Update advanced search

This commit is contained in:
2025-11-05 22:04:29 +11:00
parent 0fcb810241
commit fba291ff4c
5 changed files with 622 additions and 7 deletions
+13 -2
View File
@@ -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
+3 -1
View File
@@ -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'),
]
+107 -1
View File
@@ -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'
+3 -3
View File
@@ -62,9 +62,9 @@
</label>
<select name="sort" id="sort-order"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md">
<option value="newest" {% if sort_order == 'newest' %}selected{% endif %}>{% trans "Newest First" %}</option>
<option value="oldest" {% if sort_order == 'oldest' %}selected{% endif %}>{% trans "Oldest First" %}</option>
<option value="type" {% if sort_order == 'type' %}selected{% endif %}>{% trans "By Type" %}</option>
<option value="relevance" {% if sort_by == 'relevance' %}selected{% endif %}>{% trans "Most Relevant" %}</option>
<option value="newest" {% if sort_by == 'newest' %}selected{% endif %}>{% trans "Newest First" %}</option>
<option value="oldest" {% if sort_by == 'oldest' %}selected{% endif %}>{% trans "Oldest First" %}</option>
</select>
</div>
</div>
+496
View File
@@ -0,0 +1,496 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Search - GoLinks</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
@keyframes shimmer {
0% { background-position: -1000px 0; }
100% { background-position: 1000px 0; }
}
.shimmer {
animation: shimmer 2s infinite linear;
background: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%);
background-size: 1000px 100%;
}
.glass {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
</style>
</head>
<body class="bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 min-h-screen">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useMemo } = React;
// Badge Component
const Badge = ({ children, variant = "default", className = "" }) => {
const variants = {
default: "bg-gray-100 text-gray-800",
link: "bg-blue-100 text-blue-800",
page: "bg-green-100 text-green-800",
post: "bg-purple-100 text-purple-800"
};
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variants[variant]} ${className}`}>
{children}
</span>
);
};
// Card Component
const Card = ({ children, className = "", hover = false }) => (
<div className={`bg-white rounded-lg shadow-sm border border-gray-200 ${hover ? 'hover:shadow-md transition-shadow duration-200' : ''} ${className}`}>
{children}
</div>
);
// Input Component
const Input = ({ className = "", ...props }) => (
<input
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
/>
);
// Select Component
const Select = ({ children, className = "", ...props }) => (
<select
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
{...props}
>
{children}
</select>
);
// Button Component
const Button = ({ children, variant = "default", className = "", ...props }) => {
const variants = {
default: "bg-blue-600 text-white hover:bg-blue-700",
outline: "border-2 border-gray-300 bg-white hover:bg-gray-50",
ghost: "hover:bg-gray-100"
};
return (
<button
className={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium ring-offset-white transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${variants[variant]} ${className}`}
{...props}
>
{children}
</button>
);
};
// Skeleton Loader
const Skeleton = ({ className = "" }) => (
<div className={`shimmer rounded ${className}`}></div>
);
// Search Result Item Component
const SearchResultItem = ({ result }) => {
const typeIcons = {
link: "fa-link",
page: "fa-file-alt",
post: "fa-newspaper"
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
return (
<Card hover className="p-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-1">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
result.type === 'link' ? 'bg-blue-100 text-blue-600' :
result.type === 'page' ? 'bg-green-100 text-green-600' :
'bg-purple-100 text-purple-600'
}`}>
<i className={`fas ${typeIcons[result.type]}`}></i>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={result.type === 'link' ? result.url : result.detail_url || result.url}
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors truncate"
target={result.type === 'link' ? '_blank' : '_self'}
rel={result.type === 'link' ? 'noopener noreferrer' : ''}
>
{result.title}
</a>
<Badge variant={result.type}>
{result.type}
</Badge>
</div>
{result.type === 'link' && result.original_url && (
<a
href={result.original_url}
className="text-sm text-gray-600 hover:text-gray-900 truncate block mb-2"
target="_blank"
rel="noopener noreferrer"
>
<i className="fas fa-external-link-alt mr-1"></i>
{result.original_url}
</a>
)}
{(result.description || result.summary) && (
<p className="text-sm text-gray-600 line-clamp-2 mb-2">
{result.description || result.summary}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>
<i className="fas fa-calendar mr-1"></i>
{formatDate(result.created_at)}
</span>
{result.click_count !== undefined && (
<span>
<i className="fas fa-mouse-pointer mr-1"></i>
{result.click_count} clicks
</span>
)}
</div>
{result.tags && result.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{result.tags.map(tag => (
<Badge key={tag.id} variant="default" className="text-xs">
<i className="fas fa-tag mr-1"></i>
{tag.name}
</Badge>
))}
</div>
)}
</div>
<div className="flex-shrink-0">
<a
href={result.type === 'link' ? `/link/${result.id}/edit/` : result.type === 'page' ? `/ui/pages/${result.id}/edit/` : `/ui/posts/${result.id}/edit/`}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Edit"
>
<i className="fas fa-edit"></i>
</a>
</div>
</div>
</Card>
);
};
// Main Search App Component
const SearchApp = () => {
const [query, setQuery] = useState('');
const [type, setType] = useState('');
const [sort, setSort] = useState('relevance');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [error, setError] = useState(null);
const perPage = 20;
const performSearch = useCallback(async (searchQuery, searchType, searchSort, searchPage) => {
if (!searchQuery.trim()) {
setResults([]);
setTotal(0);
return;
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
q: searchQuery,
type: searchType,
sort: searchSort,
page: searchPage,
per_page: perPage
});
const response = await fetch(`/search/api/v2/?${params}`);
const data = await response.json();
if (response.ok) {
setResults(data.results);
setTotal(data.total);
} else {
setError(data.error || 'Search failed');
setResults([]);
}
} catch (err) {
setError('Network error. Please try again.');
setResults([]);
} finally {
setLoading(false);
}
}, []);
const handleSearch = useCallback((e) => {
e.preventDefault();
setPage(1);
performSearch(query, type, sort, 1);
}, [query, type, sort, performSearch]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const urlQuery = params.get('q') || '';
const urlType = params.get('type') || '';
const urlSort = params.get('sort') || 'relevance';
setQuery(urlQuery);
setType(urlType);
setSort(urlSort);
if (urlQuery) {
performSearch(urlQuery, urlType, urlSort, 1);
}
}, [performSearch]);
useEffect(() => {
if (query) {
const params = new URLSearchParams({
q: query,
...(type && { type }),
...(sort !== 'relevance' && { sort })
});
window.history.replaceState({}, '', `?${params}`);
}
}, [query, type, sort]);
const handlePageChange = (newPage) => {
setPage(newPage);
performSearch(query, type, sort, newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const totalPages = Math.ceil(total / perPage);
return (
<div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-2 flex items-center justify-center gap-3">
<i className="fas fa-search text-blue-600"></i>
Advanced Search
</h1>
<p className="text-gray-600">Search through all Links, Pages, and Posts</p>
</div>
{/* Search Form */}
<Card className="p-6 mb-8 glass">
<form onSubmit={handleSearch} className="space-y-4">
{/* Search Input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Query
</label>
<div className="relative">
<i className="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<Input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter keywords to search..."
className="pl-10"
/>
</div>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by Type
</label>
<Select value={type} onChange={(e) => setType(e.target.value)}>
<option value="">All Types</option>
<option value="link">Links</option>
<option value="page">Pages</option>
<option value="post">Posts</option>
</Select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<Select value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="relevance">Most Relevant</option>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
</Select>
</div>
</div>
{/* Search Button */}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<i className="fas fa-spinner fa-spin mr-2"></i>
Searching...
</>
) : (
<>
<i className="fas fa-search mr-2"></i>
Search
</>
)}
</Button>
</form>
</Card>
{/* Results */}
{query && (
<div>
{/* Results Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{loading ? (
'Searching...'
) : error ? (
<span className="text-red-600">
<i className="fas fa-exclamation-circle mr-2"></i>
{error}
</span>
) : (
<>
Search results for: <span className="text-blue-600">"{query}"</span>
{total > 0 && (
<span className="text-gray-500 text-sm ml-2">
({total} {total === 1 ? 'result' : 'results'})
</span>
)}
</>
)}
</h2>
</div>
{/* Loading Skeletons */}
{loading && (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<Card key={i} className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="w-10 h-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
</Card>
))}
</div>
)}
{/* Results List */}
{!loading && !error && results.length > 0 && (
<div className="space-y-4">
{results.map(result => (
<SearchResultItem key={`${result.type}-${result.id}`} result={result} />
))}
</div>
)}
{/* No Results */}
{!loading && !error && results.length === 0 && query && (
<Card className="p-12 text-center">
<i className="fas fa-search text-6xl text-gray-300 mb-4"></i>
<h3 className="text-lg font-medium text-gray-900 mb-2">No results found</h3>
<p className="text-gray-500">Try adjusting your search terms or filters</p>
</Card>
)}
{/* Pagination */}
{!loading && totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
<i className="fas fa-chevron-left mr-2"></i>
Previous
</Button>
<div className="flex items-center gap-2">
{[...Array(Math.min(5, totalPages))].map((_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<Button
key={pageNum}
variant={page === pageNum ? "default" : "outline"}
onClick={() => handlePageChange(pageNum)}
className="w-10 h-10 p-0"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => handlePageChange(page + 1)}
disabled={page === totalPages}
>
Next
<i className="fas fa-chevron-right ml-2"></i>
</Button>
</div>
)}
</div>
)}
{/* Empty State */}
{!query && (
<Card className="p-12 text-center glass">
<i className="fas fa-search text-6xl text-blue-200 mb-4"></i>
<h3 className="text-xl font-medium text-gray-900 mb-2">Start Searching</h3>
<p className="text-gray-600 max-w-md mx-auto">
Enter a search query to find links, pages, and posts.
You can search by title, content, URL, or tags.
</p>
</Card>
)}
</div>
</div>
);
};
// Render the app
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<SearchApp />);
</script>
</body>
</html>