Files
links/static_src/search.jsx
T
2026-07-30 13:25:36 +10:00

528 lines
24 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",
bookmark: "bg-sky-100 text-sky-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",
bookmark: "fa-bookmark"
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const isBookmark = result.type === 'bookmark';
const badgeColor = result.type === 'link' ? 'bg-blue-100 text-blue-600' :
result.type === 'page' ? 'bg-green-100 text-green-600' :
result.type === 'post' ? 'bg-purple-100 text-purple-600' :
result.type === 'bookmark' ? 'bg-sky-100 text-sky-600' :
'bg-gray-100 text-gray-600';
const titleHref = isBookmark ? (result.detail_url || result.url) :
result.type === 'link' ? result.url : (result.detail_url || result.url);
const titleTarget = (result.type === 'link' || isBookmark) ? '_blank' : '_self';
const titleRel = (result.type === 'link' || isBookmark) ? 'noopener noreferrer' : '';
return (
<Card hover className="p-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 mt-1">
{isBookmark && result.author_profile_image_url ? (
<img
src={result.author_profile_image_url}
alt=""
className="w-10 h-10 rounded-full object-cover border border-gray-200"
loading="lazy"
/>
) : (
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${badgeColor}`}>
<i className={`fas ${typeIcons[result.type] || 'fa-circle'}`}></i>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<a
href={titleHref}
className="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors truncate"
target={titleTarget}
rel={titleRel}
>
{isBookmark ? `@${result.author_screen_name}` : result.title}
</a>
<Badge variant={result.type}>
{result.type}
</Badge>
</div>
{isBookmark ? (
<>
{(result.text || result.summary || result.title) && (
<p className="text-sm text-gray-700 line-clamp-3 mb-2 whitespace-pre-wrap">
{result.summary ? result.summary : (result.text || result.title)}
</p>
)}
{result.has_photo && (
<span className="inline-flex items-center text-xs text-gray-500 mr-3">
<i className="fas fa-image mr-1"></i>photo
</span>
)}
{result.has_video && (
<span className="inline-flex items-center text-xs text-gray-500 mr-3">
<i className="fas fa-video mr-1"></i>video
</span>
)}
<a href={result.url || result.detail_url}
target="_blank" rel="noopener noreferrer"
className="inline-flex items-center text-xs text-blue-600 hover:underline mr-3">
<i className="fas fa-external-link-alt mr-1"></i>Open on X
</a>
<a href={result.detail_url}
className="inline-flex items-center text-xs text-gray-500 hover:text-gray-800">
<i className="fas fa-list mr-1"></i>Detail
</a>
</>
) : (
<>
{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>
{!isBookmark && (
<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, Posts and Bookmarks</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>
<option value="bookmark">Bookmarks</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, posts, and bookmarks.
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 />);