mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Update advanced search
This commit is contained in:
+13
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user