mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-28 09:07:03 -05:00
Compare commits
2 Commits
8444c58b44
...
65f8aee1d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65f8aee1d7 | ||
|
|
c1da84491a |
@@ -48,18 +48,43 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="max-w-3xl mx-auto relative mb-8">
|
<div class="max-w-3xl mx-auto relative mb-8">
|
||||||
<label for="search" class="sr-only">Search parks</label>
|
<label for="search" class="sr-only">Search parks</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div x-data="{
|
||||||
|
open: false,
|
||||||
|
query: '{{ request.GET.search|default:'' }}',
|
||||||
|
focusedIndex: -1,
|
||||||
|
suggestions: []
|
||||||
|
}"
|
||||||
|
@click.away="open = false"
|
||||||
|
x-init="$watch('query', value => { console.log('query:', value); console.log('open:', open) })"
|
||||||
|
class="relative">
|
||||||
<input type="search"
|
<input type="search"
|
||||||
name="search"
|
name="search"
|
||||||
id="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"
|
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..."
|
placeholder="Search parks by name or location..."
|
||||||
|
x-model="query"
|
||||||
hx-get="{% url 'parks:search_parks' %}?view_mode={{ view_mode|default:'grid' }}"
|
hx-get="{% url 'parks:search_parks' %}?view_mode={{ view_mode|default:'grid' }}"
|
||||||
hx-trigger="input delay:300ms, search"
|
hx-trigger="input delay:500ms, search"
|
||||||
hx-target="#park-results"
|
hx-target="#park-results"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-indicator="#search-indicator"
|
hx-indicator="#search-indicator"
|
||||||
value="{{ request.GET.search|default:'' }}"
|
aria-label="Search parks"
|
||||||
aria-label="Search parks">
|
@keydown.down.prevent="focusedIndex = Math.min(focusedIndex + 1, $refs.suggestionsList?.children.length - 1 || 0)"
|
||||||
|
@keydown.up.prevent="focusedIndex = Math.max(focusedIndex - 1, -1)"
|
||||||
|
@keydown.enter.prevent="if (focusedIndex >= 0) $refs.suggestionsList?.children[focusedIndex]?.click()">
|
||||||
|
<div class="relative">
|
||||||
|
<div hx-get="{% url 'parks:suggest_parks' %}?view_mode={{ view_mode|default:'grid' }}"
|
||||||
|
hx-trigger="input[target.value.length > 1] delay:300ms from:input"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-include="closest input[name=search]"
|
||||||
|
x-ref="suggestionsList"
|
||||||
|
@htmx:afterRequest="open = detail.xhr.response.trim().length > 0"
|
||||||
|
@htmx:beforeRequest="open = false"
|
||||||
|
class="absolute top-full left-0 right-0 mt-1 z-50"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
<div id="search-indicator" class="htmx-indicator">
|
<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">
|
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -94,6 +119,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<script src="{% static 'parks/js/search.js' %}"></script>
|
<script src="{% static 'parks/js/search.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
29
parks/templates/parks/partials/search_suggestions.html
Normal file
29
parks/templates/parks/partials/search_suggestions.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% if suggestions %}
|
||||||
|
<div class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
@keydown.escape.window="open = false"
|
||||||
|
x-transition:enter="transition ease-out duration-100"
|
||||||
|
x-transition:enter-start="transform opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="transform opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-75"
|
||||||
|
x-transition:leave-start="transform opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="transform opacity-0 scale-95">
|
||||||
|
{% for park in suggestions %}
|
||||||
|
<a href="{% url 'parks:park_detail' slug=park.slug %}"
|
||||||
|
class="block px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between"
|
||||||
|
:class="{ 'bg-gray-100': focusedIndex === {{ forloop.counter0 }} }"
|
||||||
|
hx-get="{% url 'parks:search_parks' %}?search={{ park.name }}&view_mode={{ view_mode|default:'grid' }}"
|
||||||
|
hx-target="#park-results"
|
||||||
|
hx-push-url="true"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="query = '{{ park.name }}'; open = false">
|
||||||
|
<span class="font-medium">{{ park.name }}</span>
|
||||||
|
<span class="text-gray-500">
|
||||||
|
{% if park.location.first.city %}{{ park.location.first.city }}, {% endif %}
|
||||||
|
{% if park.location.first.state %}{{ park.location.first.state }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from . import views
|
from . import views, views_search
|
||||||
from rides.views import ParkSingleCategoryListView
|
from rides.views import ParkSingleCategoryListView
|
||||||
|
|
||||||
app_name = "parks"
|
app_name = "parks"
|
||||||
@@ -18,6 +18,8 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Areas and search endpoints for HTMX
|
# Areas and search endpoints for HTMX
|
||||||
path("areas/", views.get_park_areas, name="get_park_areas"),
|
path("areas/", views.get_park_areas, name="get_park_areas"),
|
||||||
|
path("suggestions/", views_search.suggest_parks, name="suggest_parks"),
|
||||||
|
|
||||||
path("search/", views.search_parks, name="search_parks"),
|
path("search/", views.search_parks, name="search_parks"),
|
||||||
|
|
||||||
# Park detail and related views
|
# Park detail and related views
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
|
from .querysets import get_base_park_queryset
|
||||||
|
from search.mixins import HTMXFilterableMixin
|
||||||
|
from reviews.models import Review
|
||||||
|
from location.models import Location
|
||||||
|
from media.models import Photo
|
||||||
|
from moderation.models import EditSubmission
|
||||||
|
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
||||||
|
from core.views import SlugRedirectMixin
|
||||||
|
from .filters import ParkFilter
|
||||||
|
from .forms import ParkForm
|
||||||
|
from .models import Park, ParkArea
|
||||||
|
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.db.models import Q, Count, QuerySet
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
from decimal import InvalidOperation
|
||||||
|
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||||
import requests
|
import requests
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
from typing import Any, Optional, cast, Literal
|
from typing import Any, Optional, cast, Literal
|
||||||
@@ -8,26 +29,6 @@ PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
|||||||
REQUIRED_FIELDS_ERROR = "Please correct the errors below. Required fields are marked with an asterisk (*)."
|
REQUIRED_FIELDS_ERROR = "Please correct the errors below. Required fields are marked with an asterisk (*)."
|
||||||
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||||
|
|
||||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
|
||||||
from decimal import InvalidOperation
|
|
||||||
from django.shortcuts import get_object_or_404, render
|
|
||||||
from django.urls import reverse
|
|
||||||
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.core.exceptions import ObjectDoesNotExist
|
|
||||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
|
|
||||||
from .models import Park, ParkArea
|
|
||||||
from .forms import ParkForm
|
|
||||||
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 search.mixins import HTMXFilterableMixin
|
|
||||||
|
|
||||||
ViewMode = Literal["grid", "list"]
|
ViewMode = Literal["grid", "list"]
|
||||||
|
|
||||||
@@ -96,23 +97,24 @@ def normalize_osm_result(result: dict) -> dict:
|
|||||||
'postal_code': postal_code,
|
'postal_code': postal_code,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_view_mode(request: HttpRequest) -> ViewMode:
|
def get_view_mode(request: HttpRequest) -> ViewMode:
|
||||||
"""Get the current view mode from request, defaulting to grid"""
|
"""Get the current view mode from request, defaulting to grid"""
|
||||||
view_mode = request.GET.get('view_mode', 'grid')
|
view_mode = request.GET.get('view_mode', 'grid')
|
||||||
return cast(ViewMode, 'list' if view_mode == 'list' else 'grid')
|
return cast(ViewMode, 'list' if view_mode == 'list' else 'grid')
|
||||||
|
|
||||||
|
|
||||||
from .querysets import get_base_park_queryset
|
|
||||||
|
|
||||||
def add_park_button(request: HttpRequest) -> HttpResponse:
|
def add_park_button(request: HttpRequest) -> HttpResponse:
|
||||||
"""Return the add park button partial template"""
|
"""Return the add park button partial template"""
|
||||||
return render(request, "parks/partials/add_park_button.html")
|
return render(request, "parks/partials/add_park_button.html")
|
||||||
|
|
||||||
|
|
||||||
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
|
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
|
||||||
"""Return the park actions partial template"""
|
"""Return the park actions partial template"""
|
||||||
park = get_object_or_404(Park, slug=slug)
|
park = get_object_or_404(Park, slug=slug)
|
||||||
return render(request, "parks/partials/park_actions.html", {"park": park})
|
return render(request, "parks/partials/park_actions.html", {"park": park})
|
||||||
|
|
||||||
|
|
||||||
def get_park_areas(request: HttpRequest) -> HttpResponse:
|
def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||||
"""Return park areas as options for a select element"""
|
"""Return park areas as options for a select element"""
|
||||||
park_id = request.GET.get('park')
|
park_id = request.GET.get('park')
|
||||||
@@ -131,6 +133,7 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
|
|||||||
except Park.DoesNotExist:
|
except Park.DoesNotExist:
|
||||||
return HttpResponse('<option value="">Invalid park selected</option>')
|
return HttpResponse('<option value="">Invalid park selected</option>')
|
||||||
|
|
||||||
|
|
||||||
def location_search(request: HttpRequest) -> JsonResponse:
|
def location_search(request: HttpRequest) -> JsonResponse:
|
||||||
"""Search for locations using OpenStreetMap Nominatim API"""
|
"""Search for locations using OpenStreetMap Nominatim API"""
|
||||||
query = request.GET.get("q", "")
|
query = request.GET.get("q", "")
|
||||||
@@ -153,7 +156,8 @@ def location_search(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
results = response.json()
|
results = response.json()
|
||||||
normalized_results = [normalize_osm_result(result) for result in results]
|
normalized_results = [normalize_osm_result(
|
||||||
|
result) for result in results]
|
||||||
valid_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
|
if r["lat"] is not None and r["lon"] is not None
|
||||||
@@ -162,6 +166,7 @@ def location_search(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
return JsonResponse({"results": []})
|
return JsonResponse({"results": []})
|
||||||
|
|
||||||
|
|
||||||
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||||
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
||||||
try:
|
try:
|
||||||
@@ -311,6 +316,7 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
|||||||
response['HX-Trigger'] = 'searchError'
|
response['HX-Trigger'] = 'searchError'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||||
model = Park
|
model = Park
|
||||||
form_class = ParkForm
|
form_class = ParkForm
|
||||||
@@ -324,7 +330,8 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
data["opening_date"] = data["opening_date"].isoformat()
|
data["opening_date"] = data["opening_date"].isoformat()
|
||||||
if data.get("closing_date"):
|
if data.get("closing_date"):
|
||||||
data["closing_date"] = data["closing_date"].isoformat()
|
data["closing_date"] = data["closing_date"].isoformat()
|
||||||
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
decimal_fields = ["latitude", "longitude",
|
||||||
|
"size_acres", "average_rating"]
|
||||||
for field in decimal_fields:
|
for field in decimal_fields:
|
||||||
if data.get(field):
|
if data.get(field):
|
||||||
data[field] = str(data[field])
|
data[field] = str(data[field])
|
||||||
@@ -375,7 +382,8 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
location_type="park",
|
location_type="park",
|
||||||
latitude=form.cleaned_data["latitude"],
|
latitude=form.cleaned_data["latitude"],
|
||||||
longitude=form.cleaned_data["longitude"],
|
longitude=form.cleaned_data["longitude"],
|
||||||
street_address=form.cleaned_data.get("street_address", ""),
|
street_address=form.cleaned_data.get(
|
||||||
|
"street_address", ""),
|
||||||
city=form.cleaned_data.get("city", ""),
|
city=form.cleaned_data.get("city", ""),
|
||||||
state=form.cleaned_data.get("state", ""),
|
state=form.cleaned_data.get("state", ""),
|
||||||
country=form.cleaned_data.get("country", ""),
|
country=form.cleaned_data.get("country", ""),
|
||||||
@@ -389,7 +397,8 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
Photo.objects.create(
|
Photo.objects.create(
|
||||||
image=photo_file,
|
image=photo_file,
|
||||||
uploaded_by=self.request.user,
|
uploaded_by=self.request.user,
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(
|
||||||
|
Park),
|
||||||
object_id=self.object.id,
|
object_id=self.object.id,
|
||||||
)
|
)
|
||||||
uploaded_count += 1
|
uploaded_count += 1
|
||||||
@@ -444,7 +453,8 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
data["opening_date"] = data["opening_date"].isoformat()
|
data["opening_date"] = data["opening_date"].isoformat()
|
||||||
if data.get("closing_date"):
|
if data.get("closing_date"):
|
||||||
data["closing_date"] = data["closing_date"].isoformat()
|
data["closing_date"] = data["closing_date"].isoformat()
|
||||||
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
decimal_fields = ["latitude", "longitude",
|
||||||
|
"size_acres", "average_rating"]
|
||||||
for field in decimal_fields:
|
for field in decimal_fields:
|
||||||
if data.get(field):
|
if data.get(field):
|
||||||
data[field] = str(data[field])
|
data[field] = str(data[field])
|
||||||
@@ -516,7 +526,8 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
Photo.objects.create(
|
Photo.objects.create(
|
||||||
image=photo_file,
|
image=photo_file,
|
||||||
uploaded_by=self.request.user,
|
uploaded_by=self.request.user,
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(
|
||||||
|
Park),
|
||||||
object_id=self.object.id,
|
object_id=self.object.id,
|
||||||
)
|
)
|
||||||
uploaded_count += 1
|
uploaded_count += 1
|
||||||
@@ -651,5 +662,3 @@ class ParkAreaDetailView(
|
|||||||
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
||||||
area = cast(ParkArea, self.object)
|
area = cast(ParkArea, self.object)
|
||||||
return {"park_slug": area.park.slug, "area_slug": area.slug}
|
return {"park_slug": area.park.slug, "area_slug": area.slug}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
32
parks/views_search.py
Normal file
32
parks/views_search.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from .filters import ParkFilter
|
||||||
|
from .querysets import get_base_park_queryset
|
||||||
|
|
||||||
|
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""Return park search suggestions as a dropdown"""
|
||||||
|
try:
|
||||||
|
query = request.GET.get('search', '').strip()
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return HttpResponse('')
|
||||||
|
|
||||||
|
# Get current view mode from request
|
||||||
|
current_view_mode = request.GET.get('view_mode', 'grid')
|
||||||
|
park_filter = ParkFilter({
|
||||||
|
'search': query
|
||||||
|
}, queryset=get_base_park_queryset())
|
||||||
|
|
||||||
|
parks = park_filter.qs[:8] # Limit to 8 suggestions
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
'parks/partials/search_suggestions.html',
|
||||||
|
{
|
||||||
|
'suggestions': parks,
|
||||||
|
'view_mode': current_view_mode
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response['HX-Trigger'] = 'showSuggestions'
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponse(f'Error getting suggestions: {str(e)}')
|
||||||
Reference in New Issue
Block a user