mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 13:27:00 -05:00
Compare commits
28 Commits
88c16be231
...
7feb7c462d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7feb7c462d | ||
|
|
7485477e26 | ||
|
|
1277835775 | ||
|
|
f2fccdf190 | ||
|
|
beac6ddfd8 | ||
|
|
6e0c3121be | ||
|
|
691f018e56 | ||
|
|
6697d8890b | ||
|
|
95f94cc799 | ||
|
|
cb3a9ddf3f | ||
|
|
6d30131f2c | ||
|
|
5737e5953d | ||
|
|
789d5db37a | ||
|
|
b8891fc65f | ||
|
|
331329d1ec | ||
|
|
120f215cad | ||
|
|
707546f279 | ||
|
|
b67353eff9 | ||
|
|
2cad07c198 | ||
|
|
30997cb615 | ||
|
|
0ee6e8c820 | ||
|
|
1a8171f918 | ||
|
|
ffebd5ce01 | ||
|
|
97bf980e45 | ||
|
|
3beeb91c7f | ||
|
|
25e6fdb496 | ||
|
|
0331e2087a | ||
|
|
1511fcfcfe |
4
.replit
4
.replit
@@ -62,10 +62,6 @@ externalPort = 3000
|
||||
localPort = 45245
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 45563
|
||||
externalPort = 3002
|
||||
|
||||
[deployment]
|
||||
deploymentTarget = "autoscale"
|
||||
run = [
|
||||
|
||||
97
apps/core/middleware/security_headers.py
Normal file
97
apps/core/middleware/security_headers.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Modern Security Headers Middleware for ThrillWiki
|
||||
Implements Content Security Policy and other modern security headers.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import base64
|
||||
from django.conf import settings
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
Middleware to add modern security headers to all responses.
|
||||
"""
|
||||
|
||||
def _generate_nonce(self):
|
||||
"""Generate a cryptographically secure nonce for CSP."""
|
||||
# Generate 16 random bytes and encode as base64
|
||||
return base64.b64encode(secrets.token_bytes(16)).decode('ascii')
|
||||
|
||||
def _modify_csp_with_nonce(self, csp_policy, nonce):
|
||||
"""Modify CSP policy to include nonce for script-src."""
|
||||
if not csp_policy:
|
||||
return csp_policy
|
||||
|
||||
# Look for script-src directive and add nonce
|
||||
directives = csp_policy.split(';')
|
||||
modified_directives = []
|
||||
|
||||
for directive in directives:
|
||||
directive = directive.strip()
|
||||
if directive.startswith('script-src '):
|
||||
# Add nonce to script-src directive
|
||||
directive += f" 'nonce-{nonce}'"
|
||||
modified_directives.append(directive)
|
||||
|
||||
return '; '.join(modified_directives)
|
||||
|
||||
def process_request(self, request):
|
||||
"""Generate and store nonce for this request."""
|
||||
# Generate a nonce for this request
|
||||
nonce = self._generate_nonce()
|
||||
# Store it in request so templates can access it
|
||||
request.csp_nonce = nonce
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
"""Add security headers to the response."""
|
||||
|
||||
# Content Security Policy with nonce support
|
||||
if hasattr(settings, 'SECURE_CONTENT_SECURITY_POLICY'):
|
||||
csp_policy = settings.SECURE_CONTENT_SECURITY_POLICY
|
||||
# Apply nonce if we have one for this request
|
||||
if hasattr(request, 'csp_nonce'):
|
||||
csp_policy = self._modify_csp_with_nonce(csp_policy, request.csp_nonce)
|
||||
response['Content-Security-Policy'] = csp_policy
|
||||
|
||||
# Cross-Origin Opener Policy
|
||||
if hasattr(settings, 'SECURE_CROSS_ORIGIN_OPENER_POLICY'):
|
||||
response['Cross-Origin-Opener-Policy'] = settings.SECURE_CROSS_ORIGIN_OPENER_POLICY
|
||||
|
||||
# Referrer Policy
|
||||
if hasattr(settings, 'SECURE_REFERRER_POLICY'):
|
||||
response['Referrer-Policy'] = settings.SECURE_REFERRER_POLICY
|
||||
|
||||
# Permissions Policy
|
||||
if hasattr(settings, 'SECURE_PERMISSIONS_POLICY'):
|
||||
response['Permissions-Policy'] = settings.SECURE_PERMISSIONS_POLICY
|
||||
|
||||
# Additional security headers
|
||||
response['X-Content-Type-Options'] = 'nosniff'
|
||||
response['X-Frame-Options'] = getattr(settings, 'X_FRAME_OPTIONS', 'DENY')
|
||||
response['X-XSS-Protection'] = '1; mode=block'
|
||||
|
||||
# Cache Control headers for better performance
|
||||
# Prevent caching of HTML pages to ensure users get fresh content
|
||||
if response.get('Content-Type', '').startswith('text/html'):
|
||||
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response['Pragma'] = 'no-cache'
|
||||
response['Expires'] = '0'
|
||||
|
||||
# Strict Transport Security (if SSL is enabled)
|
||||
if getattr(settings, 'SECURE_SSL_REDIRECT', False):
|
||||
hsts_seconds = getattr(settings, 'SECURE_HSTS_SECONDS', 31536000)
|
||||
hsts_include_subdomains = getattr(settings, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', True)
|
||||
hsts_preload = getattr(settings, 'SECURE_HSTS_PRELOAD', False)
|
||||
|
||||
hsts_header = f'max-age={hsts_seconds}'
|
||||
if hsts_include_subdomains:
|
||||
hsts_header += '; includeSubDomains'
|
||||
if hsts_preload:
|
||||
hsts_header += '; preload'
|
||||
|
||||
response['Strict-Transport-Security'] = hsts_header
|
||||
|
||||
return response
|
||||
@@ -336,6 +336,7 @@ class Park(TrackedModel):
|
||||
|
||||
# Try pghistory events
|
||||
print("Searching pghistory events")
|
||||
historical_event = None
|
||||
event_model = getattr(cls, "event_model", None)
|
||||
if event_model:
|
||||
historical_event = (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load static %}
|
||||
{% load cotton %}
|
||||
|
||||
{% if error %}
|
||||
<div class="p-4" data-testid="park-list-error">
|
||||
@@ -11,140 +12,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
{% for park in object_list|default:parks %}
|
||||
{% if view_mode == 'list' %}
|
||||
{# Enhanced List View Item #}
|
||||
<article class="group 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 hover:shadow-xl transition-all duration-300 transform hover:scale-[1.02] overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
{# Main Content Section #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h2 class="text-xl lg:text-2xl font-bold">
|
||||
<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">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{# Status Badge #}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
|
||||
{% if park.status == 'operating' %}bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800
|
||||
{% elif park.status == 'closed' %}bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800
|
||||
{% elif park.status == 'seasonal' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800
|
||||
{% else %}bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="text-base font-medium text-gray-600 dark:text-gray-400 mb-3">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.description %}
|
||||
<p class="text-gray-600 dark:text-gray-400 line-clamp-2 mb-4">
|
||||
{{ park.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Stats Section #}
|
||||
{% if park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center space-x-6 text-sm">
|
||||
{% 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-lg border border-blue-200/50 dark:border-blue-800/50">
|
||||
<svg class="w-5 h-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="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-semibold text-blue-700 dark:text-blue-300">{{ park.ride_count }}</span>
|
||||
<span class="text-blue-600 dark:text-blue-400">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-lg border border-purple-200/50 dark:border-purple-800/50">
|
||||
<svg class="w-5 h-5 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="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-purple-700 dark:text-purple-300">{{ park.coaster_count }}</span>
|
||||
<span class="text-purple-600 dark:text-purple-400">coasters</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
{# Enhanced Grid View Item #}
|
||||
<article class="group 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 hover:shadow-xl transition-all duration-300 transform hover:scale-105 hover:-rotate-1 overflow-hidden">
|
||||
{# Card Header with Gradient #}
|
||||
<div class="h-2 bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500"></div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h2 class="text-xl font-bold line-clamp-2 flex-1 mr-3">
|
||||
<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">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{# Status Badge #}
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border shrink-0
|
||||
{% if park.status == 'operating' %}bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800
|
||||
{% elif park.status == 'closed' %}bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800
|
||||
{% elif park.status == 'seasonal' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800
|
||||
{% else %}bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3 truncate">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-3 mb-4">
|
||||
{{ park.description|truncatewords:15 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Stats Footer #}
|
||||
{% if park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200/50 dark:border-gray-600/50">
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
{% if park.ride_count %}
|
||||
<div class="flex items-center space-x-1 text-blue-600 dark:text-blue-400">
|
||||
<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 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-semibold">{{ park.ride_count }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.coaster_count %}
|
||||
<div class="flex items-center space-x-1 text-purple-600 dark:text-purple-400">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold">{{ park.coaster_count }}</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-200">
|
||||
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" 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 %}
|
||||
<c-park_card park=park view_mode=view_mode />
|
||||
{% empty %}
|
||||
<div class="{% if view_mode == 'list' %}w-full{% else %}col-span-full{% endif %} p-12 text-center" data-testid="no-parks-found">
|
||||
<div class="mx-auto w-24 h-24 text-gray-300 dark:text-gray-600 mb-6">
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Enhanced ThrillWiki Header Icons Sizing Prompt
|
||||
|
||||
```xml
|
||||
<instructions>
|
||||
Increase the size of the theme toggle icon and user profile icon in ThrillWiki's header navigation. The icons should be more prominent and touch-friendly while maintaining visual harmony with the existing Django Cotton header component design. Update the CSS classes and ensure proper scaling across different screen sizes using ThrillWiki's responsive design patterns.
|
||||
</instructions>
|
||||
|
||||
<thrillwiki_context>
|
||||
ThrillWiki uses Django Cotton templating for the header component, likely located in a `header.html` template or Cotton component. The header contains navigation elements, theme toggle functionality (probably using AlpineJS for state management), and user authentication status indicators. The current icon sizing may be using utility classes or custom CSS within the Django project structure.
|
||||
|
||||
Technologies involved:
|
||||
- Django Cotton for templating
|
||||
- AlpineJS for theme toggle interactivity
|
||||
- CSS/Tailwind for styling and responsive design
|
||||
- Responsive design patterns for mobile usability
|
||||
</thrillwiki_context>
|
||||
|
||||
<example>
|
||||
Current header structure likely resembles:
|
||||
```html
|
||||
<!-- Django Cotton header component -->
|
||||
<header class="header-container">
|
||||
<nav class="nav-wrapper">
|
||||
<!-- Theme toggle icon (current: small) -->
|
||||
<button @click="toggleTheme()" class="theme-toggle">
|
||||
<svg class="w-4 h-4"><!-- Theme icon --></svg>
|
||||
</button>
|
||||
|
||||
<!-- User profile icon (current: small) -->
|
||||
<div class="user-menu">
|
||||
<svg class="w-4 h-4"><!-- User icon --></svg>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
```
|
||||
|
||||
Enhanced version should increase to:
|
||||
```html
|
||||
<!-- Updated with larger icons -->
|
||||
<button @click="toggleTheme()" class="theme-toggle">
|
||||
<svg class="w-6 h-6 md:w-7 md:h-7"><!-- Larger theme icon --></svg>
|
||||
</button>
|
||||
|
||||
<div class="user-menu">
|
||||
<svg class="w-6 h-6 md:w-7 md:h-7"><!-- Larger user icon --></svg>
|
||||
</div>
|
||||
```
|
||||
</example>
|
||||
|
||||
<variables>
|
||||
<current_icon_size>w-4 h-4 (16px)</current_icon_size>
|
||||
<target_icon_size>w-6 h-6 (24px) mobile, w-7 h-7 (28px) desktop</target_icon_size>
|
||||
<component_location>header.html, base.html, or dedicated Cotton component</component_location>
|
||||
<styling_approach>Utility classes with responsive modifiers</styling_approach>
|
||||
<interactivity>AlpineJS theme toggle, Django user authentication</interactivity>
|
||||
</variables>
|
||||
|
||||
<thinking>
|
||||
The header icons need to be enlarged while considering:
|
||||
1. Touch accessibility (minimum 44px touch targets)
|
||||
2. Visual balance with other header elements
|
||||
3. Responsive behavior across devices
|
||||
4. Consistency with ThrillWiki's design system
|
||||
5. Proper spacing to avoid crowding
|
||||
6. Potential impact on mobile header layout
|
||||
|
||||
Development approach should:
|
||||
- Locate the header template/component
|
||||
- Identify current icon sizing classes
|
||||
- Update with responsive sizing utilities
|
||||
- Test across breakpoints
|
||||
- Ensure touch targets meet accessibility standards
|
||||
</thinking>
|
||||
|
||||
<checkpoint_approach>
|
||||
**Phase 1: Locate & Analyze**
|
||||
- Find header template in Django Cotton components
|
||||
- Identify current icon classes and sizing
|
||||
- Document existing responsive behavior
|
||||
|
||||
**Phase 2: Update Sizing**
|
||||
- Replace icon size classes with larger variants
|
||||
- Add responsive modifiers for different screen sizes
|
||||
- Maintain proper spacing and alignment
|
||||
|
||||
**Phase 3: Test & Refine**
|
||||
- Test header layout on mobile, tablet, desktop
|
||||
- Verify theme toggle functionality still works
|
||||
- Check user menu interactions
|
||||
- Ensure accessibility compliance (touch targets)
|
||||
|
||||
**Phase 4: Optimize**
|
||||
- Adjust spacing if needed for visual balance
|
||||
- Confirm consistency with ThrillWiki design patterns
|
||||
- Test with different user states (logged in/out)
|
||||
</checkpoint_approach>
|
||||
|
||||
<debugging_context>
|
||||
Common issues to watch for:
|
||||
- Icons becoming too large and breaking header layout
|
||||
- Responsive breakpoints causing icon jumping
|
||||
- AlpineJS theme toggle losing functionality after DOM changes
|
||||
- User menu positioning issues with larger icons
|
||||
- Touch target overlapping with adjacent elements
|
||||
|
||||
Django/HTMX considerations:
|
||||
- Ensure icon changes don't break HTMX partial updates
|
||||
- Verify Django Cotton component inheritance
|
||||
- Check if icons are SVGs, icon fonts, or images
|
||||
</debugging_context>
|
||||
|
||||
<testing_strategy>
|
||||
1. **Visual Testing**: Check header appearance across screen sizes
|
||||
2. **Functional Testing**: Verify theme toggle and user menu still work
|
||||
3. **Accessibility Testing**: Confirm touch targets meet 44px minimum
|
||||
4. **Cross-browser Testing**: Ensure consistent rendering
|
||||
5. **Mobile Testing**: Test on actual mobile devices for usability
|
||||
</testing_strategy>
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Enhanced ThrillWiki Park Listing Page - Optimized Prompt
|
||||
|
||||
```xml
|
||||
<instructions>
|
||||
Create an improved park listing page for ThrillWiki that prioritizes user experience with intelligent filtering, real-time autocomplete search, and clean pagination. Build using Django Cotton templates, HTMX for dynamic interactions, and AlpineJS for reactive filtering components. Focus on accessibility, performance, and intuitive navigation without infinite scroll complexity.
|
||||
|
||||
Key requirements:
|
||||
- Fast, responsive autocomplete search leveraging available database fields
|
||||
- Multi-criteria filtering with live updates based on existing Park model attributes
|
||||
- Clean pagination with proper Django pagination controls
|
||||
- Optimized park card layout using CloudFlare Images
|
||||
- Accessible design following WCAG guidelines
|
||||
- Mobile-first responsive approach
|
||||
</instructions>
|
||||
|
||||
<thrillwiki_context>
|
||||
Working with ThrillWiki's existing Django infrastructure:
|
||||
- Unknown Park model structure - will need to examine current fields and relationships
|
||||
- Potential integration with PostGIS if geographic data exists
|
||||
- Unknown filtering criteria - will discover available Park attributes for filtering
|
||||
- Unknown review/rating system - will check if rating data is available
|
||||
|
||||
The page should integrate with:
|
||||
- Django Cotton templating system for consistent components
|
||||
- HTMX endpoints for search and filtering without full page reloads
|
||||
- AlpineJS for client-side filter state management
|
||||
- CloudFlare Images for optimized park images (if image fields exist)
|
||||
- Existing ThrillWiki URL patterns and view structure
|
||||
</thrillwiki_context>
|
||||
|
||||
<example>
|
||||
Park listing page structure (adaptable based on discovered model fields):
|
||||
```html
|
||||
<!-- Search and Filter Section -->
|
||||
<div x-data="parkFilters()" class="park-search-container">
|
||||
<c-search-autocomplete
|
||||
hx-get="/api/parks/search/"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
placeholder="Search parks..."
|
||||
/>
|
||||
|
||||
<c-filter-panel>
|
||||
<!-- Filters will be determined by available Park model fields -->
|
||||
<div class="filter-options" x-show="showFilters">
|
||||
<!-- Dynamic filter generation based on model inspection -->
|
||||
</div>
|
||||
</c-filter-panel>
|
||||
</div>
|
||||
|
||||
<!-- Results Section -->
|
||||
<div id="park-results" hx-get="/parks/list/" class="park-grid">
|
||||
<!-- Park cards will display available fields from Park model -->
|
||||
<c-park-card v-for="park in parks" :park="park" :key="park.id">
|
||||
<!-- Card content based on discovered model structure -->
|
||||
</c-park-card>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<c-pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
hx-get="/parks/list/"
|
||||
hx-target="#park-results"
|
||||
/>
|
||||
```
|
||||
|
||||
Expected development approach:
|
||||
1. Examine existing Park model to understand available fields
|
||||
2. Identify searchable and filterable attributes
|
||||
3. Design search/filter UI based on discovered data structure
|
||||
4. Implement pagination with Django's built-in Paginator
|
||||
5. Optimize queries and add HTMX interactions
|
||||
</example>
|
||||
|
||||
<variables>
|
||||
<django_models>Park (structure to be discovered), related models TBD</django_models>
|
||||
<search_technologies>PostgreSQL full-text search, PostGIS if geographic fields exist</search_technologies>
|
||||
<ui_framework>Django Cotton + HTMX + AlpineJS</ui_framework>
|
||||
<image_optimization>CloudFlare Images (if image fields exist in Park model)</image_optimization>
|
||||
<pagination_style>Traditional pagination with Django Paginator</pagination_style>
|
||||
<accessibility_level>WCAG 2.1 AA compliance</accessibility_level>
|
||||
<discovery_required>Park model fields, existing views/URLs, current template structure</discovery_required>
|
||||
</variables>
|
||||
|
||||
<thinking>
|
||||
Since we don't know the Park model structure, the development approach needs to be discovery-first:
|
||||
|
||||
1. **Model Discovery**: First step must be examining the Park model to understand:
|
||||
- Available fields for display (name, description, etc.)
|
||||
- Searchable text fields
|
||||
- Filterable attributes (categories, status, etc.)
|
||||
- Geographic data (if PostGIS integration exists)
|
||||
- Image fields (for CloudFlare Images optimization)
|
||||
- Relationship fields (foreign keys, many-to-many)
|
||||
|
||||
2. **Search Strategy**: Build search functionality based on discovered text fields
|
||||
- Use Django's full-text search capabilities
|
||||
- Add PostGIS spatial search if location fields exist
|
||||
- Implement autocomplete based on available searchable fields
|
||||
|
||||
3. **Filter Design**: Create filters dynamically based on model attributes
|
||||
- Categorical fields become dropdown/checkbox filters
|
||||
- Numeric fields become range filters
|
||||
- Boolean fields become toggle filters
|
||||
- Date fields become date range filters
|
||||
|
||||
4. **Display Optimization**: Design park cards using available fields
|
||||
- Prioritize essential information (name, basic details)
|
||||
- Use CloudFlare Images if image fields exist
|
||||
- Handle cases where optional fields might be empty
|
||||
|
||||
5. **Performance Considerations**:
|
||||
- Use Django's select_related and prefetch_related based on discovered relationships
|
||||
- Add database indexes for commonly searched/filtered fields
|
||||
- Implement efficient pagination
|
||||
|
||||
The checkpoint approach will be:
|
||||
- Checkpoint 1: Discover and document Park model structure
|
||||
- Checkpoint 2: Build basic listing with pagination
|
||||
- Checkpoint 3: Add search functionality based on available fields
|
||||
- Checkpoint 4: Implement filters based on model attributes
|
||||
- Checkpoint 5: Add HTMX interactions and optimize performance
|
||||
- Checkpoint 6: Polish UI/UX and add accessibility features
|
||||
</thinking>
|
||||
|
||||
<development_checkpoints>
|
||||
1. **Discovery Phase**: Examine Park model, existing views, and current templates
|
||||
2. **Basic Listing**: Create paginated park list with Django Cotton templates
|
||||
3. **Search Implementation**: Add autocomplete search based on available text fields
|
||||
4. **Filter System**: Build dynamic filters based on discovered model attributes
|
||||
5. **HTMX Integration**: Add dynamic interactions without page reloads
|
||||
6. **Optimization**: Performance tuning, image optimization, accessibility
|
||||
7. **Testing**: Cross-browser testing, mobile responsiveness, user experience validation
|
||||
</development_checkpoints>
|
||||
|
||||
<discovery_questions>
|
||||
Before implementation, investigate:
|
||||
1. What fields does the Park model contain?
|
||||
2. Are there geographic/location fields that could leverage PostGIS?
|
||||
3. What relationships exist (foreign keys to Location, Category, etc.)?
|
||||
4. Is there a rating/review system connected to parks?
|
||||
5. What image fields exist and how are they currently handled?
|
||||
6. What existing views and URL patterns are in place?
|
||||
7. What search functionality currently exists?
|
||||
8. What Django Cotton components are already available?
|
||||
</discovery_questions>
|
||||
```
|
||||
@@ -0,0 +1,116 @@
|
||||
Traceback (most recent call last):
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/contrib/staticfiles/handlers.py", line 80, in __call__
|
||||
return self.application(environ, start_response)
|
||||
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/wsgi.py", line 124, in __call__
|
||||
response = self.get_response(request)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/base.py", line 140, in get_response
|
||||
response = self._middleware_chain(request)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 57, in inner
|
||||
response = response_for_exception(request, exc)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 141, in response_for_exception
|
||||
response = handle_uncaught_exception(
|
||||
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 182, in handle_uncaught_exception
|
||||
return debug.technical_500_response(request, *exc_info)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django_extensions/management/technical_response.py", line 41, in null_technical_500_response
|
||||
raise exc_value.with_traceback(tb)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/exception.py", line 55, in inner
|
||||
response = get_response(request)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/core/handlers/base.py", line 220, in _get_response
|
||||
response = response.render()
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/response.py", line 114, in render
|
||||
self.content = self.rendered_content
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/response.py", line 92, in rendered_content
|
||||
return template.render(context, self._request)
|
||||
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/backends/django.py", line 107, in render
|
||||
return self.template.render(context)
|
||||
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 171, in render
|
||||
return self._render(context)
|
||||
~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 163, in _render
|
||||
return self.nodelist.render(context)
|
||||
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
|
||||
return SafeString("".join([node.render_annotated(context) for node in self]))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/loader_tags.py", line 159, in render
|
||||
return compiled_parent._render(context)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 163, in _render
|
||||
return self.nodelist.render(context)
|
||||
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
|
||||
return SafeString("".join([node.render_annotated(context) for node in self]))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/loader_tags.py", line 65, in render
|
||||
result = block.nodelist.render(context)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
|
||||
return SafeString("".join([node.render_annotated(context) for node in self]))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 243, in render
|
||||
nodelist.append(node.render_annotated(context))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django_cotton/templatetags/_component.py", line 86, in render
|
||||
output = template.render(context)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 173, in render
|
||||
return self._render(context)
|
||||
~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 163, in _render
|
||||
return self.nodelist.render(context)
|
||||
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
|
||||
return SafeString("".join([node.render_annotated(context) for node in self]))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django_cotton/templatetags/_vars.py", line 52, in render
|
||||
output = self.nodelist.render(context)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
|
||||
return SafeString("".join([node.render_annotated(context) for node in self]))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 327, in render
|
||||
return nodelist.render(context)
|
||||
~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
|
||||
return SafeString("".join([node.render_annotated(context) for node in self]))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 327, in render
|
||||
return nodelist.render(context)
|
||||
~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 1016, in render
|
||||
return SafeString("".join([node.render_annotated(context) for node in self]))
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/base.py", line 977, in render_annotated
|
||||
return self.render(context)
|
||||
~~~~~~~~~~~^^^^^^^^^
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/template/defaulttags.py", line 480, in render
|
||||
url = reverse(view_name, args=args, kwargs=kwargs, current_app=current_app)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/urls/base.py", line 98, in reverse
|
||||
resolved_url = resolver._reverse_with_prefix(view, prefix, *args, **kwargs)
|
||||
File "/home/runner/workspace/.venv/lib/python3.13/site-packages/django/urls/resolvers.py", line 831, in _reverse_with_prefix
|
||||
raise NoReverseMatch(msg)
|
||||
django.urls.exceptions.NoReverseMatch: Reverse for 'park_detail' with arguments '('',)' not found. 1 pattern(s) tried: ['parks/(?P<slug>[-a-zA-Z0-9_]+)/\\Z']
|
||||
@@ -42,5 +42,49 @@ All API directory structures MUST match URL nesting patterns. No exceptions. If
|
||||
- Need to extend type classifications to all ride categories
|
||||
- Maintain clear separation between type (how it works) and model (what product it is)
|
||||
|
||||
## UI Component Standards
|
||||
|
||||
### DJANGO-COTTON COMPONENT REQUIREMENT
|
||||
**MANDATORY RULE**: All new card components and reusable UI patterns MUST be implemented using Django Cotton components.
|
||||
|
||||
#### Component Organization
|
||||
- **Location**: All Django Cotton components must be stored in `templates/cotton/`
|
||||
- **Naming**: Component files must use snake_case naming (e.g., `park_card.html`, `ride_card.html`)
|
||||
- **Documentation**: Every component must include comprehensive documentation comments with usage examples
|
||||
- **Parameters**: Components must use `<c-vars>` for parameter definition with sensible defaults
|
||||
|
||||
#### Standardized Card Components
|
||||
The following standardized components are available and MUST be used instead of custom implementations:
|
||||
|
||||
##### Park Card Component (`templates/cotton/park_card.html`)
|
||||
- **Usage**: `<c-park_card park=park view_mode="grid" />`
|
||||
- **Features**: Supports both list and grid modes, status badges, operator info, stats
|
||||
- **Required for**: All park listing and display use cases
|
||||
|
||||
##### Ride Card Component (`templates/cotton/ride_card.html`)
|
||||
- **Usage**: `<c-ride_card ride=ride />`
|
||||
- **Features**: Image handling, status badges, stats grid, special features, manufacturer info
|
||||
- **Required for**: All ride listing and display use cases
|
||||
|
||||
#### Implementation Requirements
|
||||
- **No Duplication**: Do not create new card templates that duplicate existing Cotton component functionality
|
||||
- **Consistent Styling**: All components must follow established Tailwind CSS patterns and design system
|
||||
- **Responsive Design**: Components must include proper responsive breakpoints and mobile-first design
|
||||
- **Accessibility**: All components must include proper ARIA labels and semantic HTML
|
||||
- **Performance**: Components should be optimized for rendering performance and minimize template complexity
|
||||
|
||||
#### Legacy Template Migration
|
||||
- **Existing Templates**: When modifying existing templates, refactor them to use Cotton components
|
||||
- **Gradual Migration**: Priority should be given to high-traffic pages and frequently modified templates
|
||||
- **Testing Required**: All migrations must include thorough testing to ensure functionality preservation
|
||||
|
||||
#### Exceptions
|
||||
The only acceptable reasons to NOT use Django Cotton components are:
|
||||
- Technical limitations that prevent Cotton usage in specific contexts
|
||||
- Performance-critical pages where component overhead is measurably problematic
|
||||
- Temporary prototyping (with clear migration path to Cotton components)
|
||||
|
||||
All exceptions must be documented with justification and include a plan for eventual Cotton migration.
|
||||
|
||||
## Enforcement
|
||||
These rules are MANDATORY and must be followed in all development work. Any violation should be immediately corrected.
|
||||
|
||||
@@ -122,6 +122,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.cache.UpdateCacheMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware", # CORS middleware for API
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Modern security headers
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
|
||||
@@ -34,3 +34,37 @@ SESSION_COOKIE_SAMESITE = env("SESSION_COOKIE_SAMESITE", default="Lax")
|
||||
CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False)
|
||||
CSRF_COOKIE_HTTPONLY = env.bool("CSRF_COOKIE_HTTPONLY", default=True)
|
||||
CSRF_COOKIE_SAMESITE = env("CSRF_COOKIE_SAMESITE", default="Lax")
|
||||
|
||||
# Content Security Policy (CSP) - Tightened security without unsafe directives
|
||||
SECURE_CONTENT_SECURITY_POLICY = env(
|
||||
"SECURE_CONTENT_SECURITY_POLICY",
|
||||
default=(
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' "
|
||||
"https://unpkg.com https://cdnjs.cloudflare.com; "
|
||||
"style-src 'self' "
|
||||
"https://fonts.googleapis.com https://cdnjs.cloudflare.com; "
|
||||
"img-src 'self' data: https: blob:; "
|
||||
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; "
|
||||
"connect-src 'self'; "
|
||||
"media-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"frame-src 'none'; "
|
||||
"worker-src 'self'; "
|
||||
"manifest-src 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"upgrade-insecure-requests;"
|
||||
)
|
||||
)
|
||||
|
||||
# Additional modern security headers
|
||||
SECURE_CROSS_ORIGIN_OPENER_POLICY = env("SECURE_CROSS_ORIGIN_OPENER_POLICY", default="same-origin")
|
||||
SECURE_REFERRER_POLICY = env("SECURE_REFERRER_POLICY", default="strict-origin-when-cross-origin")
|
||||
SECURE_PERMISSIONS_POLICY = env(
|
||||
"SECURE_PERMISSIONS_POLICY",
|
||||
default="geolocation=(), camera=(), microphone=(), payment=()"
|
||||
)
|
||||
|
||||
# X-Frame-Options alternative - more flexible
|
||||
X_FRAME_OPTIONS = env("X_FRAME_OPTIONS", default="DENY")
|
||||
|
||||
@@ -56,6 +56,7 @@ Migrations: All applied successfully (including circular dependency resolution)
|
||||
✅ **Spatial Data Support**: GeoDjango Point objects and spatial functionality working correctly
|
||||
✅ **CloudflareImages Integration**: Avatar functionality preserved with proper foreign key relationships
|
||||
✅ **Django-Cotton Integration**: Modern component-based template system with EXACT visual preservation
|
||||
✅ **Cotton Components**: Standardized park_card.html and ride_card.html components with full feature support
|
||||
|
||||
### API Endpoints Available
|
||||
- `/api/v1/parks/` - Parks API with spatial data
|
||||
|
||||
29
static/css/inline-styles.css
Normal file
29
static/css/inline-styles.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/* Inline styles that were moved from the base template for CSP compliance */
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 12rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
16
static/js/theme.js
Normal file
16
static/js/theme.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Theme management script
|
||||
* Prevents flash of wrong theme by setting theme class immediately
|
||||
*/
|
||||
(function() {
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme) {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
@@ -5,30 +5,68 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content="{{ csrf_token }}" />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<title>{% block title %}ThrillWiki{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}Your ultimate guide to theme parks and attractions worldwide. Discover thrilling rides, explore amazing parks, and share your adventures with fellow enthusiasts.{% endblock %}" />
|
||||
<meta name="keywords" content="{% block meta_keywords %}theme parks, roller coasters, attractions, rides, amusement parks, Disney, Universal, Cedar Point{% endblock %}" />
|
||||
<meta name="author" content="ThrillWiki" />
|
||||
<meta name="robots" content="{% block meta_robots %}index, follow{% endblock %}" />
|
||||
<link rel="canonical" href="{% block canonical_url %}{{ request.scheme }}://{{ request.get_host }}{{ request.path }}{% endblock %}" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}" />
|
||||
<meta property="og:url" content="{% block og_url %}{{ request.build_absolute_uri|default:'' }}{% endblock %}" />
|
||||
<meta property="og:title" content="{% block og_title %}ThrillWiki{% endblock %}" />
|
||||
<meta property="og:description" content="{% block og_description %}Your ultimate guide to theme parks and attractions worldwide. Discover thrilling rides, explore amazing parks, and share your adventures with fellow enthusiasts.{% endblock %}" />
|
||||
<meta property="og:image" content="{% block og_image %}{% load static %}{{ request.scheme }}://{{ request.get_host }}{% static 'images/placeholders/default-park.jpg' %}{% endblock %}" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:site_name" content="ThrillWiki" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="{% block twitter_card %}summary_large_image{% endblock %}" />
|
||||
<meta name="twitter:url" content="{% block twitter_url %}{{ request.build_absolute_uri|default:'' }}{% endblock %}" />
|
||||
<meta name="twitter:title" content="{% block twitter_title %}ThrillWiki{% endblock %}" />
|
||||
<meta name="twitter:description" content="{% block twitter_description %}Your ultimate guide to theme parks and attractions worldwide. Discover thrilling rides, explore amazing parks, and share your adventures with fellow enthusiasts.{% endblock %}" />
|
||||
<meta name="twitter:image" content="{% block twitter_image %}{% load static %}{{ request.scheme }}://{{ request.get_host }}{% static 'images/placeholders/default-park.jpg' %}{% endblock %}" />
|
||||
<meta name="twitter:creator" content="@ThrillWiki" />
|
||||
<meta name="twitter:site" content="@ThrillWiki" />
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<!-- Resource Hints for Performance -->
|
||||
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
|
||||
<link rel="dns-prefetch" href="//fonts.gstatic.com" />
|
||||
<link rel="dns-prefetch" href="//unpkg.com" />
|
||||
<link rel="dns-prefetch" href="//cdnjs.cloudflare.com" />
|
||||
{% block extra_dns_prefetch %}{% endblock %}
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
{% block extra_preconnect %}{% endblock %}
|
||||
|
||||
<!-- Preload Critical Resources -->
|
||||
{% block critical_resources %}
|
||||
<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style" />
|
||||
<link rel="preload" href="{% static 'js/theme.js' %}?v={{ version|default:'1.0' }}" as="script" />
|
||||
<link rel="preload" href="{% static 'js/alpine.min.js' %}?v={{ version|default:'1.0' }}" as="script" />
|
||||
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style" />
|
||||
{% endblock %}
|
||||
|
||||
<!-- Module Preload for Modern Browsers -->
|
||||
{% block module_preload %}{% endblock %}
|
||||
|
||||
<!-- Google Fonts with performance optimizations -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Prevent flash of wrong theme -->
|
||||
<script>
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme) {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
</script>
|
||||
<script src="{% static 'js/theme.js' %}?v={{ version|default:'1.0' }}"></script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6" integrity="sha384-FYYOtJ1BoGZ1EZbdLzmaydhwRKh5zxCwWA0jzNEzSJYTzFxN2wjCjOj3gLyQYZGC" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Alpine.js Components (must load before Alpine.js) -->
|
||||
<script src="{% static 'js/alpine-components.js' %}?v={{ version|default:'1.0' }}"></script>
|
||||
@@ -43,75 +81,85 @@
|
||||
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'css/components.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'css/alerts.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'css/inline-styles.css' %}" rel="stylesheet" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||
integrity="sha512-9usAa10IRO0HhonpyAIVpjrylPvoDwiPUiKdWk5t3PyolY1cOd4DSE0Ga+ri4AuTroPR5aQvXU9xC6qOPnzFeg=="
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<style>
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 12rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Structured Data (JSON-LD) -->
|
||||
{% block structured_data %}
|
||||
<script type="application/ld+json" nonce="{{ request.csp_nonce }}">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "ThrillWiki",
|
||||
"description": "Your ultimate guide to theme parks and attractions worldwide",
|
||||
"url": "{{ request.scheme }}://{{ request.get_host }}",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "{{ request.scheme }}://{{ request.get_host }}/search/?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "ThrillWiki"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body
|
||||
class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white"
|
||||
{% block body_attributes %}{% endblock %}
|
||||
>
|
||||
<!-- Skip to content link for accessibility -->
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">Skip to main content</a>
|
||||
<!-- Enhanced Header -->
|
||||
{% include 'components/layout/enhanced_header.html' %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% if messages %}
|
||||
<div class="fixed top-0 right-0 z-50 p-4 space-y-4">
|
||||
<div class="fixed top-0 right-0 z-50 p-4 space-y-4" role="alert" aria-live="polite" aria-label="Notifications">
|
||||
{% for message in messages %}
|
||||
<div
|
||||
class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
||||
role="alert"
|
||||
aria-describedby="alert-{{ forloop.counter }}"
|
||||
>
|
||||
{{ message }}
|
||||
<span id="alert-{{ forloop.counter }}">{{ message }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container flex-grow px-6 py-8 mx-auto">
|
||||
<main id="main-content" class="container flex-grow px-6 py-8 mx-auto" role="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer
|
||||
class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50"
|
||||
role="contentinfo"
|
||||
aria-label="Site footer"
|
||||
>
|
||||
<div class="container px-6 py-6 mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<p>© {% now "Y" %} ThrillWiki. All rights reserved.</p>
|
||||
</div>
|
||||
<div class="space-x-4">
|
||||
<nav class="space-x-4" role="navigation" aria-label="Footer links">
|
||||
<a
|
||||
href="{% url 'terms' %}"
|
||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||
@@ -122,7 +170,7 @@
|
||||
class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary"
|
||||
>Privacy</a
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -137,10 +185,6 @@
|
||||
<script src="{% static 'js/main.js' %}?v={{ version|default:'1.0' }}"></script>
|
||||
<script src="{% static 'js/alerts.js' %}?v={{ version|default:'1.0' }}"></script>
|
||||
|
||||
<!-- Cache control meta tag -->
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -180,8 +180,8 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
@click="toggleTheme()"
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-12 w-12"
|
||||
>
|
||||
<i class="fas fa-sun h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"></i>
|
||||
<i class="fas fa-moon absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"></i>
|
||||
<i class="fas fa-sun h-5 w-5 md:h-7 md:w-7 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0 text-lg"></i>
|
||||
<i class="fas fa-moon absolute h-5 w-5 md:h-7 md:w-7 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 text-lg"></i>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
|
||||
@@ -204,7 +204,7 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<i class="fas fa-user h-6 w-6"></i>
|
||||
<i class="fas fa-user h-5 w-5 text-lg"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
@@ -280,8 +280,8 @@ Includes: Browse menu, advanced search, theme toggle, user dropdown, mobile menu
|
||||
@click="toggleTheme()"
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
|
||||
>
|
||||
<i class="fas fa-sun h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"></i>
|
||||
<i class="fas fa-moon absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"></i>
|
||||
<i class="fas fa-sun h-6 w-6 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"></i>
|
||||
<i class="fas fa-moon absolute h-6 w-6 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
222
templates/cotton/park_card.html
Normal file
222
templates/cotton/park_card.html
Normal file
@@ -0,0 +1,222 @@
|
||||
{% comment %}
|
||||
Park Card Component - Django Cotton Version
|
||||
|
||||
A reusable park card component that supports both list and grid view modes.
|
||||
Includes status badges, operator information, description, and ride/coaster statistics.
|
||||
|
||||
Usage Examples:
|
||||
|
||||
List View:
|
||||
<c-park_card
|
||||
park=park
|
||||
view_mode="list"
|
||||
/>
|
||||
|
||||
Grid View:
|
||||
<c-park_card
|
||||
park=park
|
||||
view_mode="grid"
|
||||
/>
|
||||
|
||||
With custom CSS classes:
|
||||
<c-park_card
|
||||
park=park
|
||||
view_mode="grid"
|
||||
class="custom-class"
|
||||
/>
|
||||
|
||||
Parameters:
|
||||
- park: Park object (required)
|
||||
- view_mode: "list" or "grid" (default: "grid")
|
||||
- class: Additional CSS classes (optional)
|
||||
|
||||
Features:
|
||||
- Responsive design with hover effects
|
||||
- Status badge with proper color coding
|
||||
- Operator information display
|
||||
- Description with automatic truncation (30 words for list, 15 for grid)
|
||||
- Ride and coaster count statistics with icons
|
||||
- Gradient effects and modern styling
|
||||
- Links to park detail pages
|
||||
{% endcomment %}
|
||||
|
||||
<c-vars
|
||||
park
|
||||
view_mode="grid"
|
||||
class=""
|
||||
/>
|
||||
|
||||
{% if park %}
|
||||
{% if view_mode == 'list' %}
|
||||
{# Enhanced List View Item #}
|
||||
<article class="group 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 hover:shadow-xl transition-all duration-300 transform hover:scale-[1.02] overflow-hidden {{ class }}">
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
|
||||
{# Main Content Section #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<h2 class="text-xl lg:text-2xl font-bold">
|
||||
{% 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">
|
||||
{{ 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 %}
|
||||
</h2>
|
||||
|
||||
{# Status Badge #}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
|
||||
{% if park.status == 'operating' or park.status == 'OPERATING' %}bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800
|
||||
{% elif park.status == 'closed' or park.status == 'CLOSED_PERM' or park.status == 'closed_permanently' or park.status == 'closed_perm' %}bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800
|
||||
{% elif park.status == 'seasonal' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800
|
||||
{% elif park.status == 'closed_temp' or park.status == 'CLOSED_TEMP' %}bg-yellow-50 text-yellow-700 border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800
|
||||
{% elif park.status == 'under_construction' or park.status == 'UNDER_CONSTRUCTION' %}bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800
|
||||
{% elif park.status == 'demolished' or park.status == 'DEMOLISHED' %}bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600
|
||||
{% elif park.status == 'relocated' or park.status == 'RELOCATED' %}bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800
|
||||
{% else %}bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="text-base font-medium text-gray-600 dark:text-gray-400 mb-3">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.description %}
|
||||
<p class="text-gray-600 dark:text-gray-400 line-clamp-2 mb-4">
|
||||
{{ park.description|truncatewords:30 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Stats Section #}
|
||||
{% if park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center space-x-6 text-sm">
|
||||
{% 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-lg border border-blue-200/50 dark:border-blue-800/50">
|
||||
<svg class="w-5 h-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="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-semibold text-blue-700 dark:text-blue-300">{{ park.ride_count }}</span>
|
||||
<span class="text-blue-600 dark:text-blue-400">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-lg border border-purple-200/50 dark:border-purple-800/50">
|
||||
<svg class="w-5 h-5 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="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-purple-700 dark:text-purple-300">{{ park.coaster_count }}</span>
|
||||
<span class="text-purple-600 dark:text-purple-400">coasters</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
{# Enhanced Grid View Item #}
|
||||
<article class="group 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 hover:shadow-xl transition-all duration-300 transform hover:scale-105 hover:-rotate-1 overflow-hidden {{ class }}">
|
||||
{# Park Image #}
|
||||
<div class="relative h-48 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500">
|
||||
{% if park.card_image %}
|
||||
<img src="{{ park.card_image.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% elif park.photos.first %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<svg class="w-16 h-16 text-white opacity-50" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Status Badge Overlay #}
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border shrink-0 bg-white/90 backdrop-blur-sm
|
||||
{% if park.status == 'operating' or park.status == 'OPERATING' %}text-green-700 border-green-200
|
||||
{% elif park.status == 'closed' or park.status == 'CLOSED_PERM' or park.status == 'closed_permanently' or park.status == 'closed_perm' %}text-red-700 border-red-200
|
||||
{% elif park.status == 'seasonal' %}text-blue-700 border-blue-200
|
||||
{% elif park.status == 'closed_temp' or park.status == 'CLOSED_TEMP' %}text-yellow-700 border-yellow-200
|
||||
{% elif park.status == 'under_construction' or park.status == 'UNDER_CONSTRUCTION' %}text-blue-700 border-blue-200
|
||||
{% elif park.status == 'demolished' or park.status == 'DEMOLISHED' %}text-gray-700 border-gray-200
|
||||
{% elif park.status == 'relocated' or park.status == 'RELOCATED' %}text-purple-700 border-purple-200
|
||||
{% else %}text-gray-700 border-gray-200{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-bold line-clamp-2 mb-2">
|
||||
{% 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">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-900 dark:text-white">
|
||||
{{ park.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if park.operator %}
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3 truncate">
|
||||
{{ park.operator.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-3 mb-4">
|
||||
{{ park.description|truncatewords:15 }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{# Stats Footer #}
|
||||
{% if park.ride_count or park.coaster_count %}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200/50 dark:border-gray-600/50">
|
||||
<div class="flex items-center space-x-4 text-sm">
|
||||
{% if park.ride_count %}
|
||||
<div class="flex items-center space-x-1 text-blue-600 dark:text-blue-400">
|
||||
<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 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-semibold">{{ park.ride_count }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.coaster_count %}
|
||||
<div class="flex items-center space-x-1 text-purple-600 dark:text-purple-400">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold">{{ park.coaster_count }}</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-200">
|
||||
<svg class="w-5 h-5 transform group-hover:translate-x-1 transition-transform duration-200" 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 %}
|
||||
246
templates/cotton/ride_card.html
Normal file
246
templates/cotton/ride_card.html
Normal file
@@ -0,0 +1,246 @@
|
||||
{% comment %}
|
||||
Ride Card Component - Django Cotton Version
|
||||
|
||||
A comprehensive ride card component with image handling, status badges, feature displays,
|
||||
and robust URL generation that supports both global and park-specific URL patterns.
|
||||
Includes graceful handling of missing slugs to prevent 500 errors.
|
||||
|
||||
Usage Examples:
|
||||
|
||||
Basic usage (default global URL pattern):
|
||||
<c-ride_card ride=ride />
|
||||
|
||||
Park-specific URL pattern:
|
||||
<c-ride_card ride=ride url_variant="park" />
|
||||
|
||||
With custom CSS classes:
|
||||
<c-ride_card
|
||||
ride=ride
|
||||
url_variant="global"
|
||||
class="custom-class"
|
||||
/>
|
||||
|
||||
With custom image fallback:
|
||||
<c-ride_card
|
||||
ride=ride
|
||||
url_variant="park"
|
||||
fallback_gradient="from-red-500 to-blue-600"
|
||||
/>
|
||||
|
||||
Parameters:
|
||||
- ride: Ride object (required)
|
||||
- url_variant: URL pattern type - 'global' (default) or 'park' (optional)
|
||||
- class: Additional CSS classes (optional)
|
||||
- fallback_gradient: Custom gradient for image fallback (default: "from-blue-500 to-purple-600")
|
||||
|
||||
URL Pattern Logic:
|
||||
- If url_variant='global' and ride.slug exists: uses rides:ride_detail with ride.slug
|
||||
- If url_variant='park' and both ride.park.slug and ride.slug exist: uses parks:rides:ride_detail with ride.park.slug, ride.slug
|
||||
- If no valid URL can be generated: renders ride name as plain text (no link)
|
||||
|
||||
Features:
|
||||
- Graceful handling of missing slugs (prevents NoReverseMatch errors)
|
||||
- Support for both global and park-specific URL patterns
|
||||
- Image handling with gradient fallback backgrounds
|
||||
- Status badges with proper color coding (operating, closed_temporarily, closed_permanently, under_construction)
|
||||
- Ride name with conditional linking based on slug availability
|
||||
- Category and park information display
|
||||
- Statistics grid for height, speed, capacity, duration
|
||||
- Special features badges (inversions, launches, track_type)
|
||||
- Opening date and manufacturer/designer information
|
||||
- Responsive design with hover effects
|
||||
- Modern Tailwind styling and animations
|
||||
- Backwards compatibility with existing usage
|
||||
{% endcomment %}
|
||||
|
||||
<c-vars
|
||||
ride
|
||||
url_variant="global"
|
||||
class=""
|
||||
fallback_gradient="from-blue-500 to-purple-600"
|
||||
/>
|
||||
|
||||
{% if ride %}
|
||||
<div class="ride-card bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-all duration-300 {{ class }}">
|
||||
<!-- Ride image -->
|
||||
<div class="relative h-48 bg-gradient-to-br {{ fallback_gradient }}">
|
||||
{% if ride.card_image %}
|
||||
<img src="{{ ride.card_image.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% elif ride.photos.first %}
|
||||
<img src="{{ ride.photos.first.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<i class="fas fa-rocket text-4xl text-white opacity-50"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Status badge -->
|
||||
<div class="absolute top-3 right-3">
|
||||
{% if ride.operating_status == 'operating' or ride.operating_status == 'OPERATING' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<i class="fas fa-play-circle mr-1"></i>
|
||||
Operating
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_temporarily' or ride.operating_status == 'CLOSED_TEMP' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<i class="fas fa-pause-circle mr-1"></i>
|
||||
Temporarily Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_permanently' or ride.operating_status == 'CLOSED_PERM' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-stop-circle mr-1"></i>
|
||||
Permanently Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'under_construction' or ride.operating_status == 'UNDER_CONSTRUCTION' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<i class="fas fa-hard-hat mr-1"></i>
|
||||
Under Construction
|
||||
</span>
|
||||
{% elif ride.operating_status == 'sbno' or ride.operating_status == 'SBNO' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
<i class="fas fa-pause-circle mr-1"></i>
|
||||
SBNO
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride details -->
|
||||
<div class="p-5">
|
||||
<!-- Name and category -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{% comment %}Robust URL generation with missing slug handling{% endcomment %}
|
||||
{% if url_variant == 'park' and ride.park and ride.park.slug and ride.slug %}
|
||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
{% elif url_variant == 'global' and ride.slug %}
|
||||
<a href="{% url 'rides:ride_detail' ride.slug %}"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
{% comment %}No valid URL can be generated - render as plain text{% endcomment %}
|
||||
{{ ride.name }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 mr-2">
|
||||
{{ ride.category|default:"Ride" }}
|
||||
</span>
|
||||
{% if ride.park %}
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
{{ ride.park.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key stats grid -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
{% if ride.height %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.height }}ft</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Height</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.rollercoaster_stats.max_speed %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.rollercoaster_stats.max_speed }}mph</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Top Speed</div>
|
||||
</div>
|
||||
{% elif ride.max_speed %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.max_speed }}mph</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Max Speed</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.capacity_per_hour %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.capacity_per_hour }}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Capacity/Hr</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.duration %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.duration }}s</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Duration</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Special features -->
|
||||
{% if ride.has_inversions or ride.has_launches or ride.rollercoaster_stats.track_type or ride.track_type %}
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{% if ride.has_inversions %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<i class="fas fa-sync-alt mr-1"></i>
|
||||
{% if ride.rollercoaster_stats.number_of_inversions %}
|
||||
{{ ride.rollercoaster_stats.number_of_inversions }} Inversion{{ ride.rollercoaster_stats.number_of_inversions|pluralize }}
|
||||
{% else %}
|
||||
Inversions
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.has_launches %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-rocket mr-1"></i>
|
||||
{% if ride.rollercoaster_stats.number_of_launches %}
|
||||
{{ ride.rollercoaster_stats.number_of_launches }} Launch{{ ride.rollercoaster_stats.number_of_launches|pluralize }}
|
||||
{% else %}
|
||||
Launched
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.rollercoaster_stats.track_type %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ ride.rollercoaster_stats.track_type|title }}
|
||||
</span>
|
||||
{% elif ride.track_type %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ ride.track_type|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Opening date -->
|
||||
{% if ride.opened_date %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
Opened {{ ride.opened_date|date:"F j, Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Manufacturer and designer -->
|
||||
{% if ride.manufacturer or ride.designer %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if ride.manufacturer %}
|
||||
<div class="flex items-center mb-1">
|
||||
<i class="fas fa-industry mr-1"></i>
|
||||
<span>{{ ride.manufacturer.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if ride.designer and ride.designer != ride.manufacturer %}
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-drafting-compass mr-1"></i>
|
||||
<span>Designed by {{ ride.designer.name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,8 +1,50 @@
|
||||
{% extends 'base/base.html' %}
|
||||
{% load static %}
|
||||
{% load cotton %}
|
||||
|
||||
{% block title %}ThrillWiki - Theme Parks & Attractions Guide{% endblock %}
|
||||
|
||||
{% block meta_description %}Discover the world's best theme parks and thrilling rides. Explore amazing parks, find detailed ride information, and share your adventures with fellow theme park enthusiasts.{% endblock %}
|
||||
|
||||
{% block meta_keywords %}theme parks, roller coasters, attractions, rides, amusement parks, Disney World, Universal Studios, Cedar Point, Six Flags, thrill rides{% endblock %}
|
||||
|
||||
{% block og_title %}ThrillWiki - Your Ultimate Theme Park & Attractions Guide{% endblock %}
|
||||
{% block og_description %}Discover the world's best theme parks and thrilling rides. Explore amazing parks, find detailed ride information, and share your adventures with fellow theme park enthusiasts.{% endblock %}
|
||||
{% block og_type %}website{% endblock %}
|
||||
|
||||
{% block twitter_title %}ThrillWiki - Your Ultimate Theme Park & Attractions Guide{% endblock %}
|
||||
{% block twitter_description %}Discover the world's best theme parks and thrilling rides. Explore amazing parks, find detailed ride information, and share your adventures.{% endblock %}
|
||||
|
||||
{% block structured_data %}
|
||||
<script type="application/ld+json" nonce="{{ request.csp_nonce }}">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "ThrillWiki",
|
||||
"description": "Your ultimate guide to theme parks and attractions worldwide",
|
||||
"url": "{{ request.scheme }}://{{ request.get_host }}",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "{{ request.scheme }}://{{ request.get_host }}/search/?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "ThrillWiki",
|
||||
"description": "The ultimate theme park and attractions database"
|
||||
},
|
||||
"mainEntity": {
|
||||
"@type": "ItemList",
|
||||
"name": "Featured Theme Parks and Attractions",
|
||||
"description": "Top-rated theme parks and thrilling rides from around the world"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Hero Section -->
|
||||
<div class="mb-12 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
@@ -71,30 +113,7 @@
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for park in popular_parks %}
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if park.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ park.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ park.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
{{ park.ride_count }} rides, {{ park.coaster_count }} coasters
|
||||
</div>
|
||||
{% if park.average_rating %}
|
||||
<div class="absolute top-0 right-0 p-2 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ park.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-400">Rating not available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
<c-park_card :park="park" view_mode="grid" />
|
||||
{% empty %}
|
||||
<div class="flex flex-col items-center justify-center h-48 p-8 text-center bg-gray-50 rounded-lg dark:bg-gray-800/50">
|
||||
<div class="mb-4 text-4xl">🎢</div>
|
||||
@@ -112,30 +131,7 @@
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{% for ride in popular_rides %}
|
||||
<a href="{% url 'parks:rides:ride_detail' ride.park.slug ride.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if ride.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ ride.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ ride.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
at {{ ride.park.name }}
|
||||
</div>
|
||||
{% if ride.average_rating %}
|
||||
<div class="flex items-center mt-1 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ ride.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-gray-400">Rating not available</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
<c-ride_card :ride="ride" url_variant="park" />
|
||||
{% empty %}
|
||||
<div class="flex flex-col items-center justify-center h-48 p-8 text-center bg-gray-50 rounded-lg dark:bg-gray-800/50">
|
||||
<div class="mb-4 text-4xl">🎠</div>
|
||||
@@ -155,48 +151,10 @@
|
||||
{% for item in highest_rated %}
|
||||
{% if item.park %}
|
||||
<!-- This is a ride -->
|
||||
<a href="{% url 'parks:rides:ride_detail' item.park.slug item.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if item.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ item.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
at {{ item.park.name }}
|
||||
</div>
|
||||
<div class="flex items-center mt-1 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ item.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<c-ride_card :ride="item" url_variant="park" />
|
||||
{% else %}
|
||||
<!-- This is a park -->
|
||||
<a href="{% url 'parks:park_detail' item.slug %}"
|
||||
class="relative block h-48 overflow-hidden transition-all rounded-lg group hover:-translate-y-1 hover:shadow-xl"
|
||||
{% if item.photos.first %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7)), url('{{ item.photos.first.image.url }}') center/cover no-repeat;"
|
||||
{% else %}
|
||||
style="background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.7));"
|
||||
{% endif %}>
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 text-white">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-200">
|
||||
{{ item.ride_count }} rides, {{ item.coaster_count }} coasters
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 p-2 text-yellow-400">
|
||||
<span class="mr-1">★</span>
|
||||
<span>{{ item.average_rating|floatformat:1 }}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<c-park_card :park="item" view_mode="grid" />
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<div class="flex flex-col items-center justify-center h-48 p-8 text-center bg-gray-50 rounded-lg dark:bg-gray-800/50">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% load static %}
|
||||
{% load park_tags %}
|
||||
{% load cotton %}
|
||||
|
||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
||||
|
||||
@@ -11,7 +12,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
<script nonce="{{ request.csp_nonce }}">
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('photoUploadModal', () => ({
|
||||
show: false,
|
||||
@@ -65,7 +66,7 @@
|
||||
<dd class="mt-1">
|
||||
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
|
||||
{{ park.operator.name }}
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,10 +78,9 @@
|
||||
<div class="text-center">
|
||||
<dt class="text-sm font-semibold text-gray-900 dark:text-white">Property Owner</dt>
|
||||
<dd class="mt-1">
|
||||
<a href="{% url 'property_owners:property_owner_detail' park.property_owner.slug %}"
|
||||
class="text-sm font-bold text-sky-900 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-300">
|
||||
<span class="text-sm font-bold text-sky-900 dark:text-sky-400">
|
||||
{{ park.property_owner.name }}
|
||||
</a>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,22 +169,9 @@
|
||||
</a>
|
||||
</div>
|
||||
{% if park.rides.exists %}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||
{% for ride in park.rides.all|slice:":6" %}
|
||||
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<a href="{% url 'parks:rides:ride_detail' park.slug ride.slug %}" class="block">
|
||||
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
|
||||
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
|
||||
{{ ride.get_category_display }}
|
||||
</span>
|
||||
{% if ride.average_rating %}
|
||||
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
|
||||
<span class="mr-1 text-yellow-500 dark:text-yellow-200">★</span>
|
||||
{{ ride.average_rating|floatformat:1 }}/10
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<c-ride_card :ride="ride" url_variant="park" />
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -199,7 +186,19 @@
|
||||
{% if park.location.exists %}
|
||||
<div class="p-optimized mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Location</h2>
|
||||
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"></div>
|
||||
{% with location=park.location.first %}
|
||||
{% if location.latitude is not None and location.longitude is not None %}
|
||||
<div id="park-map" class="relative rounded-lg" style="z-index: 0;"
|
||||
data-latitude="{{ location.latitude|default_if_none:'' }}"
|
||||
data-longitude="{{ location.longitude|default_if_none:'' }}"
|
||||
data-park-name="{{ park.name|escape }}"></div>
|
||||
{% else %}
|
||||
<div class="relative rounded-lg p-4 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-map-marker-alt text-2xl mb-2"></i>
|
||||
<p>Location information not available</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -274,12 +273,20 @@
|
||||
{% if park.location.exists %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="{% static 'js/park-map.js' %}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% with location=park.location.first %}
|
||||
initParkMap({{ location.latitude }}, {{ location.longitude }}, "{{ park.name }}");
|
||||
{% endwith %}
|
||||
});
|
||||
|
||||
<script nonce="{{ request.csp_nonce }}">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var mapElement = document.getElementById('park-map');
|
||||
if (mapElement && mapElement.dataset.latitude && mapElement.dataset.longitude) {
|
||||
var latitude = parseFloat(mapElement.dataset.latitude);
|
||||
var longitude = parseFloat(mapElement.dataset.longitude);
|
||||
var parkName = mapElement.dataset.parkName;
|
||||
|
||||
if (!isNaN(latitude) && !isNaN(longitude) && parkName) {
|
||||
initParkMap(latitude, longitude, parkName);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,90 +1,8 @@
|
||||
{% load cotton %}
|
||||
|
||||
<!-- Featured Parks Grid -->
|
||||
{% for park in featured_parks %}
|
||||
<div class="group relative overflow-hidden rounded-xl bg-background border border-border/50 shadow-sm hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1">
|
||||
<!-- Park Image -->
|
||||
<div class="aspect-video relative overflow-hidden">
|
||||
{% if park.card_image %}
|
||||
<img src="{{ park.card_image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-gradient-to-br from-primary/20 to-secondary/20 flex items-center justify-center">
|
||||
<svg class="w-16 h-16 text-muted-foreground" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<!-- Quick Stats Overlay -->
|
||||
<div class="absolute top-4 right-4 flex gap-2">
|
||||
{% if park.average_rating %}
|
||||
<div class="bg-background/90 backdrop-blur-sm px-2 py-1 rounded-full text-xs font-medium flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-yellow-500" 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"></path>
|
||||
</svg>
|
||||
{{ park.average_rating|floatformat:1 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if park.ride_count %}
|
||||
<div class="bg-background/90 backdrop-blur-sm px-2 py-1 rounded-full text-xs font-medium">
|
||||
{{ park.ride_count }} rides
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Park Info -->
|
||||
<div class="p-6">
|
||||
<h3 class="text-xl font-bold mb-2 group-hover:text-primary transition-colors">
|
||||
<a href="/parks/{{ park.slug }}/" class="stretched-link">
|
||||
{{ park.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||
{{ park.description|truncatewords:20 }}
|
||||
</p>
|
||||
|
||||
<!-- Park Details -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
{% if park.location %}
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<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.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
{{ park.location.city }}, {{ park.location.country }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if park.opening_year %}
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
{{ park.opening_year }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
{% if park.status %}
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium
|
||||
{% if park.status == 'operating' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400
|
||||
{% elif park.status == 'closed' %}bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400
|
||||
{% elif park.status == 'seasonal' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400{% endif %}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<c-park_card :park="park" view_mode="grid" class="h-full" />
|
||||
{% empty %}
|
||||
<div class="col-span-full text-center py-12">
|
||||
<svg class="w-16 h-16 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -1,122 +1,8 @@
|
||||
{% load cotton %}
|
||||
|
||||
<!-- Featured Rides Grid -->
|
||||
{% for ride in featured_rides %}
|
||||
<div class="group relative overflow-hidden rounded-xl bg-background border border-border/50 shadow-sm hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1">
|
||||
<!-- Ride Image -->
|
||||
<div class="aspect-square relative overflow-hidden">
|
||||
{% if ride.card_image %}
|
||||
<img src="{{ ride.card_image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-gradient-to-br from-secondary/20 to-accent/20 flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-muted-foreground" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<!-- Quick Stats Overlay -->
|
||||
<div class="absolute top-3 right-3 flex flex-col gap-1">
|
||||
{% if ride.average_rating %}
|
||||
<div class="bg-background/90 backdrop-blur-sm px-2 py-1 rounded-full text-xs font-medium flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-yellow-500" 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"></path>
|
||||
</svg>
|
||||
{{ ride.average_rating|floatformat:1 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.ride_type %}
|
||||
<div class="bg-background/90 backdrop-blur-sm px-2 py-1 rounded-full text-xs font-medium">
|
||||
{{ ride.get_ride_type_display }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Thrill Level Indicator -->
|
||||
{% if ride.thrill_level %}
|
||||
<div class="absolute bottom-3 left-3">
|
||||
<div class="flex items-center gap-1 bg-background/90 backdrop-blur-sm px-2 py-1 rounded-full text-xs font-medium">
|
||||
{% if ride.thrill_level == 'family' %}
|
||||
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span class="text-green-700 dark:text-green-400">Family</span>
|
||||
{% elif ride.thrill_level == 'moderate' %}
|
||||
<div class="w-2 h-2 rounded-full bg-yellow-500"></div>
|
||||
<span class="text-yellow-700 dark:text-yellow-400">Moderate</span>
|
||||
{% elif ride.thrill_level == 'extreme' %}
|
||||
<div class="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<span class="text-red-700 dark:text-red-400">Extreme</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ride Info -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-bold mb-1 group-hover:text-primary transition-colors line-clamp-1">
|
||||
<a href="/rides/{{ ride.slug }}/" class="stretched-link">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-2 line-clamp-1">
|
||||
<a href="/parks/{{ ride.park.slug }}/" class="hover:text-primary transition-colors">
|
||||
{{ ride.park.name }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Ride Stats -->
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
{% if ride.height_requirement %}
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m-9 0h10m-10 0a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2"></path>
|
||||
</svg>
|
||||
{{ ride.height_requirement }}"
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.opening_year %}
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-3 h-3" 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"></path>
|
||||
</svg>
|
||||
{{ ride.opening_year }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Roller Coaster Stats (if applicable) -->
|
||||
{% if ride.roller_coaster_stats %}
|
||||
<div class="mt-2 pt-2 border-t border-border/50">
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
{% if ride.roller_coaster_stats.max_height %}
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12"></path>
|
||||
</svg>
|
||||
{{ ride.roller_coaster_stats.max_height }}ft
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.roller_coaster_stats.max_speed %}
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
<svg class="w-3 h-3" 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"></path>
|
||||
</svg>
|
||||
{{ ride.roller_coaster_stats.max_speed }}mph
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<c-ride_card :ride="ride" url_variant="global" class="h-full" />
|
||||
{% empty %}
|
||||
<div class="col-span-full text-center py-12">
|
||||
<svg class="w-16 h-16 text-muted-foreground mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
{% load cotton %}
|
||||
|
||||
<!-- Active filters display (mobile and desktop) -->
|
||||
{% if has_filters %}
|
||||
<div class="mb-6">
|
||||
@@ -83,157 +85,7 @@
|
||||
{% if rides %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{% for ride in rides %}
|
||||
<div class="ride-card bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-lg transition-all duration-300">
|
||||
<!-- Ride image -->
|
||||
<div class="relative h-48 bg-gradient-to-br from-blue-500 to-purple-600">
|
||||
{% if ride.image %}
|
||||
<img src="{{ ride.image.url }}"
|
||||
alt="{{ ride.name }}"
|
||||
class="w-full h-full object-cover">
|
||||
{% else %}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<i class="fas fa-rocket text-4xl text-white opacity-50"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Status badge -->
|
||||
<div class="absolute top-3 right-3">
|
||||
{% if ride.operating_status == 'operating' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<i class="fas fa-play-circle mr-1"></i>
|
||||
Operating
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_temporarily' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
<i class="fas fa-pause-circle mr-1"></i>
|
||||
Temporarily Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'closed_permanently' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-stop-circle mr-1"></i>
|
||||
Permanently Closed
|
||||
</span>
|
||||
{% elif ride.operating_status == 'under_construction' %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<i class="fas fa-hard-hat mr-1"></i>
|
||||
Under Construction
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ride details -->
|
||||
<div class="p-5">
|
||||
<!-- Name and category -->
|
||||
<div class="mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
<a href="{% url 'rides:ride_detail' ride.id %}"
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{{ ride.name }}
|
||||
</a>
|
||||
</h3>
|
||||
<div class="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 mr-2">
|
||||
{{ ride.category|default:"Ride" }}
|
||||
</span>
|
||||
{% if ride.park %}
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
{{ ride.park.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key stats -->
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
{% if ride.height %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.height }}ft</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Height</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.rollercoaster_stats.max_speed %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.rollercoaster_stats.max_speed }}mph</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Top Speed</div>
|
||||
</div>
|
||||
{% elif ride.max_speed %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.max_speed }}mph</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Max Speed</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.capacity_per_hour %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.capacity_per_hour }}</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Capacity/Hr</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.duration %}
|
||||
<div class="text-center p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ ride.duration }}s</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Duration</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Special features -->
|
||||
{% if ride.has_inversions or ride.has_launches or ride.rollercoaster_stats %}
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{% if ride.has_inversions %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
<i class="fas fa-sync-alt mr-1"></i>
|
||||
{% if ride.rollercoaster_stats.number_of_inversions %}
|
||||
{{ ride.rollercoaster_stats.number_of_inversions }} Inversion{{ ride.rollercoaster_stats.number_of_inversions|pluralize }}
|
||||
{% else %}
|
||||
Inversions
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.has_launches %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<i class="fas fa-rocket mr-1"></i>
|
||||
{% if ride.rollercoaster_stats.number_of_launches %}
|
||||
{{ ride.rollercoaster_stats.number_of_launches }} Launch{{ ride.rollercoaster_stats.number_of_launches|pluralize }}
|
||||
{% else %}
|
||||
Launched
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ride.rollercoaster_stats.track_type %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ ride.rollercoaster_stats.track_type|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Opening date -->
|
||||
{% if ride.opened_date %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
Opened {{ ride.opened_date|date:"F j, Y" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Manufacturer -->
|
||||
{% if ride.manufacturer %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<i class="fas fa-industry mr-1"></i>
|
||||
{{ ride.manufacturer.name }}
|
||||
{% if ride.designer and ride.designer != ride.manufacturer %}
|
||||
• Designed by {{ ride.designer.name }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<c-ride_card :ride="ride" />
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -373,10 +373,7 @@
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Designer</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white">
|
||||
<a href="{% url 'designers:designer_detail' ride.designer.slug %}"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{{ ride.designer.name }}
|
||||
</a>
|
||||
{{ ride.designer.name }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,8 +6,28 @@ from apps.parks.models import Park, Company
|
||||
from apps.rides.models import Ride
|
||||
from apps.core.analytics import PageView
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
# Set up logger for query debugging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def optimize_park_queryset(queryset):
|
||||
"""Add proper select_related and prefetch_related to park querysets"""
|
||||
return queryset.select_related(
|
||||
'operator', 'property_owner', 'card_image', 'banner_image'
|
||||
).prefetch_related('photos')
|
||||
|
||||
|
||||
def optimize_ride_queryset(queryset):
|
||||
"""Add proper select_related and prefetch_related to ride querysets"""
|
||||
return queryset.select_related(
|
||||
'park', 'park__operator', 'manufacturer', 'designer', 'card_image',
|
||||
'ride_model', 'park_area'
|
||||
).prefetch_related('photos')
|
||||
|
||||
|
||||
def handler404(request, exception):
|
||||
@@ -22,14 +42,22 @@ class HomeView(TemplateView):
|
||||
template_name = "home.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
# Track query count for performance monitoring
|
||||
queries_start = len(connection.queries)
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Get stats
|
||||
context["stats"] = {
|
||||
"total_parks": Park.objects.count(),
|
||||
"ride_count": Ride.objects.count(),
|
||||
"coaster_count": Ride.objects.filter(category="RC").count(),
|
||||
}
|
||||
# Get stats - try cache first
|
||||
stats = cache.get("homepage_stats")
|
||||
if stats is None:
|
||||
stats = {
|
||||
"total_parks": Park.objects.count(),
|
||||
"ride_count": Ride.objects.count(),
|
||||
"coaster_count": Ride.objects.filter(category="RC").count(),
|
||||
}
|
||||
# Cache stats for 30 minutes
|
||||
cache.set("homepage_stats", stats, 1800)
|
||||
context["stats"] = stats
|
||||
|
||||
# Try to get trending items from cache first
|
||||
trending_parks = cache.get("trending_parks")
|
||||
@@ -38,9 +66,13 @@ class HomeView(TemplateView):
|
||||
# If not in cache, get them directly and cache them
|
||||
if trending_parks is None:
|
||||
try:
|
||||
trending_parks = list(
|
||||
# Get trending parks with optimized queries
|
||||
trending_parks_qs = optimize_park_queryset(
|
||||
PageView.get_trending_items(Park, hours=24, limit=10)
|
||||
)
|
||||
trending_parks = list(trending_parks_qs)
|
||||
# Filter out any parks with invalid slugs
|
||||
trending_parks = [p for p in trending_parks if getattr(p, 'slug', None)]
|
||||
if trending_parks:
|
||||
cache.set(
|
||||
"trending_parks", trending_parks, 3600
|
||||
@@ -49,18 +81,26 @@ class HomeView(TemplateView):
|
||||
# Fallback to highest rated parks if no trending data
|
||||
trending_parks = Park.objects.exclude(
|
||||
average_rating__isnull=True
|
||||
).order_by("-average_rating")[:10]
|
||||
).exclude(slug__isnull=True).exclude(slug__exact='').select_related(
|
||||
'operator', 'property_owner', 'card_image', 'banner_image'
|
||||
).prefetch_related('photos').order_by("-average_rating")[:10]
|
||||
except Exception:
|
||||
# Fallback to highest rated parks if trending calculation fails
|
||||
trending_parks = Park.objects.exclude(
|
||||
average_rating__isnull=True
|
||||
).order_by("-average_rating")[:10]
|
||||
).exclude(slug__isnull=True).exclude(slug__exact='').select_related(
|
||||
'operator', 'property_owner', 'card_image', 'banner_image'
|
||||
).prefetch_related('photos').order_by("-average_rating")[:10]
|
||||
|
||||
if trending_rides is None:
|
||||
try:
|
||||
trending_rides = list(
|
||||
# Get trending rides with optimized queries
|
||||
trending_rides_qs = optimize_ride_queryset(
|
||||
PageView.get_trending_items(Ride, hours=24, limit=10)
|
||||
)
|
||||
trending_rides = list(trending_rides_qs)
|
||||
# Filter out any rides with invalid slugs
|
||||
trending_rides = [r for r in trending_rides if getattr(r, 'slug', None)]
|
||||
if trending_rides:
|
||||
cache.set(
|
||||
"trending_rides", trending_rides, 3600
|
||||
@@ -69,24 +109,35 @@ class HomeView(TemplateView):
|
||||
# Fallback to highest rated rides if no trending data
|
||||
trending_rides = Ride.objects.exclude(
|
||||
average_rating__isnull=True
|
||||
).order_by("-average_rating")[:10]
|
||||
).exclude(slug__isnull=True).exclude(slug__exact='').select_related(
|
||||
'park', 'park__operator', 'manufacturer', 'designer', 'card_image',
|
||||
'ride_model', 'park_area'
|
||||
).prefetch_related('photos').order_by("-average_rating")[:10]
|
||||
except Exception:
|
||||
# Fallback to highest rated rides if trending calculation fails
|
||||
trending_rides = Ride.objects.exclude(
|
||||
average_rating__isnull=True
|
||||
).order_by("-average_rating")[:10]
|
||||
).exclude(slug__isnull=True).exclude(slug__exact='').select_related(
|
||||
'park', 'park__operator', 'manufacturer', 'designer', 'card_image',
|
||||
'ride_model', 'park_area'
|
||||
).prefetch_related('photos').order_by("-average_rating")[:10]
|
||||
|
||||
# Get highest rated items (mix of parks and rides)
|
||||
highest_rated_parks = list(
|
||||
Park.objects.exclude(average_rating__isnull=True).order_by(
|
||||
"-average_rating"
|
||||
)[:20]
|
||||
Park.objects.exclude(average_rating__isnull=True)
|
||||
.exclude(slug__isnull=True).exclude(slug__exact='')
|
||||
.select_related('operator', 'property_owner', 'card_image', 'banner_image')
|
||||
.prefetch_related('photos')
|
||||
.order_by("-average_rating")[:20]
|
||||
) # Get more items to randomly select from
|
||||
|
||||
highest_rated_rides = list(
|
||||
Ride.objects.exclude(average_rating__isnull=True).order_by(
|
||||
"-average_rating"
|
||||
)[:20]
|
||||
Ride.objects.exclude(average_rating__isnull=True)
|
||||
.exclude(slug__isnull=True).exclude(slug__exact='')
|
||||
.select_related('park', 'park__operator', 'manufacturer', 'designer', 'card_image',
|
||||
'ride_model', 'park_area')
|
||||
.prefetch_related('photos')
|
||||
.order_by("-average_rating")[:20]
|
||||
) # Get more items to randomly select from
|
||||
|
||||
# Combine and shuffle highest rated items
|
||||
@@ -100,6 +151,11 @@ class HomeView(TemplateView):
|
||||
:10
|
||||
] # Take first 10 after shuffling
|
||||
|
||||
# Log query count for debugging
|
||||
queries_end = len(connection.queries)
|
||||
query_count = queries_end - queries_start
|
||||
logger.info(f"HomeView executed {query_count} queries")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user