diff --git a/links/search_backend.py b/links/search_backend.py index bdb3679..bc1398a 100644 --- a/links/search_backend.py +++ b/links/search_backend.py @@ -211,7 +211,7 @@ class SearchBackend: logger.error(f"Error rebuilding index: {e}", exc_info=True) raise - def search(self, query_string, model_type=None, limit=100, page=1, per_page=20): + def search(self, query_string, model_type=None, limit=100, page=1, per_page=20, sort_by='relevance'): """ Search the index with the given query @@ -221,6 +221,7 @@ class SearchBackend: limit: Maximum number of results to return page: Page number for pagination per_page: Results per page + sort_by: Sort order - 'relevance', 'newest', 'oldest' Returns: dict with results and metadata @@ -246,8 +247,18 @@ class SearchBackend: from whoosh.query import And, Term query = And([query, Term('model_type', model_type)]) + # Determine sort order + sortedby = None + reverse = True + if sort_by == 'newest': + sortedby = 'created_at' + reverse = True + elif sort_by == 'oldest': + sortedby = 'created_at' + reverse = False + # Execute search - results = searcher.search(query, limit=limit) + results = searcher.search(query, limit=limit, sortedby=sortedby, reverse=reverse) # Extract results with pagination start = (page - 1) * per_page diff --git a/links/search_urls.py b/links/search_urls.py index 314ec95..99d2585 100644 --- a/links/search_urls.py +++ b/links/search_urls.py @@ -1,8 +1,10 @@ from django.urls import path -from .search_views import SearchView, search, search_aliases +from .search_views import SearchView, search, search_aliases, search_api_v2, SearchReactView urlpatterns = [ path('', SearchView.as_view(), name='search'), + path('react/', SearchReactView.as_view(), name='search_react'), path('api/', search, name='search_api'), + path('api/v2/', search_api_v2, name='search_api_v2'), path('aliases/', search_aliases, name='search_aliases'), ] diff --git a/links/search_views.py b/links/search_views.py index 7a32155..6205658 100644 --- a/links/search_views.py +++ b/links/search_views.py @@ -2,11 +2,15 @@ import logging from itertools import chain from django.http import JsonResponse from django.views.generic import ListView +from django.views.generic.base import TemplateView from django.db.models import Q, Value, CharField from django.db import models from django.utils.text import slugify from .models import Link, Page, Post from .search_backend import search_backend +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt +import json logger = logging.getLogger(__name__) @@ -18,6 +22,7 @@ class SearchView(ListView): def get_queryset(self): query = self.request.GET.get('q', '').strip() type_filter = self.request.GET.get('type', '') + sort_by = self.request.GET.get('sort', 'relevance') page_num = self.request.GET.get('page', 1) try: @@ -32,7 +37,8 @@ class SearchView(ListView): query_string=query, model_type=type_filter if type_filter else None, page=page_num, - per_page=self.paginate_by + per_page=self.paginate_by, + sort_by=sort_by ) # Enrich results with actual model data @@ -103,6 +109,7 @@ class SearchView(ListView): context.update({ 'query': self.request.GET.get('q', ''), 'selected_type': self.request.GET.get('type', ''), + 'sort_by': self.request.GET.get('sort', 'relevance'), 'total_results': getattr(self, 'search_total', 0), 'search_error': getattr(self, 'search_error', None), }) @@ -140,6 +147,101 @@ def search(request): results = [] return JsonResponse(results, safe=False) +@require_http_methods(["GET"]) +def search_api_v2(request): + """ + Modern API endpoint for React search interface + """ + query = request.GET.get('q', '').strip() + type_filter = request.GET.get('type', '') + sort_by = request.GET.get('sort', 'relevance') + page = int(request.GET.get('page', 1)) + per_page = int(request.GET.get('per_page', 20)) + + if not query: + return JsonResponse({ + 'results': [], + 'total': 0, + 'page': page, + 'per_page': per_page, + 'has_next': False, + 'has_prev': False + }) + + try: + # Use Whoosh search backend + search_results = search_backend.search( + query_string=query, + model_type=type_filter if type_filter else None, + page=page, + per_page=per_page, + sort_by=sort_by + ) + + # Enrich results with actual model data + enriched_results = [] + for result in search_results['results']: + model_type = result['model_type'] + model_id = result['model_id'] + + try: + if model_type == 'link': + link = Link.objects.get(id=model_id) + enriched_results.append({ + 'id': link.id, + 'type': 'link', + 'title': link.alias, + 'url': f'/{link.alias}', + 'original_url': link.original_url, + 'description': link.description or '', + 'click_count': link.click_count, + 'created_at': link.created_at.isoformat(), + 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in link.tags.all()] + }) + elif model_type == 'page': + page_obj = Page.objects.get(id=model_id) + enriched_results.append({ + 'id': page_obj.id, + 'type': 'page', + 'title': page_obj.title or 'Untitled', + 'url': page_obj.url, + 'detail_url': f'/ui/pages/{page_obj.id}/', + 'summary': page_obj.summary or '', + 'created_at': page_obj.created_at.isoformat(), + 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in page_obj.tags.all()] + }) + elif model_type == 'post': + post = Post.objects.get(id=model_id) + enriched_results.append({ + 'id': post.id, + 'type': 'post', + 'title': post.title, + 'url': f'/ui/posts/{post.id}/', + 'summary': post.summary or '', + 'created_at': post.created_at.isoformat(), + 'tags': [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in post.tags.all()] + }) + except Exception as e: + logger.error(f"Error enriching {model_type} {model_id}: {e}") + continue + + return JsonResponse({ + 'results': enriched_results, + 'total': search_results['total'], + 'page': search_results['page'], + 'per_page': search_results['per_page'], + 'has_next': search_results['has_next'], + 'has_prev': search_results['has_prev'] + }) + + except Exception as e: + logger.error(f"Search API error: {e}", exc_info=True) + return JsonResponse({ + 'error': str(e), + 'results': [], + 'total': 0 + }, status=500) + def search_aliases(request): query = request.GET.get('q', '').strip() links = Link.objects.filter(alias__icontains=query)[:10] @@ -154,3 +256,7 @@ def search_aliases(request): continue return JsonResponse(results, safe=False) + +class SearchReactView(TemplateView): + """Modern React-based search interface""" + template_name = 'links/search_react.html' diff --git a/links/templates/links/search.html b/links/templates/links/search.html index 6ddc191..36248d1 100644 --- a/links/templates/links/search.html +++ b/links/templates/links/search.html @@ -62,9 +62,9 @@ diff --git a/links/templates/links/search_react.html b/links/templates/links/search_react.html new file mode 100644 index 0000000..c99daa8 --- /dev/null +++ b/links/templates/links/search_react.html @@ -0,0 +1,496 @@ +{% load static %} + + + + + + Advanced Search - GoLinks + + + + + + + + +
+ + + +