mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-08 21:05:14 +10:00
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { useState, useCallback, useEffect } from "react";
|
|
import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react";
|
|
import { SearchBar } from "@/components/SearchBar";
|
|
import { WebResults } from "@/components/WebResults";
|
|
import { ImageResults } from "@/components/ImageResults";
|
|
import { SearchStats } from "@/components/SearchStats";
|
|
import { SettingsModal } from "@/components/SettingsModal";
|
|
import { ErrorToast } from "@/components/ErrorToast";
|
|
import { search as apiSearch, isImageResult, type SearchResponse, type WebResult, type ImageResult } from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Category = "web" | "images";
|
|
type ImageSize = "" | "large" | "medium" | "small";
|
|
|
|
const IMAGE_SIZE_OPTIONS: { value: ImageSize; label: string }[] = [
|
|
{ value: "", label: "All sizes" },
|
|
{ value: "large", label: "Large" },
|
|
{ value: "medium", label: "Medium" },
|
|
{ value: "small", label: "Small" },
|
|
];
|
|
|
|
function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize } {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const q = params.get("q") ?? "";
|
|
const cat = params.get("category") === "images" ? "images" : "web";
|
|
const page = Math.max(1, parseInt(params.get("page") ?? "1", 10) || 1);
|
|
const rawSize = params.get("image_size") ?? "";
|
|
const imageSize: ImageSize = (["large", "medium", "small"].includes(rawSize) ? rawSize : "") as ImageSize;
|
|
return { q, cat, page, imageSize };
|
|
}
|
|
|
|
function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "") {
|
|
const params = new URLSearchParams();
|
|
params.set("q", q);
|
|
if (cat !== "web") params.set("category", cat);
|
|
if (page > 1) params.set("page", String(page));
|
|
if (imageSize) params.set("image_size", imageSize);
|
|
const url = `/?${params.toString()}`;
|
|
if (window.location.pathname + window.location.search !== url) {
|
|
window.history.pushState(null, "", url);
|
|
}
|
|
}
|
|
|
|
function App() {
|
|
const initial = parseUrlState();
|
|
const [query, setQuery] = useState(initial.q);
|
|
const [category, setCategory] = useState<Category>(initial.cat);
|
|
const [page, setPage] = useState(initial.page);
|
|
const [imageSize, setImageSize] = useState<ImageSize>(initial.imageSize);
|
|
const [response, setResponse] = useState<SearchResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [hasSearched, setHasSearched] = useState(!!initial.q);
|
|
|
|
const doSearch = useCallback(
|
|
async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true) => {
|
|
if (!q.trim()) return;
|
|
setQuery(q);
|
|
setCategory(cat);
|
|
setPage(p);
|
|
setImageSize(size);
|
|
setLoading(true);
|
|
setHasSearched(true);
|
|
if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "");
|
|
try {
|
|
const res = await apiSearch(q, cat, p, cat === "images" ? size : "");
|
|
setResponse(res);
|
|
} catch (err) {
|
|
setResponse({
|
|
query: q,
|
|
category: cat,
|
|
page: p,
|
|
results: [],
|
|
errors: [{ engine: "system", message: String(err), is_timeout: false }],
|
|
suggestions: [],
|
|
engine_stats: [],
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[category, imageSize]
|
|
);
|
|
|
|
// Restore search from URL on initial load
|
|
useEffect(() => {
|
|
if (initial.q) {
|
|
doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Handle browser back/forward
|
|
useEffect(() => {
|
|
const onPopState = () => {
|
|
const { q, cat, page: p, imageSize: size } = parseUrlState();
|
|
if (q) {
|
|
doSearch(q, cat, p, size, false);
|
|
} else {
|
|
setHasSearched(false);
|
|
setResponse(null);
|
|
setQuery("");
|
|
setPage(1);
|
|
setCategory("web");
|
|
setImageSize("");
|
|
}
|
|
};
|
|
window.addEventListener("popstate", onPopState);
|
|
return () => window.removeEventListener("popstate", onPopState);
|
|
}, [doSearch]);
|
|
|
|
const handleCategoryChange = (cat: Category) => {
|
|
setCategory(cat);
|
|
if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "");
|
|
};
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
if (newPage < 1) return;
|
|
doSearch(query, category, newPage, imageSize);
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
};
|
|
|
|
const handleImageSizeChange = (size: ImageSize) => {
|
|
setImageSize(size);
|
|
if (query) doSearch(query, category, 1, size);
|
|
};
|
|
|
|
const handleGoHome = () => {
|
|
setHasSearched(false);
|
|
setResponse(null);
|
|
setPage(1);
|
|
setImageSize("");
|
|
window.history.pushState(null, "", "/");
|
|
};
|
|
|
|
const webResults = response?.results.filter((r): r is WebResult => !isImageResult(r)) ?? [];
|
|
const imageResults = response?.results.filter((r): r is ImageResult => isImageResult(r)) ?? [];
|
|
const hasResults = (response?.results.length ?? 0) > 0;
|
|
|
|
// Home page (no search yet)
|
|
if (!hasSearched) {
|
|
return (
|
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
|
<div className="mb-8 text-center">
|
|
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
|
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
|
Hey Search
|
|
</span>
|
|
</h1>
|
|
<p className="mt-2 text-muted-foreground">Private metasearch engine</p>
|
|
</div>
|
|
|
|
<SearchBar onSearch={(q) => doSearch(q)} className="w-full" />
|
|
|
|
<div className="mt-6">
|
|
<button
|
|
onClick={() => setShowSettings(true)}
|
|
className="flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
|
|
>
|
|
<Settings className="h-4 w-4" /> Settings
|
|
</button>
|
|
</div>
|
|
|
|
<footer className="absolute bottom-6 text-center">
|
|
<a
|
|
href="/docs"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
API Docs
|
|
</a>
|
|
</footer>
|
|
|
|
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Results page
|
|
return (
|
|
<div className="flex min-h-screen flex-col">
|
|
{/* Header */}
|
|
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur">
|
|
<div className="flex items-center gap-3 px-4 py-3">
|
|
<button
|
|
onClick={handleGoHome}
|
|
className="shrink-0 text-xl font-bold"
|
|
>
|
|
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
|
HS
|
|
</span>
|
|
</button>
|
|
<SearchBar initialQuery={query} onSearch={(q) => doSearch(q)} className="flex-1" />
|
|
<button
|
|
onClick={() => setShowSettings(true)}
|
|
className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-accent"
|
|
title="Settings"
|
|
>
|
|
<Settings className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Category tabs */}
|
|
<div className="flex items-center gap-1 px-4 pb-2">
|
|
{([
|
|
{ key: "web" as const, label: "Web", icon: Globe },
|
|
{ key: "images" as const, label: "Images", icon: ImageIcon },
|
|
]).map(({ key, label, icon: Icon }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => handleCategoryChange(key)}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
|
|
category === key
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
|
|
{/* Image size filter — only visible in images category */}
|
|
{category === "images" && (
|
|
<>
|
|
<div className="mx-2 h-5 w-px bg-border" />
|
|
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
|
{IMAGE_SIZE_OPTIONS.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => handleImageSizeChange(value)}
|
|
className={cn(
|
|
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
|
|
imageSize === value
|
|
? "bg-secondary text-secondary-foreground"
|
|
: "text-muted-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : response && response.results.length === 0 && response.errors.length === 0 ? (
|
|
<div className="py-20 text-center">
|
|
<Search className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
|
<p className="mt-4 text-lg text-muted-foreground">No results found for “{query}”</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Mobile stats — shown above results on small screens */}
|
|
{response && (
|
|
<div className="mb-4 lg:hidden">
|
|
<SearchStats
|
|
stats={response.engine_stats}
|
|
errors={response.errors}
|
|
page={page}
|
|
totalResults={response.results.length}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-6">
|
|
{/* Results column */}
|
|
<div className="min-w-0 flex-1">
|
|
{category === "web" && <WebResults results={webResults} />}
|
|
{category === "images" && <ImageResults results={imageResults} />}
|
|
|
|
{/* Pagination */}
|
|
{hasResults && (
|
|
<div className="mt-8 flex items-center justify-center gap-3">
|
|
<button
|
|
onClick={() => handlePageChange(page - 1)}
|
|
disabled={page <= 1}
|
|
className="flex items-center gap-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</button>
|
|
<span className="text-sm tabular-nums text-muted-foreground">
|
|
Page {page}
|
|
</span>
|
|
<button
|
|
onClick={() => handlePageChange(page + 1)}
|
|
className="flex items-center gap-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stats sidebar — hidden on mobile, shown on lg+ */}
|
|
{response && (
|
|
<aside className="hidden w-64 shrink-0 lg:block">
|
|
<div className="sticky top-28">
|
|
<SearchStats
|
|
stats={response.engine_stats}
|
|
errors={response.errors}
|
|
page={page}
|
|
totalResults={response.results.length}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
{/* Footer */}
|
|
<footer className="border-t px-4 py-3">
|
|
<div className="mx-auto flex max-w-6xl items-center justify-between text-xs text-muted-foreground">
|
|
<span>Hey Search</span>
|
|
<a
|
|
href="/docs"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
API Docs
|
|
</a>
|
|
</div>
|
|
</footer>
|
|
|
|
{/* Error toasts */}
|
|
{response?.errors && <ErrorToast errors={response.errors} />}
|
|
|
|
{/* Settings modal */}
|
|
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|