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="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' %}?view_mode={{ view_mode|default:'grid' }}"
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="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"
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 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">
@@ -94,6 +119,13 @@
</div>
{% endblock %}
{% block extra_css %}
<style>
[x-cloak] { display: none !important; }
</style>
{% endblock %}
{% 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>
{% 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 . import views
from . import views, views_search
from rides.views import ParkSingleCategoryListView
app_name = "parks"
@@ -18,6 +18,8 @@ urlpatterns = [
# Areas and search endpoints for HTMX
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"),
# 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
from decimal import Decimal, ROUND_DOWN
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 (*)."
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"]
@@ -35,19 +36,19 @@ ViewMode = Literal["grid", "list"]
def normalize_osm_result(result: dict) -> dict:
"""Normalize OpenStreetMap result to a consistent format with enhanced address details"""
from .location_utils import get_english_name, normalize_coordinate
# Get address details
address = result.get('address', {})
# Normalize coordinates
lat = normalize_coordinate(float(result.get('lat')), 9, 6)
lon = normalize_coordinate(float(result.get('lon')), 10, 6)
# Get English names where possible
name = ''
if 'namedetails' in result:
name = get_english_name(result['namedetails'])
# Build street address from available components
street_parts = []
if address.get('house_number'):
@@ -58,30 +59,30 @@ def normalize_osm_result(result: dict) -> dict:
street_parts.append(address['pedestrian'])
elif address.get('footway'):
street_parts.append(address['footway'])
# Handle additional address components
suburb = address.get('suburb', '')
district = address.get('district', '')
neighborhood = address.get('neighbourhood', '')
# Build city from available components
city = (address.get('city') or
address.get('town') or
address.get('village') or
address.get('municipality') or
'')
# Get detailed state/region information
state = (address.get('state') or
address.get('province') or
address.get('region') or
'')
# Get postal code with fallbacks
postal_code = (address.get('postcode') or
address.get('postal_code') or
'')
address.get('postal_code') or
'')
return {
'display_name': name or result.get('display_name', ''),
'lat': lat,
@@ -96,29 +97,30 @@ def normalize_osm_result(result: dict) -> dict:
'postal_code': postal_code,
}
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')
from .querysets import get_base_park_queryset
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')
if not park_id:
return HttpResponse('<option value="">Select a park first</option>')
try:
park = Park.objects.get(id=park_id)
areas = park.areas.all()
@@ -131,6 +133,7 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
except Park.DoesNotExist:
return HttpResponse('<option value="">Invalid park selected</option>')
def location_search(request: HttpRequest) -> JsonResponse:
"""Search for locations using OpenStreetMap Nominatim API"""
query = request.GET.get("q", "")
@@ -153,7 +156,8 @@ def location_search(request: HttpRequest) -> JsonResponse:
if response.status_code == 200:
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 = [
r for r in normalized_results
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": []})
def reverse_geocode(request: HttpRequest) -> JsonResponse:
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
try:
@@ -217,11 +222,11 @@ class ParkListView(HTMXFilterableMixin, ListView):
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:
@@ -229,7 +234,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
except Exception as e:
messages.error(self.request, f"Error loading parks: {str(e)}")
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
@@ -243,7 +248,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
self.request.GET,
queryset=self.model.objects.none()
)
context = super().get_context_data(**kwargs)
context.update({
'view_mode': self.get_view_mode(),
@@ -311,6 +316,7 @@ def search_parks(request: HttpRequest) -> HttpResponse:
response['HX-Trigger'] = 'searchError'
return response
class ParkCreateView(LoginRequiredMixin, CreateView):
model = Park
form_class = ParkForm
@@ -324,7 +330,8 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
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"]
decimal_fields = ["latitude", "longitude",
"size_acres", "average_rating"]
for field in decimal_fields:
if data.get(field):
data[field] = str(data[field])
@@ -375,7 +382,8 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
location_type="park",
latitude=form.cleaned_data["latitude"],
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", ""),
state=form.cleaned_data.get("state", ""),
country=form.cleaned_data.get("country", ""),
@@ -389,7 +397,8 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
Photo.objects.create(
image=photo_file,
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,
)
uploaded_count += 1
@@ -444,7 +453,8 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
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"]
decimal_fields = ["latitude", "longitude",
"size_acres", "average_rating"]
for field in decimal_fields:
if data.get(field):
data[field] = str(data[field])
@@ -516,7 +526,8 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
Photo.objects.create(
image=photo_file,
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,
)
uploaded_count += 1
@@ -651,5 +662,3 @@ class ParkAreaDetailView(
def get_redirect_url_kwargs(self) -> dict[str, str]:
area = cast(ParkArea, self.object)
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)}')