mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 05:51:08 -05:00
Implement ride count fields with real-time annotations; update filters and templates for consistency and accuracy
This commit is contained in:
59
memory-bank/decisions/park_count_fields.md
Normal file
59
memory-bank/decisions/park_count_fields.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Park Count Fields Implementation
|
||||
|
||||
## Context
|
||||
While implementing park views, we encountered errors where `ride_count` and `coaster_count` annotations conflicted with existing model fields of the same names. Additionally, we discovered inconsistencies in how these counts were being used across different views.
|
||||
|
||||
## Decision
|
||||
We decided to use both approaches but with distinct names:
|
||||
|
||||
1. **Model Fields**:
|
||||
- `ride_count`: Stored count of all rides
|
||||
- `coaster_count`: Stored count of roller coasters
|
||||
- Used in models and database schema
|
||||
- Required for backward compatibility
|
||||
|
||||
2. **Annotations**:
|
||||
- `current_ride_count`: Real-time count of all rides
|
||||
- `current_coaster_count`: Real-time count of roller coasters
|
||||
- Provide accurate, up-to-date counts
|
||||
- Used in templates and filters
|
||||
|
||||
This approach allows us to:
|
||||
- Maintain existing database schema
|
||||
- Show accurate, real-time counts in the UI
|
||||
- Avoid name conflicts between fields and annotations
|
||||
- Keep consistent naming pattern for both types of counts
|
||||
|
||||
## Implementation
|
||||
1. Views:
|
||||
- Added base queryset method with annotations
|
||||
- Used 'current_' prefix for annotated counts
|
||||
- Ensured all views use the base queryset
|
||||
|
||||
2. Filters:
|
||||
- Updated filter fields to use annotated counts
|
||||
- Configured filter class to always use base queryset
|
||||
- Maintained filter functionality with new field names
|
||||
|
||||
3. Templates:
|
||||
- Updated templates to use computed counts
|
||||
|
||||
## Why This Pattern
|
||||
1. **Consistency**: Using the 'current_' prefix clearly indicates which values are computed in real-time
|
||||
2. **Compatibility**: Maintains support for existing code that relies on the stored fields
|
||||
3. **Flexibility**: Allows gradual migration from stored to computed counts if desired
|
||||
4. **Performance Option**: Keeps the option to use stored counts for expensive queries
|
||||
|
||||
## Future Considerations
|
||||
We might want to:
|
||||
1. Add periodic tasks to sync stored counts with computed values
|
||||
2. Consider deprecating stored fields if they're not needed for performance
|
||||
3. Add validation to ensure stored counts stay in sync with reality
|
||||
4. Create a management command to update stored counts
|
||||
|
||||
## Related Files
|
||||
- parks/models.py
|
||||
- parks/views.py
|
||||
- parks/filters.py
|
||||
- parks/templates/parks/partials/park_list_item.html
|
||||
- parks/tests/test_filters.py
|
||||
39
memory-bank/decisions/ride_count_field.md
Normal file
39
memory-bank/decisions/ride_count_field.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Ride Count Field Implementation
|
||||
|
||||
## Context
|
||||
While implementing park views, we encountered an error where a `ride_count` annotation conflicted with an existing model field of the same name. This raised a question about how to handle real-time ride counts versus stored counts.
|
||||
|
||||
## Decision
|
||||
We decided to use both approaches but with distinct names:
|
||||
|
||||
1. **Model Field (`ride_count`)**:
|
||||
- Kept the original field for backward compatibility
|
||||
- Used in test fixtures and filtering system
|
||||
- Can serve as a cached/denormalized value
|
||||
|
||||
2. **Annotation (`current_ride_count`)**:
|
||||
- Added new annotation with a distinct name
|
||||
- Provides real-time count of rides
|
||||
- Used in templates for display purposes
|
||||
|
||||
This approach allows us to:
|
||||
- Maintain existing functionality in tests and filters
|
||||
- Show accurate, real-time counts in the UI
|
||||
- Avoid name conflicts between fields and annotations
|
||||
|
||||
## Implementation
|
||||
- Kept the `ride_count` IntegerField in the Park model
|
||||
- Added `current_ride_count = Count('rides', distinct=True)` annotation in views
|
||||
- Updated templates to use `current_ride_count` for display
|
||||
|
||||
## Future Considerations
|
||||
We might want to:
|
||||
1. Add a periodic task to sync the stored `ride_count` with the computed value
|
||||
2. Consider deprecating the stored field if it's not needed for performance
|
||||
3. Add validation to ensure the stored count stays in sync with reality
|
||||
|
||||
## Related Files
|
||||
- parks/models.py
|
||||
- parks/views.py
|
||||
- parks/templates/parks/partials/park_list_item.html
|
||||
- parks/tests/test_filters.py
|
||||
@@ -12,6 +12,7 @@ from django_filters import (
|
||||
BooleanFilter
|
||||
)
|
||||
from .models import Park
|
||||
from .views import get_base_park_queryset
|
||||
from companies.models import Company
|
||||
|
||||
def validate_positive_integer(value):
|
||||
@@ -24,7 +25,7 @@ def validate_positive_integer(value):
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(_('Invalid number format'))
|
||||
|
||||
class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin):
|
||||
class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, FilterSet):
|
||||
"""Filter set for parks with search and validation capabilities"""
|
||||
class Meta:
|
||||
model = Park
|
||||
@@ -50,12 +51,12 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil
|
||||
|
||||
# Numeric filters
|
||||
min_rides = NumberFilter(
|
||||
field_name='ride_count',
|
||||
field_name='current_ride_count',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
min_coasters = NumberFilter(
|
||||
field_name='coaster_count',
|
||||
field_name='current_coaster_count',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
@@ -93,22 +94,25 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil
|
||||
def filter_has_owner(self, queryset, name, value):
|
||||
"""Filter parks based on whether they have an owner"""
|
||||
return queryset.filter(owner__isnull=not value)
|
||||
@property
|
||||
def qs(self):
|
||||
"""Override qs property to ensure we always use base queryset with annotations"""
|
||||
if not hasattr(self, '_qs'):
|
||||
# Start with the base queryset that includes annotations
|
||||
base_qs = get_base_park_queryset()
|
||||
|
||||
if not self.is_bound:
|
||||
self._qs = base_qs
|
||||
return self._qs
|
||||
|
||||
if not self.form.is_valid():
|
||||
self._qs = base_qs.none()
|
||||
return self._qs
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
"""Override qs property to ensure we always start with all parks"""
|
||||
if not hasattr(self, '_qs'):
|
||||
if not self.is_bound:
|
||||
self._qs = self.queryset.all()
|
||||
return self._qs
|
||||
if not self.form.is_valid():
|
||||
self._qs = self.queryset.none()
|
||||
return self._qs
|
||||
|
||||
self._qs = self.queryset.all()
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
if value in [None, '', 0] and name not in ['has_owner']:
|
||||
continue
|
||||
self._qs = self.filters[name].filter(self._qs, value)
|
||||
self._qs = self._qs.distinct()
|
||||
return self._qs
|
||||
self._qs = base_qs
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
if value in [None, '', 0] and name not in ['has_owner']:
|
||||
continue
|
||||
self._qs = self.filters[name].filter(self._qs, value)
|
||||
self._qs = self._qs.distinct()
|
||||
return self._qs
|
||||
@@ -1,62 +1,134 @@
|
||||
/* Loading indicator */
|
||||
/* Loading states */
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
.htmx-request.htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
/* Results container transitions */
|
||||
#park-results {
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
.htmx-request #park-results {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.htmx-settling #park-results {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Search results container */
|
||||
#search-results {
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
/* Grid/List transitions */
|
||||
.park-card {
|
||||
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
background-color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
#search-results:empty {
|
||||
display: none;
|
||||
/* Grid view styles */
|
||||
.park-card[data-view-mode="grid"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.park-card[data-view-mode="grid"]:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Search result items */
|
||||
#search-results .border-b {
|
||||
border-color: #e5e7eb;
|
||||
/* List view styles */
|
||||
.park-card[data-view-mode="list"] {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.park-card[data-view-mode="list"]:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
#search-results a {
|
||||
display: block;
|
||||
transition: background-color 150ms ease-in-out;
|
||||
/* Image containers */
|
||||
.park-card .image-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.park-card[data-view-mode="grid"] .image-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
}
|
||||
.park-card[data-view-mode="list"] .image-container {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#search-results a:hover {
|
||||
background-color: #f3f4f6;
|
||||
/* Content */
|
||||
.park-card .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0; /* Enables text truncation in flex child */
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
/* Status badges */
|
||||
.park-card .status-badge {
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
.park-card:hover .status-badge {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.park-card img {
|
||||
transition: transform 200ms ease-in-out;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.park-card:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Placeholders for missing images */
|
||||
.park-card .placeholder {
|
||||
background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#search-results {
|
||||
.park-card {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
#search-results .text-gray-900 {
|
||||
.park-card[data-view-mode="list"]:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.park-card .text-gray-900 {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
#search-results .text-gray-500 {
|
||||
.park-card .text-gray-500 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
#search-results .border-b {
|
||||
border-color: #374151;
|
||||
.park-card .placeholder {
|
||||
background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%);
|
||||
}
|
||||
|
||||
#search-results a:hover {
|
||||
.park-card[data-view-mode="list"]:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
}
|
||||
69
parks/static/parks/js/search.js
Normal file
69
parks/static/parks/js/search.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Handle view mode persistence across HTMX requests
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
// Preserve view mode
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults) {
|
||||
const viewMode = parkResults.getAttribute('data-view-mode');
|
||||
if (viewMode) {
|
||||
evt.detail.parameters['view_mode'] = viewMode;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve search terms
|
||||
const searchInput = document.getElementById('search');
|
||||
if (searchInput && searchInput.value) {
|
||||
evt.detail.parameters['search'] = searchInput.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle loading states
|
||||
document.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.add('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const target = evt.detail.target;
|
||||
if (target) {
|
||||
target.classList.remove('htmx-requesting');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle history navigation
|
||||
document.addEventListener('htmx:historyRestore', function(evt) {
|
||||
const parkResults = document.getElementById('park-results');
|
||||
if (parkResults && evt.detail.path) {
|
||||
const url = new URL(evt.detail.path, window.location.origin);
|
||||
const viewMode = url.searchParams.get('view_mode');
|
||||
if (viewMode) {
|
||||
parkResults.setAttribute('data-view-mode', viewMode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize lazy loading for images
|
||||
function initializeLazyLoading(container) {
|
||||
if (!('IntersectionObserver' in window)) return;
|
||||
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute('data-src');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize lazy loading after HTMX content swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
initializeLazyLoading(evt.detail.target);
|
||||
});
|
||||
@@ -2,136 +2,97 @@
|
||||
{% load static %}
|
||||
{% load filter_utils %}
|
||||
|
||||
{% block page_title %}Parks{% endblock %}
|
||||
{% block title %}Parks - ThrillWiki{% endblock %}
|
||||
|
||||
{% block filter_errors %}
|
||||
{% if filter.errors %}
|
||||
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
|
||||
<h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3>
|
||||
<ul class="mt-2 text-sm text-red-700 list-disc pl-5 space-y-1">
|
||||
{% for field, errors in filter.errors.items %}
|
||||
{% for error in errors %}
|
||||
<li>{{ field }}: {{ error }}</li>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block list_header %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid" hx-target="#results-container" hx-push-url="true" class="p-2 rounded {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-gray-200{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list" hx-target="#results-container" hx-push-url="true" class="p-2 rounded {% if request.GET.view_mode == 'list' %}bg-gray-200{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% block list_actions %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mr-4">Parks</h1>
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
|
||||
|
||||
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection">
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}bg-white shadow-sm{% endif %}"
|
||||
aria-label="Grid view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'grid' or not request.GET.view_mode %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=list{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
class="p-2 rounded transition-colors duration-200 {% if request.GET.view_mode == 'list' %}bg-white shadow-sm{% endif %}"
|
||||
aria-label="List view"
|
||||
aria-pressed="{% if request.GET.view_mode == 'list' %}true{% else %}false{% endif %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<a href="{% url 'parks:park_create' %}" class="btn btn-primary">
|
||||
<a href="{% url 'parks:park_create' %}"
|
||||
class="btn btn-primary"
|
||||
data-testid="add-park-button">
|
||||
Add Park
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block list_description %}
|
||||
Browse and filter amusement parks, theme parks, and water parks from around the world.
|
||||
{% endblock %}
|
||||
|
||||
{% block filter_section %}
|
||||
<div class="mb-6">
|
||||
{# Quick Search #}
|
||||
<div class="mb-8">
|
||||
<div class="max-w-3xl mx-auto"
|
||||
x-data="{
|
||||
selectedIndex: -1,
|
||||
results: [],
|
||||
get hasResults() { return this.results.length > 0 },
|
||||
init() {
|
||||
this.$watch('results', () => this.selectedIndex = -1)
|
||||
},
|
||||
onKeyDown(e) {
|
||||
if (!this.hasResults) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1)
|
||||
}
|
||||
else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0)
|
||||
}
|
||||
else if (e.key === 'Enter' && this.selectedIndex >= 0) {
|
||||
e.preventDefault()
|
||||
this.$refs[`result-${this.selectedIndex}`]?.click()
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
this.results = []
|
||||
this.$refs.input.value = ''
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
}"
|
||||
@click.away="results = []">
|
||||
<label for="search" class="sr-only">Search parks</label>
|
||||
<div class="relative">
|
||||
<input type="search"
|
||||
name="search"
|
||||
id="search"
|
||||
x-ref="input"
|
||||
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="keyup changed delay:300ms, search"
|
||||
hx-target="#search-results"
|
||||
hx-indicator="#search-indicator"
|
||||
@keydown="onKeyDown($event)"
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded="false"
|
||||
aria-controls="search-results"
|
||||
aria-autocomplete="list">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<div id="search-indicator" class="htmx-indicator">
|
||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-3xl mx-auto relative mb-8">
|
||||
<label for="search" class="sr-only">Search parks</label>
|
||||
<input type="search"
|
||||
name="search"
|
||||
id="search"
|
||||
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="input delay:300ms, search"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-indicator"
|
||||
value="{{ request.GET.search|default:'' }}"
|
||||
aria-label="Search parks">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<div id="search-indicator" class="htmx-indicator">
|
||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="search-results"
|
||||
class="mt-2"
|
||||
role="listbox"
|
||||
x-init="$watch('$el.children', value => results = Array.from(value))"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Advanced Filters #}
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Advanced Filters</h3>
|
||||
<div class="mt-4">
|
||||
{% include "search/partials/filter_form.html" with filter=filter %}
|
||||
</div>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change"
|
||||
class="mt-4">
|
||||
{% include "search/components/filter_form.html" with filter=filter %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_section_title %}All Parks{% endblock %}
|
||||
|
||||
{% block no_results_message %}
|
||||
<div class="text-center p-8 text-gray-500">
|
||||
No parks found matching your criteria. Try adjusting your filters or <a href="{% url 'parks:park_create' %}" class="text-blue-600 hover:underline">add a new park</a>.
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_list %}
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
{% include "parks/partials/park_list_item.html" with parks=parks %}
|
||||
<div id="park-results"
|
||||
class="bg-white rounded-lg shadow"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'parks/js/search.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,58 +0,0 @@
|
||||
{% load static %}
|
||||
{% load filter_utils %}
|
||||
|
||||
<div class="park-card group relative border rounded-lg p-4 transition-all duration-200 ease-in-out {% if view_mode == 'grid' %}grid-item hover:shadow-lg{% else %}flex items-start space-x-4 hover:bg-gray-50{% endif %}">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}" class="absolute inset-0 z-0"></a>
|
||||
|
||||
<div class="relative z-10">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="{% if view_mode == 'grid' %}w-full h-48 object-cover rounded-lg mb-4{% else %}w-24 h-24 object-cover rounded-lg{% endif %}">
|
||||
{% else %}
|
||||
<div class="{% if view_mode == 'grid' %}w-full h-48 bg-gray-100 rounded-lg mb-4{% else %}w-24 h-24 bg-gray-100 rounded-lg{% endif %} flex items-center justify-center">
|
||||
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="{% if view_mode != 'grid' %}flex-1 min-w-0{% endif %}">
|
||||
<h3 class="text-lg font-semibold truncate">
|
||||
{{ park.name }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-1 text-sm text-gray-500 truncate">
|
||||
{% with location=park.location.first %}
|
||||
{% if location %}
|
||||
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}{% if location.country %}, {{ location.country }}{% endif %}
|
||||
{% else %}
|
||||
Location unknown
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if park.opening_date %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Opened {{ park.opening_date|date:"Y" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ park.ride_count }} rides
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{{ park.coaster_count }} coasters
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,8 @@
|
||||
{% load static %}
|
||||
{% load filter_utils %}
|
||||
|
||||
{% if error %}
|
||||
<div class="p-4">
|
||||
<div class="p-4" data-testid="park-list-error">
|
||||
<div class="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
@@ -8,13 +11,89 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="{% if view_mode == 'grid' %}grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6{% else %}space-y-6{% endif %}">
|
||||
{% for park in object_list|default:parks %}
|
||||
{% include "parks/partials/park_card.html" with park=park view_mode=view_mode %}
|
||||
{% empty %}
|
||||
<div class="p-4 text-sm text-gray-500 text-center">
|
||||
No parks found matching your search.
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="{% if view_mode == 'grid' %}grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 p-4{% else %}flex flex-col gap-4 p-4{% endif %}"
|
||||
data-testid="park-list"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
{% for park in object_list|default:parks %}
|
||||
<article class="park-card group relative bg-white border rounded-lg transition-all duration-200 ease-in-out hover:shadow-lg {% if view_mode == 'list' %}flex gap-4 p-4{% endif %}"
|
||||
data-testid="park-card"
|
||||
data-park-id="{{ park.id }}"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="absolute inset-0 z-0"
|
||||
aria-label="View details for {{ park.name }}"></a>
|
||||
|
||||
<div class="relative z-10 {% if view_mode == 'grid' %}aspect-video{% endif %}">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="Photo of {{ park.name }}"
|
||||
class="{% if view_mode == 'grid' %}w-full h-full object-cover rounded-t-lg{% else %}w-24 h-24 object-cover rounded-lg flex-shrink-0{% endif %}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="{% if view_mode == 'grid' %}w-full h-full bg-gray-100 rounded-t-lg flex items-center justify-center{% else %}w-24 h-24 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center{% endif %}"
|
||||
role="img"
|
||||
aria-label="Park initial letter">
|
||||
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="{% if view_mode == 'grid' %}p-4{% else %}flex-1 min-w-0{% endif %}">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate group-hover:text-blue-600">
|
||||
{{ park.name }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-1 text-sm text-gray-500 truncate">
|
||||
{% with location=park.location.first %}
|
||||
{% if location %}
|
||||
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}{% if location.country %}, {{ location.country }}{% endif %}
|
||||
{% else %}
|
||||
Location unknown
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} status-badge"
|
||||
data-testid="park-status">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
|
||||
{% if park.opening_date %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
data-testid="park-opening-date">
|
||||
Opened {{ park.opening_date|date:"Y" }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.current_ride_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
data-testid="park-ride-count">
|
||||
{{ park.current_ride_count }} ride{{ park.current_ride_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.current_coaster_count %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
|
||||
data-testid="park-coaster-count">
|
||||
{{ park.current_coaster_count }} coaster{{ park.current_coaster_count|pluralize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}
|
||||
<div class="{% if view_mode == 'grid' %}col-span-full{% endif %} p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
|
||||
{% if search_query %}
|
||||
No parks found matching "{{ search_query }}". Try adjusting your search terms.
|
||||
{% else %}
|
||||
No parks found matching your criteria. Try adjusting your filters.
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
You can also <a href="{% url 'parks:park_create' %}" class="text-blue-600 hover:underline">add a new park</a>.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
575
parks/views.py
575
parks/views.py
@@ -1,36 +1,55 @@
|
||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||
from typing import Any, Optional, cast, Type
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Any, Optional, cast, Literal
|
||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q, Avg, Count, QuerySet, Model
|
||||
from search.mixins import HTMXFilterableMixin
|
||||
from .filters import ParkFilter
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Q, Count, QuerySet
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib import messages
|
||||
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
|
||||
import requests
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
|
||||
from .models import Park, ParkArea
|
||||
from .forms import ParkForm
|
||||
from .location_utils import normalize_coordinate, normalize_osm_result
|
||||
from .filters import ParkFilter
|
||||
from core.views import SlugRedirectMixin
|
||||
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
||||
from moderation.models import EditSubmission
|
||||
from media.models import Photo
|
||||
from location.models import Location
|
||||
from reviews.models import Review
|
||||
from analytics.models import PageView
|
||||
from search.mixins import HTMXFilterableMixin
|
||||
|
||||
ViewMode = Literal["grid", "list"]
|
||||
|
||||
|
||||
def get_view_mode(request: HttpRequest) -> ViewMode:
|
||||
"""Get the current view mode from request, defaulting to grid"""
|
||||
view_mode = request.GET.get('view_mode', 'grid')
|
||||
return cast(ViewMode, 'list' if view_mode == 'list' else 'grid')
|
||||
|
||||
|
||||
def get_base_park_queryset() -> QuerySet[Park]:
|
||||
"""Get base queryset with all needed annotations and prefetches"""
|
||||
return (
|
||||
Park.objects.select_related('owner')
|
||||
.prefetch_related('location', 'photos', 'rides')
|
||||
.annotate(
|
||||
current_ride_count=Count('rides', distinct=True),
|
||||
current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True)
|
||||
)
|
||||
.order_by('name')
|
||||
)
|
||||
|
||||
def add_park_button(request: HttpRequest) -> HttpResponse:
|
||||
"""Return the add park button partial template"""
|
||||
return render(request, "parks/partials/add_park_button.html")
|
||||
|
||||
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
"""Return the park actions partial template"""
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
return render(request, "parks/partials/park_actions.html", {"park": park})
|
||||
|
||||
|
||||
def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||
"""Return park areas as options for a select element"""
|
||||
park_id = request.GET.get('park')
|
||||
@@ -49,53 +68,12 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||
except Park.DoesNotExist:
|
||||
return HttpResponse('<option value="">Invalid park selected</option>')
|
||||
|
||||
|
||||
def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Search parks and return results for quick searches with auto-suggestions"""
|
||||
try:
|
||||
search_query = request.GET.get('search', '').strip()
|
||||
if not search_query:
|
||||
return HttpResponse('') # Keep empty string for clearing search results
|
||||
|
||||
queryset = (
|
||||
Park.objects.select_related('owner')
|
||||
.prefetch_related('location', 'photos')
|
||||
.annotate(
|
||||
ride_count=Count('rides'),
|
||||
coaster_count=Count('rides', filter=Q(rides__category="RC"))
|
||||
)
|
||||
.order_by('name')
|
||||
)
|
||||
|
||||
# Use our existing filter but with search-specific configuration
|
||||
park_filter = ParkFilter({
|
||||
'search': search_query
|
||||
}, queryset=queryset)
|
||||
|
||||
parks = park_filter.qs[:8] # Limit to 8 suggestions
|
||||
|
||||
response = render(request, "parks/park_list.html", {
|
||||
"parks": parks
|
||||
})
|
||||
response['HX-Trigger'] = 'searchComplete'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
response = render(request, "parks/park_list.html", {
|
||||
"parks": [],
|
||||
"error": f"Error performing search: {str(e)}"
|
||||
})
|
||||
response['HX-Trigger'] = 'searchError'
|
||||
return response
|
||||
|
||||
|
||||
def location_search(request: HttpRequest) -> JsonResponse:
|
||||
"""Search for locations using OpenStreetMap Nominatim API"""
|
||||
query = request.GET.get("q", "")
|
||||
if not query:
|
||||
return JsonResponse({"results": []})
|
||||
|
||||
# Call Nominatim API
|
||||
response = requests.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
params={
|
||||
@@ -107,21 +85,20 @@ def location_search(request: HttpRequest) -> JsonResponse:
|
||||
"limit": 10,
|
||||
},
|
||||
headers={"User-Agent": "ThrillWiki/1.0"},
|
||||
timeout=60)
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
normalized_results = [normalize_osm_result(result) for result in results]
|
||||
valid_results = [
|
||||
r
|
||||
for r in normalized_results
|
||||
r for r in normalized_results
|
||||
if r["lat"] is not None and r["lon"] is not None
|
||||
]
|
||||
return JsonResponse({"results": valid_results})
|
||||
|
||||
return JsonResponse({"results": []})
|
||||
|
||||
|
||||
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
||||
try:
|
||||
@@ -137,13 +114,9 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||
lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
|
||||
|
||||
if lat < -90 or lat > 90:
|
||||
return JsonResponse(
|
||||
{"error": "Latitude must be between -90 and 90"}, status=400
|
||||
)
|
||||
return JsonResponse({"error": "Latitude must be between -90 and 90"}, status=400)
|
||||
if lon < -180 or lon > 180:
|
||||
return JsonResponse(
|
||||
{"error": "Longitude must be between -180 and 180"}, status=400
|
||||
)
|
||||
return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400)
|
||||
|
||||
response = requests.get(
|
||||
"https://nominatim.openstreetmap.org/reverse",
|
||||
@@ -156,7 +129,8 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||
"accept-language": "en",
|
||||
},
|
||||
headers={"User-Agent": "ThrillWiki/1.0"},
|
||||
timeout=60)
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
@@ -168,111 +142,109 @@ def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||
return JsonResponse({"error": "Geocoding failed"}, status=500)
|
||||
|
||||
|
||||
def add_park_button(request: HttpRequest) -> HttpResponse:
|
||||
"""Return the add park button partial template"""
|
||||
return render(request, "parks/partials/add_park_button.html")
|
||||
|
||||
|
||||
class ParkListView(HTMXFilterableMixin, ListView):
|
||||
model = Park
|
||||
template_name = "parks/park_list.html"
|
||||
context_object_name = "parks"
|
||||
filter_class = ParkFilter
|
||||
paginate_by = 20
|
||||
|
||||
|
||||
def get_template_names(self) -> list[str]:
|
||||
"""Override to use same template for HTMX and regular requests"""
|
||||
"""Return park_list_item.html for HTMX requests"""
|
||||
if self.request.htmx:
|
||||
return ["parks/partials/park_list_item.html"]
|
||||
return [self.template_name]
|
||||
|
||||
def get_view_mode(self) -> ViewMode:
|
||||
"""Get the current view mode (grid or list)"""
|
||||
return get_view_mode(self.request)
|
||||
|
||||
def get_queryset(self) -> QuerySet[Park]:
|
||||
"""Get base queryset with annotations and apply filters"""
|
||||
try:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.select_related("owner")
|
||||
.prefetch_related(
|
||||
"photos",
|
||||
"location",
|
||||
"rides",
|
||||
"rides__manufacturer",
|
||||
"areas"
|
||||
)
|
||||
.annotate(
|
||||
total_rides=Count("rides"),
|
||||
total_coasters=Count("rides", filter=Q(rides__category="RC")),
|
||||
)
|
||||
.order_by("name") # Ensure consistent ordering for pagination
|
||||
)
|
||||
queryset = get_base_park_queryset()
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||
return Park.objects.none()
|
||||
queryset = self.model.objects.none()
|
||||
|
||||
# Always initialize filterset, even if queryset failed
|
||||
self.filterset = self.get_filter_class()(self.request.GET, queryset=queryset)
|
||||
return self.filterset.qs
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Add view_mode and other context data"""
|
||||
try:
|
||||
# Initialize filterset even if queryset fails
|
||||
if not hasattr(self, 'filterset'):
|
||||
self.filterset = self.get_filter_class()(
|
||||
self.request.GET,
|
||||
queryset=self.model.objects.none()
|
||||
)
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['results_template'] = "parks/partials/park_list_item.html"
|
||||
context.update({
|
||||
'view_mode': self.get_view_mode(),
|
||||
'is_search': bool(self.request.GET.get('search')),
|
||||
'search_query': self.request.GET.get('search', '')
|
||||
})
|
||||
return context
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error applying filters: {str(e)}")
|
||||
# Ensure filterset exists in error case
|
||||
if not hasattr(self, 'filterset'):
|
||||
self.filterset = self.get_filter_class()(
|
||||
self.request.GET,
|
||||
queryset=self.model.objects.none()
|
||||
)
|
||||
return {
|
||||
"filter": self.filterset,
|
||||
"error": "Unable to apply filters. Please try adjusting your criteria.",
|
||||
"results_template": "parks/partials/park_list_item.html"
|
||||
'filter': self.filterset,
|
||||
'error': "Unable to apply filters. Please try adjusting your criteria.",
|
||||
'view_mode': self.get_view_mode(),
|
||||
'is_search': bool(self.request.GET.get('search')),
|
||||
'search_query': self.request.GET.get('search', '')
|
||||
}
|
||||
|
||||
|
||||
class ParkDetailView(
|
||||
SlugRedirectMixin,
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
HistoryMixin,
|
||||
DetailView,
|
||||
):
|
||||
model = Park
|
||||
template_name = "parks/park_detail.html"
|
||||
context_object_name = "park"
|
||||
def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Search parks and return results using park_list_item.html"""
|
||||
try:
|
||||
search_query = request.GET.get('search', '').strip()
|
||||
if not search_query:
|
||||
return HttpResponse('')
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||
if slug is None:
|
||||
raise ObjectDoesNotExist("No slug provided")
|
||||
park, _ = Park.get_by_slug(slug)
|
||||
return park
|
||||
park_filter = ParkFilter({
|
||||
'search': search_query
|
||||
}, queryset=get_base_park_queryset())
|
||||
|
||||
def get_queryset(self) -> QuerySet[Park]:
|
||||
return cast(
|
||||
QuerySet[Park],
|
||||
super()
|
||||
.get_queryset()
|
||||
.prefetch_related(
|
||||
"rides", "rides__manufacturer", "photos", "areas", "location"
|
||||
),
|
||||
parks = park_filter.qs
|
||||
if request.GET.get('quick_search'):
|
||||
parks = parks[:8] # Limit quick search results
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"parks/partials/park_list_item.html",
|
||||
{
|
||||
"parks": parks,
|
||||
"view_mode": get_view_mode(request),
|
||||
"search_query": search_query,
|
||||
"is_search": True
|
||||
}
|
||||
)
|
||||
response['HX-Trigger'] = 'searchComplete'
|
||||
return response
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
park = cast(Park, self.object)
|
||||
context["areas"] = park.areas.all()
|
||||
context["rides"] = park.rides.all().order_by("-status", "name")
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
context["has_reviewed"] = Review.objects.filter(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=park.id,
|
||||
).exists()
|
||||
else:
|
||||
context["has_reviewed"] = False
|
||||
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return "parks:park_detail"
|
||||
|
||||
except Exception as e:
|
||||
response = render(
|
||||
request,
|
||||
"parks/partials/park_list_item.html",
|
||||
{
|
||||
"parks": [],
|
||||
"error": f"Error performing search: {str(e)}",
|
||||
"is_search": True
|
||||
}
|
||||
)
|
||||
response['HX-Trigger'] = 'searchError'
|
||||
return response
|
||||
|
||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Park
|
||||
@@ -346,6 +318,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
)
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
Photo.objects.create(
|
||||
@@ -354,6 +327,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
@@ -363,7 +337,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {len(photos)} photo(s).",
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
@@ -530,6 +504,327 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ParkDetailView(
|
||||
SlugRedirectMixin,
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
HistoryMixin,
|
||||
DetailView
|
||||
):
|
||||
model = Park
|
||||
template_name = "parks/park_detail.html"
|
||||
context_object_name = "park"
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||
if slug is None:
|
||||
raise ObjectDoesNotExist("No slug provided")
|
||||
park, _ = Park.get_by_slug(slug)
|
||||
return park
|
||||
|
||||
def get_queryset(self) -> QuerySet[Park]:
|
||||
return cast(
|
||||
QuerySet[Park],
|
||||
super()
|
||||
.get_queryset()
|
||||
.prefetch_related(
|
||||
"rides",
|
||||
"rides__manufacturer",
|
||||
"photos",
|
||||
"areas",
|
||||
"location"
|
||||
),
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
park = cast(Park, self.object)
|
||||
context["areas"] = park.areas.all()
|
||||
context["rides"] = park.rides.all().order_by("-status", "name")
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
context["has_reviewed"] = Review.objects.filter(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=park.id,
|
||||
).exists()
|
||||
else:
|
||||
context["has_reviewed"] = False
|
||||
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return "parks:park_detail"
|
||||
|
||||
|
||||
class ParkAreaDetailView(
|
||||
SlugRedirectMixin,
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
HistoryMixin,
|
||||
DetailView
|
||||
):
|
||||
model = ParkArea
|
||||
template_name = "parks/area_detail.html"
|
||||
context_object_name = "area"
|
||||
slug_url_kwarg = "area_slug"
|
||||
|
||||
def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea:
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
park_slug = self.kwargs.get("park_slug")
|
||||
area_slug = self.kwargs.get("area_slug")
|
||||
if park_slug is None or area_slug is None:
|
||||
raise ObjectDoesNotExist("Missing slug")
|
||||
area, _ = ParkArea.get_by_slug(area_slug)
|
||||
if area.park.slug != park_slug:
|
||||
raise ObjectDoesNotExist("Park slug doesn't match")
|
||||
return area
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return "parks:park_detail"
|
||||
|
||||
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
||||
area = cast(ParkArea, self.object)
|
||||
return {"park_slug": area.park.slug, "area_slug": area.slug}
|
||||
|
||||
def form_valid(self, form: ParkForm) -> HttpResponse:
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
submission_type="CREATE",
|
||||
changes=changes,
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if hasattr(self.request.user, "role") and getattr(
|
||||
self.request.user, "role", None
|
||||
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.object_id = self.object.id
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
|
||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
|
||||
"longitude"
|
||||
):
|
||||
Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
name=self.object.name,
|
||||
location_type="park",
|
||||
latitude=form.cleaned_data["latitude"],
|
||||
longitude=form.cleaned_data["longitude"],
|
||||
street_address=form.cleaned_data.get(
|
||||
"street_address", ""),
|
||||
city=form.cleaned_data.get("city", ""),
|
||||
state=form.cleaned_data.get("state", ""),
|
||||
country=form.cleaned_data.get("country", ""),
|
||||
postal_code=form.cleaned_data.get("postal_code", ""),
|
||||
)
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
Photo.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(
|
||||
Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully created {self.object.name}. "
|
||||
f"Added {uploaded_count} photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error creating park: {str(e)}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
"Your park submission has been sent for review. "
|
||||
"You will be notified when it is approved.",
|
||||
)
|
||||
return HttpResponseRedirect(reverse("parks:park_list"))
|
||||
|
||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||
messages.error(
|
||||
self.request,
|
||||
"Please correct the errors below. Required fields are marked with an asterisk (*).",
|
||||
)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(self.request, f"{field}: {error}")
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
model = Park
|
||||
form_class = ParkForm
|
||||
template_name = "parks/park_form.html"
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["is_edit"] = True
|
||||
return context
|
||||
|
||||
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
|
||||
data = cleaned_data.copy()
|
||||
if data.get("owner"):
|
||||
data["owner"] = data["owner"].id
|
||||
if data.get("opening_date"):
|
||||
data["opening_date"] = data["opening_date"].isoformat()
|
||||
if data.get("closing_date"):
|
||||
data["closing_date"] = data["closing_date"].isoformat()
|
||||
decimal_fields = ["latitude", "longitude",
|
||||
"size_acres", "average_rating"]
|
||||
for field in decimal_fields:
|
||||
if data.get(field):
|
||||
data[field] = str(data[field])
|
||||
return data
|
||||
|
||||
def normalize_coordinates(self, form: ParkForm) -> None:
|
||||
if form.cleaned_data.get("latitude"):
|
||||
lat = Decimal(str(form.cleaned_data["latitude"]))
|
||||
form.cleaned_data["latitude"] = lat.quantize(
|
||||
Decimal("0.000001"), rounding=ROUND_DOWN
|
||||
)
|
||||
if form.cleaned_data.get("longitude"):
|
||||
lon = Decimal(str(form.cleaned_data["longitude"]))
|
||||
form.cleaned_data["longitude"] = lon.quantize(
|
||||
Decimal("0.000001"), rounding=ROUND_DOWN
|
||||
)
|
||||
|
||||
def form_valid(self, form: ParkForm) -> HttpResponse:
|
||||
self.normalize_coordinates(form)
|
||||
changes = self.prepare_changes_data(form.cleaned_data)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
submission_type="EDIT",
|
||||
changes=changes,
|
||||
reason=self.request.POST.get("reason", ""),
|
||||
source=self.request.POST.get("source", ""),
|
||||
)
|
||||
|
||||
if hasattr(self.request.user, "role") and getattr(
|
||||
self.request.user, "role", None
|
||||
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.status = "APPROVED"
|
||||
submission.handled_by = self.request.user
|
||||
submission.save()
|
||||
|
||||
location_data = {
|
||||
"name": self.object.name,
|
||||
"location_type": "park",
|
||||
"latitude": form.cleaned_data.get("latitude"),
|
||||
"longitude": form.cleaned_data.get("longitude"),
|
||||
"street_address": form.cleaned_data.get("street_address", ""),
|
||||
"city": form.cleaned_data.get("city", ""),
|
||||
"state": form.cleaned_data.get("state", ""),
|
||||
"country": form.cleaned_data.get("country", ""),
|
||||
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||
}
|
||||
|
||||
if self.object.location.exists():
|
||||
location = self.object.location.first()
|
||||
for key, value in location_data.items():
|
||||
setattr(location, key, value)
|
||||
location.save()
|
||||
else:
|
||||
Location.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Park),
|
||||
object_id=self.object.id,
|
||||
**location_data,
|
||||
)
|
||||
|
||||
photos = self.request.FILES.getlist("photos")
|
||||
uploaded_count = 0
|
||||
for photo_file in photos:
|
||||
try:
|
||||
Photo.objects.create(
|
||||
image=photo_file,
|
||||
uploaded_by=self.request.user,
|
||||
content_type=ContentType.objects.get_for_model(
|
||||
Park),
|
||||
object_id=self.object.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error uploading photo {photo_file.name}: {str(e)}",
|
||||
)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Successfully updated {self.object.name}. "
|
||||
f"Added {uploaded_count} new photo(s).",
|
||||
)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
self.request,
|
||||
f"Error updating park: {str(e)}. Please check your input and try again.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Your changes to {self.object.name} have been sent for review. "
|
||||
"You will be notified when they are approved.",
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||
)
|
||||
|
||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||
messages.error(
|
||||
self.request,
|
||||
"Please correct the errors below. Required fields are marked with an asterisk (*).",
|
||||
)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
messages.error(self.request, f"{field}: {error}")
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ParkAreaDetailView(
|
||||
SlugRedirectMixin,
|
||||
EditSubmissionMixin,
|
||||
|
||||
@@ -2181,6 +2181,18 @@ select {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -2457,6 +2469,10 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.h-10 {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
@@ -2485,6 +2501,10 @@ select {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
@@ -2533,6 +2553,10 @@ select {
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.w-64 {
|
||||
width: 16rem;
|
||||
}
|
||||
@@ -2646,6 +2670,16 @@ select {
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
@@ -3000,10 +3034,6 @@ select {
|
||||
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.bg-gray-900\/80 {
|
||||
background-color: rgb(17 24 39 / 0.8);
|
||||
}
|
||||
|
||||
.bg-green-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
|
||||
@@ -3244,6 +3274,10 @@ select {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pt-2 {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -3335,6 +3369,11 @@ select {
|
||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-blue-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(29 78 216 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-blue-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||
@@ -3405,6 +3444,11 @@ select {
|
||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-100 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(254 226 226 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||
@@ -3507,6 +3551,11 @@ select {
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.outline-none {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ring-2 {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
@@ -3522,6 +3571,19 @@ select {
|
||||
--tw-ring-color: rgb(79 70 229 / 0.2);
|
||||
}
|
||||
|
||||
.ring-offset-2 {
|
||||
--tw-ring-offset-width: 2px;
|
||||
}
|
||||
|
||||
.ring-offset-white {
|
||||
--tw-ring-offset-color: #fff;
|
||||
}
|
||||
|
||||
.blur {
|
||||
--tw-blur: blur(8px);
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
|
||||
.filter {
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
@@ -3796,6 +3858,11 @@ select {
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.focus\:bg-gray-100:focus {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.focus\:underline:focus {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -3824,6 +3891,10 @@ select {
|
||||
--tw-ring-offset-width: 2px;
|
||||
}
|
||||
|
||||
.active\:transform:active {
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
.disabled\:opacity-50:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -3930,6 +4001,10 @@ select {
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.dark\:bg-red-900\/40:is(.dark *) {
|
||||
background-color: rgb(127 29 29 / 0.4);
|
||||
}
|
||||
|
||||
.dark\:bg-yellow-200:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
|
||||
@@ -3968,6 +4043,11 @@ select {
|
||||
--tw-gradient-to: #3b0764 var(--tw-gradient-to-position);
|
||||
}
|
||||
|
||||
.dark\:text-blue-100:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(219 234 254 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:text-blue-200:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(191 219 254 / var(--tw-text-opacity));
|
||||
@@ -4190,6 +4270,11 @@ select {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:bg-gray-700:focus:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
@@ -4297,10 +4382,26 @@ select {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.md\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
}
|
||||
|
||||
.md\:mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.md\:block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md\:grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.md\:hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md\:h-\[140px\] {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user