Compare commits

...

2 Commits

5 changed files with 163 additions and 59 deletions

View File

@@ -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>
<input type="search" <div class="relative">
name="search" <div x-data="{
id="search" open: false,
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" query: '{{ request.GET.search|default:'' }}',
placeholder="Search parks by name or location..." focusedIndex: -1,
hx-get="{% url 'parks:search_parks' %}?view_mode={{ view_mode|default:'grid' }}" suggestions: []
hx-trigger="input delay:300ms, search" }"
hx-target="#park-results" @click.away="open = false"
hx-push-url="true" x-init="$watch('query', value => { console.log('query:', value); console.log('open:', open) })"
hx-indicator="#search-indicator" class="relative">
value="{{ request.GET.search|default:'' }}" <input type="search"
aria-label="Search parks"> 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..."
x-model="query"
hx-get="{% url 'parks:search_parks' %}?view_mode={{ view_mode|default:'grid' }}"
hx-trigger="input delay:500ms, search"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-indicator"
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 %}

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

View File

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

View File

@@ -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"]
@@ -79,8 +80,8 @@ def normalize_osm_result(result: dict) -> dict:
# Get postal code with fallbacks # Get postal code with fallbacks
postal_code = (address.get('postcode') or postal_code = (address.get('postcode') or
address.get('postal_code') or address.get('postal_code') or
'') '')
return { return {
'display_name': name or result.get('display_name', ''), 'display_name': name or result.get('display_name', ''),
@@ -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
View 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)}')