mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
476 lines
21 KiB
React
476 lines
21 KiB
React
import React, { useState, useEffect, useCallback } from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
|
|
// 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 [isVector, setIsVector] = useState(false);
|
|
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 endpoint = isVector ? "/search/api/vector/" : "/search/api/v2/";
|
|
const response = await fetch(`${endpoint}?${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);
|
|
}
|
|
}, [isVector]);
|
|
|
|
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">
|
|
<a href="/" className="inline-block hover:opacity-80 transition-opacity">
|
|
<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>
|
|
</a>
|
|
<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>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<label className="text-sm font-medium text-gray-700">AI Vector Search</label>
|
|
<input
|
|
type="checkbox"
|
|
checked={isVector}
|
|
onChange={(e) => setIsVector(e.target.checked)}
|
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
/>
|
|
</div>
|
|
<Select value={sort} onChange={(e) => setSort(e.target.value)} disabled={isVector}>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
// Mount app
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<SearchApp />);
|