mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-09 05:06:23 +10:00
739 lines
28 KiB
TypeScript
739 lines
28 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { Search, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal, RefreshCw } 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 { BackgroundGallery } from "@/components/BackgroundGallery";
|
|
import { Bookmarks } from "@/components/Bookmarks";
|
|
import { AppHeader } from "@/components/AppHeader";
|
|
import { StatsPage } from "@/components/StatsPage";
|
|
import { History } from "@/components/History";
|
|
import { search as apiSearch, isImageResult, getBackground, refreshBackground, getBookmarkedUrls, addBookmark, removeBookmarkByUrl, getVersion, type SearchResponse, type WebResult, type ImageResult, type BackgroundInfo, type VersionInfo } from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Category = "web" | "images";
|
|
type ImageSize = "" | "large" | "medium" | "small";
|
|
type SortOrder = "default" | "date_desc" | "date_asc";
|
|
|
|
const IMAGE_SIZE_OPTIONS: { value: ImageSize; label: string }[] = [
|
|
{ value: "", label: "All sizes" },
|
|
{ value: "large", label: "Large" },
|
|
{ value: "medium", label: "Medium" },
|
|
{ value: "small", label: "Small" },
|
|
];
|
|
|
|
const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
|
{ value: "default", label: "Default" },
|
|
{ value: "date_desc", label: "Newest" },
|
|
{ value: "date_asc", label: "Oldest" },
|
|
];
|
|
|
|
function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize; engines: string; sort: SortOrder } {
|
|
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;
|
|
const engines = params.get("engines") ?? "";
|
|
const rawSort = params.get("sort") ?? "";
|
|
const sort: SortOrder = (["date_desc", "date_asc"].includes(rawSort) ? rawSort : "default") as SortOrder;
|
|
return { q, cat, page, imageSize, engines, sort };
|
|
}
|
|
|
|
function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "", engines: string = "", sort: SortOrder = "default") {
|
|
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);
|
|
if (engines) params.set("engines", engines);
|
|
if (sort !== "default") params.set("sort", sort);
|
|
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 [sortOrder, setSortOrder] = useState<SortOrder>(initial.sort);
|
|
const [response, setResponse] = useState<SearchResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
const [hasSearched, setHasSearched] = useState(!!initial.q);
|
|
const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds");
|
|
const [showBookmarks, setShowBookmarks] = useState(window.location.pathname === "/bookmarks");
|
|
const [showStats, setShowStats] = useState(window.location.pathname === "/stats");
|
|
const [showHistory, setShowHistory] = useState(window.location.pathname === "/history");
|
|
const statusRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Background image state
|
|
const [bgInfo, setBgInfo] = useState<BackgroundInfo | null>(null);
|
|
const [bgRefreshing, setBgRefreshing] = useState(false);
|
|
|
|
// Bookmarked URLs for toggle state
|
|
const [bookmarkedUrls, setBookmarkedUrls] = useState<Set<string>>(new Set());
|
|
|
|
// App version
|
|
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
|
|
|
|
// Fetch background, bookmarked URLs, and version on mount
|
|
useEffect(() => {
|
|
getBackground().then(setBgInfo).catch(() => {});
|
|
getBookmarkedUrls().then(setBookmarkedUrls).catch(() => {});
|
|
getVersion().then(setVersionInfo).catch(() => {});
|
|
}, []);
|
|
|
|
const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null;
|
|
const isHome = !hasSearched && !showGallery && !showBookmarks && !showStats && !showHistory;
|
|
|
|
// iOS Safari fills top/bottom browser areas from page background color,
|
|
// so sample image edge colors to avoid white/black bars.
|
|
useEffect(() => {
|
|
const root = document.documentElement;
|
|
const body = document.body;
|
|
const themeMeta = document.querySelector('meta[name="theme-color"]');
|
|
|
|
const clearColors = () => {
|
|
root.style.backgroundColor = "";
|
|
body.style.backgroundColor = "";
|
|
if (themeMeta) themeMeta.setAttribute("content", "#000000");
|
|
};
|
|
|
|
if (!isHome || !bgUrl) {
|
|
clearColors();
|
|
return;
|
|
}
|
|
|
|
const setColors = (topColor: string, bottomColor: string) => {
|
|
root.style.backgroundColor = topColor;
|
|
body.style.backgroundColor = bottomColor;
|
|
if (themeMeta) themeMeta.setAttribute("content", topColor);
|
|
};
|
|
|
|
setColors("#111111", "#111111");
|
|
|
|
const img = new Image();
|
|
img.src = bgUrl;
|
|
img.onload = () => {
|
|
try {
|
|
const width = img.naturalWidth;
|
|
const height = img.naturalHeight;
|
|
if (width < 1 || height < 1) {
|
|
setColors("#111111", "#111111");
|
|
return;
|
|
}
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
setColors("#111111", "#111111");
|
|
return;
|
|
}
|
|
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
const sampleRowColor = (y: number) => {
|
|
const pixels = ctx.getImageData(0, y, width, 1).data;
|
|
let r = 0;
|
|
let g = 0;
|
|
let b = 0;
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
r += pixels[i];
|
|
g += pixels[i + 1];
|
|
b += pixels[i + 2];
|
|
}
|
|
const darken = 0.6; // match bg-black/40 overlay
|
|
return `rgb(${Math.round((r / width) * darken)}, ${Math.round((g / width) * darken)}, ${Math.round((b / width) * darken)})`;
|
|
};
|
|
|
|
setColors(sampleRowColor(0), sampleRowColor(height - 1));
|
|
} catch {
|
|
setColors("#111111", "#111111");
|
|
}
|
|
};
|
|
img.onerror = () => setColors("#111111", "#111111");
|
|
|
|
return () => {
|
|
clearColors();
|
|
};
|
|
}, [isHome, bgUrl]);
|
|
|
|
|
|
|
|
const doSearch = useCallback(
|
|
async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true, sort: SortOrder = sortOrder) => {
|
|
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 : "", "", sort);
|
|
try {
|
|
const res = await apiSearch(q, cat, p, cat === "images" ? size : "", sort);
|
|
setResponse(res);
|
|
} catch (err) {
|
|
setResponse({
|
|
query: q,
|
|
category: cat,
|
|
page: p,
|
|
results: [],
|
|
errors: [{ engine: "system", message: String(err), is_timeout: false, code: "client_error", details: "", retry_hint: "Check your connection" }],
|
|
suggestions: [],
|
|
engine_stats: [],
|
|
timestamp: new Date().toISOString(),
|
|
total_results: 0,
|
|
has_next: false,
|
|
cached: false,
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[category, imageSize, sortOrder]
|
|
);
|
|
|
|
// Restore search from URL on initial load
|
|
useEffect(() => {
|
|
if (initial.q) {
|
|
doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false, initial.sort);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Handle browser back/forward
|
|
useEffect(() => {
|
|
const onPopState = () => {
|
|
if (window.location.pathname === "/backgrounds") {
|
|
setShowGallery(true); setShowBookmarks(false); setShowStats(false); setShowHistory(false);
|
|
return;
|
|
}
|
|
if (window.location.pathname === "/bookmarks") {
|
|
setShowBookmarks(true); setShowGallery(false); setShowStats(false); setShowHistory(false);
|
|
return;
|
|
}
|
|
if (window.location.pathname === "/stats") {
|
|
setShowStats(true); setShowGallery(false); setShowBookmarks(false); setShowHistory(false);
|
|
return;
|
|
}
|
|
if (window.location.pathname === "/history") {
|
|
setShowHistory(true); setShowGallery(false); setShowBookmarks(false); setShowStats(false);
|
|
return;
|
|
}
|
|
setShowGallery(false);
|
|
setShowBookmarks(false);
|
|
setShowStats(false);
|
|
setShowHistory(false);
|
|
const { q, cat, page: p, imageSize: size, sort } = parseUrlState();
|
|
if (q) {
|
|
setSortOrder(sort);
|
|
doSearch(q, cat, p, size, false, sort);
|
|
} 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 : "", true, sortOrder);
|
|
};
|
|
|
|
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, true, sortOrder);
|
|
};
|
|
|
|
const handleSortChange = (sort: SortOrder) => {
|
|
setSortOrder(sort);
|
|
if (query) doSearch(query, category, page, imageSize, true, sort);
|
|
};
|
|
|
|
const handleGoHome = () => {
|
|
setHasSearched(false);
|
|
setResponse(null);
|
|
setPage(1);
|
|
setImageSize("");
|
|
setShowGallery(false);
|
|
setShowBookmarks(false);
|
|
setShowStats(false);
|
|
setShowHistory(false);
|
|
window.history.pushState(null, "", "/");
|
|
getBackground().then(setBgInfo).catch(() => {});
|
|
};
|
|
|
|
const handleRefreshBg = async () => {
|
|
setBgRefreshing(true);
|
|
try {
|
|
const info = await refreshBackground();
|
|
setBgInfo(info);
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setBgRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const handleShowGallery = () => {
|
|
setShowGallery(true);
|
|
setShowBookmarks(false);
|
|
setShowStats(false);
|
|
setShowHistory(false);
|
|
window.history.pushState(null, "", "/backgrounds");
|
|
};
|
|
|
|
const handleShowBookmarks = () => {
|
|
setShowBookmarks(true);
|
|
setShowGallery(false);
|
|
setShowStats(false);
|
|
setShowHistory(false);
|
|
window.history.pushState(null, "", "/bookmarks");
|
|
};
|
|
|
|
const handleShowStats = () => {
|
|
setShowStats(true);
|
|
setShowGallery(false);
|
|
setShowBookmarks(false);
|
|
setShowHistory(false);
|
|
window.history.pushState(null, "", "/stats");
|
|
};
|
|
|
|
const handleShowHistory = () => {
|
|
setShowHistory(true);
|
|
setShowGallery(false);
|
|
setShowBookmarks(false);
|
|
setShowStats(false);
|
|
window.history.pushState(null, "", "/history");
|
|
};
|
|
|
|
const handleHistorySearch = (q: string, cat: string) => {
|
|
handleGoHome();
|
|
doSearch(q, cat as "web" | "images", 1, "", true, "default");
|
|
};
|
|
|
|
const handleToggleBookmark = async (result: WebResult | ImageResult) => {
|
|
const isImage = "img_src" in result;
|
|
const url = result.url;
|
|
if (bookmarkedUrls.has(url)) {
|
|
await removeBookmarkByUrl(url);
|
|
setBookmarkedUrls((prev) => { const next = new Set(prev); next.delete(url); return next; });
|
|
} else {
|
|
await addBookmark({
|
|
type: isImage ? "image" : "web",
|
|
title: result.title,
|
|
url: result.url,
|
|
content: "content" in result ? result.content : "",
|
|
engine: result.engine,
|
|
...(isImage && {
|
|
img_src: (result as ImageResult).img_src,
|
|
thumbnail_src: (result as ImageResult).thumbnail_src,
|
|
source: (result as ImageResult).source,
|
|
width: (result as ImageResult).width,
|
|
height: (result as ImageResult).height,
|
|
}),
|
|
});
|
|
setBookmarkedUrls((prev) => new Set(prev).add(url));
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
// SettingsModal is always rendered here so it works on every page
|
|
const settingsModal = (
|
|
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
|
|
);
|
|
|
|
// Gallery page
|
|
if (showGallery) {
|
|
return <>
|
|
<BackgroundGallery onBack={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowBookmarks={handleShowBookmarks} onShowStats={handleShowStats} onShowHistory={handleShowHistory} />
|
|
{settingsModal}
|
|
</>;
|
|
}
|
|
|
|
// Bookmarks page
|
|
if (showBookmarks) {
|
|
return <>
|
|
<Bookmarks onGoHome={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowGallery={handleShowGallery} onShowStats={handleShowStats} onShowHistory={handleShowHistory} />
|
|
{settingsModal}
|
|
</>;
|
|
}
|
|
|
|
// Stats page
|
|
if (showStats) {
|
|
return <>
|
|
<StatsPage
|
|
onGoHome={handleGoHome}
|
|
onShowSettings={() => setShowSettings(true)}
|
|
onShowBookmarks={handleShowBookmarks}
|
|
onShowGallery={handleShowGallery}
|
|
onShowHistory={handleShowHistory}
|
|
/>
|
|
{settingsModal}
|
|
</>;
|
|
}
|
|
|
|
// History page
|
|
if (showHistory) {
|
|
return <>
|
|
<History
|
|
onGoHome={handleGoHome}
|
|
onShowSettings={() => setShowSettings(true)}
|
|
onShowBookmarks={handleShowBookmarks}
|
|
onShowGallery={handleShowGallery}
|
|
onShowStats={handleShowStats}
|
|
onSearch={handleHistorySearch}
|
|
/>
|
|
{settingsModal}
|
|
</>;
|
|
}
|
|
|
|
// Home page (no search yet)
|
|
if (!hasSearched) {
|
|
return (
|
|
<div className="relative min-h-screen" style={{ minHeight: "100svh" }}>
|
|
{/* Background image */}
|
|
{bgUrl && (
|
|
<div
|
|
className="fixed left-0 right-0 bg-cover bg-center transition-opacity duration-700"
|
|
style={{
|
|
backgroundImage: `url(${bgUrl})`,
|
|
top: "calc(env(safe-area-inset-top, 0px) * -1)",
|
|
bottom: "calc(env(safe-area-inset-bottom, 0px) * -1)",
|
|
}}
|
|
>
|
|
<div className="absolute inset-0 bg-black/40 dark:bg-black/60" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative z-10 flex min-h-screen flex-col" style={{ minHeight: "100svh" }}>
|
|
{/* Shared header */}
|
|
<AppHeader
|
|
onGoHome={handleGoHome}
|
|
onShowSettings={() => setShowSettings(true)}
|
|
onShowBookmarks={handleShowBookmarks}
|
|
onShowGallery={handleShowGallery}
|
|
onShowStats={handleShowStats}
|
|
onShowHistory={handleShowHistory}
|
|
transparent={!!bgUrl}
|
|
hideLogo
|
|
/>
|
|
|
|
{/* Center content */}
|
|
<div className="flex flex-1 flex-col items-center justify-center px-4 pb-20">
|
|
<div className="mb-8 text-center">
|
|
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
|
<span className={cn(
|
|
"bg-clip-text text-transparent",
|
|
bgUrl
|
|
? "bg-gradient-to-r from-white to-white/90"
|
|
: "bg-gradient-to-r from-blue-600 to-purple-600"
|
|
)}>
|
|
HeySearch
|
|
</span>
|
|
</h1>
|
|
<p className={cn("mt-2", bgUrl ? "text-white/70" : "text-muted-foreground")}>
|
|
Private metasearch engine
|
|
</p>
|
|
</div>
|
|
|
|
<SearchBar onSearch={(q) => doSearch(q, category)} className="w-full" />
|
|
|
|
{/* Category switch */}
|
|
<div className="mt-4 flex items-center gap-2" role="group" aria-label="Search category">
|
|
{([
|
|
{ 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={() => setCategory(key)}
|
|
aria-pressed={category === key}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
|
category === key
|
|
? bgUrl
|
|
? "bg-white/20 text-white border border-white/30"
|
|
: "bg-primary text-primary-foreground"
|
|
: bgUrl
|
|
? "text-white/60 hover:text-white hover:bg-white/10 border border-transparent"
|
|
: "text-muted-foreground hover:bg-accent border border-transparent"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom-right: new image button */}
|
|
{bgUrl && (
|
|
<button
|
|
onClick={handleRefreshBg}
|
|
disabled={bgRefreshing}
|
|
aria-label="New background image"
|
|
title="New background image"
|
|
className="absolute bottom-6 right-6 flex items-center gap-1.5 rounded-full border border-white/30 bg-black/20 px-3 py-2 text-sm text-white/80 hover:text-white hover:bg-black/30 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors backdrop-blur-sm"
|
|
>
|
|
<RefreshCw className={cn("h-4 w-4", bgRefreshing && "animate-spin")} aria-hidden="true" />
|
|
<span className="hidden sm:inline">{bgRefreshing ? "Loading…" : "New image"}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Bottom-left: image source */}
|
|
{bgInfo?.source_url && bgUrl && (
|
|
<a
|
|
href={bgInfo.source_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="absolute bottom-6 left-6 flex items-center gap-1.5 text-xs text-white/50 hover:text-white/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
|
Image source
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{settingsModal}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Results page
|
|
const statusMessage = loading
|
|
? "Searching…"
|
|
: response
|
|
? `${response.results.length} result${response.results.length !== 1 ? "s" : ""} for "${query}" — page ${page}`
|
|
: "";
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col">
|
|
{/* Skip to content */}
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:bg-primary focus:text-primary-foreground focus:px-4 focus:py-2 focus:rounded"
|
|
>
|
|
Skip to main content
|
|
</a>
|
|
|
|
{/* Live status region */}
|
|
<div ref={statusRef} aria-live="polite" aria-atomic="true" className="sr-only">
|
|
{statusMessage}
|
|
</div>
|
|
|
|
{/* Shared header with search bar in center slot */}
|
|
<AppHeader
|
|
onGoHome={handleGoHome}
|
|
onShowSettings={() => setShowSettings(true)}
|
|
onShowBookmarks={handleShowBookmarks}
|
|
onShowGallery={handleShowGallery}
|
|
onShowStats={handleShowStats}
|
|
onShowHistory={handleShowHistory}
|
|
>
|
|
<SearchBar initialQuery={query} onSearch={(q) => doSearch(q)} />
|
|
</AppHeader>
|
|
|
|
{/* Category / filter bar */}
|
|
<div className="sticky top-[57px] z-30 border-b bg-background/95 backdrop-blur">
|
|
<nav aria-label="Search categories" className="flex items-center gap-1 px-4 py-2 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
|
{([
|
|
{ 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)}
|
|
aria-current={category === key ? "page" : undefined}
|
|
className={cn(
|
|
"shrink-0 flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
|
category === key
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
{label}
|
|
</button>
|
|
))}
|
|
|
|
{/* Image size filter — only visible in images category */}
|
|
{category === "images" && (
|
|
<>
|
|
<div className="mx-2 h-5 w-px shrink-0 bg-border" aria-hidden="true" />
|
|
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
{IMAGE_SIZE_OPTIONS.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => handleImageSizeChange(value)}
|
|
aria-pressed={imageSize === value}
|
|
className={cn(
|
|
"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
|
imageSize === value
|
|
? "bg-secondary text-secondary-foreground"
|
|
: "text-muted-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* Sort order — available for both web and images */}
|
|
<>
|
|
<div className="mx-2 h-5 w-px shrink-0 bg-border" aria-hidden="true" />
|
|
<span className="shrink-0 text-xs text-muted-foreground" aria-hidden="true">Sort:</span>
|
|
{SORT_OPTIONS.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => handleSortChange(value)}
|
|
aria-pressed={sortOrder === value}
|
|
className={cn(
|
|
"shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
|
sortOrder === value
|
|
? "bg-secondary text-secondary-foreground"
|
|
: "text-muted-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<main id="main-content" className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20" role="status">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" aria-hidden="true" />
|
|
<span className="sr-only">Loading search results…</span>
|
|
</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" aria-hidden="true" />
|
|
<h2 className="mt-4 text-lg text-muted-foreground">No results found for “{query}”</h2>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<h2 className="sr-only">Search results for “{query}”</h2>
|
|
|
|
{/* 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}
|
|
cached={response.cached}
|
|
initialCollapsed
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-6">
|
|
{/* Results column */}
|
|
<div className="min-w-0 flex-1">
|
|
{category === "web" && <WebResults results={webResults} query={query} category={category} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
|
|
{category === "images" && <ImageResults results={imageResults} query={query} category={category} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
|
|
|
|
{/* Pagination */}
|
|
{hasResults && (
|
|
<nav aria-label="Pagination" className="mt-8 flex items-center justify-center gap-3">
|
|
<button
|
|
onClick={() => handlePageChange(page - 1)}
|
|
disabled={page <= 1}
|
|
aria-label="Previous page"
|
|
className="flex items-center gap-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-40"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
|
Previous
|
|
</button>
|
|
<span className="text-sm tabular-nums text-muted-foreground" aria-current="page">
|
|
Page {page}
|
|
</span>
|
|
<button
|
|
onClick={() => handlePageChange(page + 1)}
|
|
aria-label="Next page"
|
|
className="flex items-center gap-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
</nav>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stats sidebar — hidden on mobile, shown on lg+ */}
|
|
{response && (
|
|
<aside className="hidden w-64 shrink-0 lg:block" aria-label="Search statistics">
|
|
<div className="sticky top-28">
|
|
<SearchStats
|
|
stats={response.engine_stats}
|
|
errors={response.errors}
|
|
page={page}
|
|
totalResults={response.results.length}
|
|
cached={response.cached}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
{/* Footer */}
|
|
<footer className="border-t px-4 py-3 text-xs text-muted-foreground">
|
|
<div className="mx-auto max-w-6xl text-center">
|
|
HeySearch
|
|
{versionInfo && (
|
|
<span className="ml-1.5 opacity-60">
|
|
{versionInfo.local === "true"
|
|
? `local · ${versionInfo.commit}`
|
|
: versionInfo.version}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</footer>
|
|
|
|
{/* Error toasts */}
|
|
{response?.errors && <ErrorToast errors={response.errors} />}
|
|
|
|
{/* Settings modal */}
|
|
{settingsModal}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|