mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-08 21:05:14 +10:00
add masonry image layout
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Pinterest-style masonry image layout** — images display at their natural aspect ratios using CSS columns, creating a dynamic waterfall grid instead of fixed-size squares
|
||||
- **Image size filter** — filter image results by size (All, Large, Medium, Small); passed to search engines server-side (Google `tbs=isz`, Bing `qft=filterui:imagesize`, DuckDuckGo `size` param)
|
||||
- Image size filter state is synced in the URL (`&image_size=large`)
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
## Search
|
||||
|
||||
- **Web search** — aggregates results from Brave, DuckDuckGo, Google, and Bing concurrently
|
||||
- **Image search** — image results with a responsive grid layout and lightbox viewer
|
||||
- **Image search** — Pinterest-style masonry layout respecting natural image aspect ratios, with lightbox viewer
|
||||
- **Image size filter** — filter images by size (Large, Medium, Small) — applied server-side across all engines
|
||||
- **Autocomplete** — live search suggestions as you type (cascades Google → DuckDuckGo → Brave)
|
||||
- **Pagination** — navigate through result pages; URL reflects current state (`?q=...&page=2`)
|
||||
- **URL deduplication** — duplicate results from multiple engines are merged automatically
|
||||
|
||||
@@ -28,9 +28,10 @@ async def api_search(
|
||||
category: Literal["web", "images"] = Query("web", description="Search category"),
|
||||
page: int = Query(1, ge=1, le=50, description="Page number"),
|
||||
engines: str | None = Query(None, description="Comma-separated engine names to use"),
|
||||
image_size: Literal["", "large", "medium", "small"] = Query("", description="Filter images by size (images category only)"),
|
||||
):
|
||||
engine_list = [e.strip() for e in engines.split(",")] if engines else None
|
||||
return await search(q, category=category, page=page, engines=engine_list)
|
||||
return await search(q, category=category, page=page, engines=engine_list, image_size=image_size)
|
||||
|
||||
|
||||
# --- Autocomplete ---
|
||||
|
||||
@@ -50,8 +50,11 @@ class SearchEngine(abc.ABC):
|
||||
"""Perform a web search and return results."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
|
||||
"""Perform an image search and return results."""
|
||||
async def search_images(self, query: str, page: int = 1, image_size: str = "") -> list[ImageResult]:
|
||||
"""Perform an image search and return results.
|
||||
|
||||
image_size: "" (all), "large", "medium", "small"
|
||||
"""
|
||||
|
||||
async def autocomplete(self, query: str) -> list[str]:
|
||||
"""Return autocomplete suggestions. Override if supported."""
|
||||
|
||||
@@ -86,9 +86,9 @@ class BingEngine(SearchEngine):
|
||||
logger.info("Bing direct blocked — falling back to Yahoo for '%s'", query)
|
||||
return await self._yahoo_web(query, page)
|
||||
|
||||
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
|
||||
async def search_images(self, query: str, page: int = 1, image_size: str = "") -> list[ImageResult]:
|
||||
# Try direct Bing first
|
||||
results = await self._bing_direct_images(query, page)
|
||||
results = await self._bing_direct_images(query, page, image_size)
|
||||
if results is not None:
|
||||
return results
|
||||
|
||||
@@ -163,10 +163,14 @@ class BingEngine(SearchEngine):
|
||||
|
||||
return results
|
||||
|
||||
async def _bing_direct_images(self, query: str, page: int) -> list[ImageResult] | None:
|
||||
async def _bing_direct_images(self, query: str, page: int, image_size: str = "") -> list[ImageResult] | None:
|
||||
"""Search Bing Images directly. Returns None if blocked."""
|
||||
first = (page - 1) * 35 + 1
|
||||
params = {"q": query, "first": first, "FORM": "HDRSC2"}
|
||||
params: dict[str, str | int] = {"q": query, "first": first, "FORM": "HDRSC2"}
|
||||
# Bing size filter
|
||||
size_map = {"large": "+filterui:imagesize-large", "medium": "+filterui:imagesize-medium", "small": "+filterui:imagesize-small"}
|
||||
if image_size in size_map:
|
||||
params["qft"] = size_map[image_size]
|
||||
|
||||
client = get_http_client()
|
||||
try:
|
||||
|
||||
@@ -52,8 +52,12 @@ class BraveEngine(SearchEngine):
|
||||
|
||||
return results
|
||||
|
||||
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
|
||||
args = {"q": query, "source": "web"}
|
||||
async def search_images(self, query: str, page: int = 1, image_size: str = "") -> list[ImageResult]:
|
||||
args: dict[str, str | int] = {"q": query, "source": "web"}
|
||||
# Brave size filter
|
||||
size_map = {"large": "Large", "medium": "Medium", "small": "Small"}
|
||||
if image_size in size_map:
|
||||
args["size"] = size_map[image_size]
|
||||
client = get_http_client()
|
||||
resp = await client.get(
|
||||
f"{BASE_URL}/images?{urlencode(args)}",
|
||||
|
||||
@@ -54,7 +54,7 @@ class DuckDuckGoEngine(SearchEngine):
|
||||
|
||||
return results
|
||||
|
||||
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
|
||||
async def search_images(self, query: str, page: int = 1, image_size: str = "") -> list[ImageResult]:
|
||||
"""Search images via DDG's i.js API (requires vqd token)."""
|
||||
client = get_http_client()
|
||||
# First get a vqd token from the HTML page
|
||||
@@ -84,8 +84,14 @@ class DuckDuckGoEngine(SearchEngine):
|
||||
if not vqd:
|
||||
return results
|
||||
|
||||
img_params: dict[str, str] = {"q": query, "vqd": vqd, "o": "json"}
|
||||
# DuckDuckGo size filter
|
||||
size_map = {"large": "Large", "medium": "Medium", "small": "Small"}
|
||||
if image_size in size_map:
|
||||
img_params["size"] = size_map[image_size]
|
||||
|
||||
img_resp = await client.get(
|
||||
f"https://duckduckgo.com/i.js?{urlencode({'q': query, 'vqd': vqd, 'o': 'json'})}",
|
||||
f"https://duckduckgo.com/i.js?{urlencode(img_params)}",
|
||||
headers={"Referer": "https://duckduckgo.com/"},
|
||||
)
|
||||
if img_resp.is_success:
|
||||
|
||||
@@ -140,9 +140,9 @@ class GoogleEngine(SearchEngine):
|
||||
|
||||
return results
|
||||
|
||||
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
|
||||
async def search_images(self, query: str, page: int = 1, image_size: str = "") -> list[ImageResult]:
|
||||
start = (page - 1) * 50
|
||||
params = {
|
||||
params: dict[str, str | int] = {
|
||||
"q": query,
|
||||
"tbm": "isch",
|
||||
"hl": "en",
|
||||
@@ -151,6 +151,10 @@ class GoogleEngine(SearchEngine):
|
||||
"asearch": "arc",
|
||||
"async": _build_async_param(start),
|
||||
}
|
||||
# Google size filter: tbs=isz:l (large), isz:m (medium), isz:s (small)
|
||||
size_map = {"large": "isz:l", "medium": "isz:m", "small": "isz:s"}
|
||||
if image_size in size_map:
|
||||
params["tbs"] = size_map[image_size]
|
||||
|
||||
client = get_http_client()
|
||||
resp = await client.get(
|
||||
|
||||
@@ -29,13 +29,14 @@ async def _search_single_engine(
|
||||
query: str,
|
||||
category: SearchCategory,
|
||||
page: int,
|
||||
image_size: str = "",
|
||||
) -> tuple[list[WebResult] | list[ImageResult], EngineError | None]:
|
||||
"""Search a single engine with retry logic."""
|
||||
|
||||
@RETRY_DECORATOR
|
||||
async def _do_search():
|
||||
if category == "images":
|
||||
return await engine.search_images(query, page)
|
||||
return await engine.search_images(query, page, image_size=image_size)
|
||||
return await engine.search_web(query, page)
|
||||
|
||||
try:
|
||||
@@ -57,6 +58,7 @@ async def search(
|
||||
category: SearchCategory = "web",
|
||||
page: int = 1,
|
||||
engines: list[str] | None = None,
|
||||
image_size: str = "",
|
||||
) -> SearchResponse:
|
||||
"""Search across all enabled engines concurrently."""
|
||||
enabled_engines = registry.get_enabled_engines()
|
||||
@@ -75,7 +77,7 @@ async def search(
|
||||
|
||||
# Run all engine searches concurrently
|
||||
tasks = [
|
||||
_search_single_engine(engine, query, category, page)
|
||||
_search_single_engine(engine, query, category, page, image_size=image_size)
|
||||
for engine in enabled_engines
|
||||
]
|
||||
results_list = await asyncio.gather(*tasks)
|
||||
|
||||
+56
-14
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight } from "lucide-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";
|
||||
@@ -10,20 +10,31 @@ import { search as apiSearch, isImageResult, type SearchResponse, type WebResult
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Category = "web" | "images";
|
||||
type ImageSize = "" | "large" | "medium" | "small";
|
||||
|
||||
function parseUrlState(): { q: string; cat: Category; page: number } {
|
||||
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);
|
||||
return { q, cat, page };
|
||||
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) {
|
||||
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);
|
||||
@@ -35,22 +46,24 @@ function App() {
|
||||
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, updateUrl = true) => {
|
||||
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);
|
||||
if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "");
|
||||
try {
|
||||
const res = await apiSearch(q, cat, p);
|
||||
const res = await apiSearch(q, cat, p, cat === "images" ? size : "");
|
||||
setResponse(res);
|
||||
} catch (err) {
|
||||
setResponse({
|
||||
@@ -66,13 +79,13 @@ function App() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[category]
|
||||
[category, imageSize]
|
||||
);
|
||||
|
||||
// Restore search from URL on initial load
|
||||
useEffect(() => {
|
||||
if (initial.q) {
|
||||
doSearch(initial.q, initial.cat, initial.page, false);
|
||||
doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -80,15 +93,16 @@ function App() {
|
||||
// Handle browser back/forward
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
const { q, cat, page: p } = parseUrlState();
|
||||
const { q, cat, page: p, imageSize: size } = parseUrlState();
|
||||
if (q) {
|
||||
doSearch(q, cat, p, false);
|
||||
doSearch(q, cat, p, size, false);
|
||||
} else {
|
||||
setHasSearched(false);
|
||||
setResponse(null);
|
||||
setQuery("");
|
||||
setPage(1);
|
||||
setCategory("web");
|
||||
setImageSize("");
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
@@ -97,19 +111,25 @@ function App() {
|
||||
|
||||
const handleCategoryChange = (cat: Category) => {
|
||||
setCategory(cat);
|
||||
if (query) doSearch(query, cat, 1);
|
||||
if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "");
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1) return;
|
||||
doSearch(query, category, newPage);
|
||||
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, "", "/");
|
||||
};
|
||||
|
||||
@@ -183,7 +203,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className="flex gap-1 px-4 pb-2">
|
||||
<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 },
|
||||
@@ -202,6 +222,28 @@ function App() {
|
||||
{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>
|
||||
|
||||
|
||||
@@ -13,25 +13,30 @@ export function ImageResults({ results }: ImageResultsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{/* Masonry grid using CSS columns */}
|
||||
<div className="columns-2 gap-3 sm:columns-3 md:columns-4 lg:columns-5">
|
||||
{results.map((img, i) => (
|
||||
<button
|
||||
key={`${img.img_src}-${i}`}
|
||||
onClick={() => setSelected(img)}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg border bg-muted hover:ring-2 hover:ring-ring"
|
||||
className="group relative mb-3 inline-block w-full overflow-hidden rounded-lg border bg-muted break-inside-avoid hover:ring-2 hover:ring-ring transition-shadow"
|
||||
>
|
||||
<img
|
||||
src={img.thumbnail_src || img.img_src}
|
||||
alt={img.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full object-cover transition-transform group-hover:scale-[1.03]"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23eee' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='%23999' font-size='12'%3ENo image%3C/text%3E%3C/svg%3E";
|
||||
const el = e.target as HTMLImageElement;
|
||||
el.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='150'%3E%3Crect fill='%23eee' width='200' height='150'/%3E%3Ctext x='100' y='80' text-anchor='middle' fill='%23999' font-size='12'%3ENo image%3C/text%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
{/* Hover overlay with title */}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<p className="truncate text-xs text-white">{img.title}</p>
|
||||
<p className="truncate text-[10px] text-white/60">{img.source}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -56,6 +61,7 @@ export function ImageResults({ results }: ImageResultsProps) {
|
||||
<img
|
||||
src={selected.img_src}
|
||||
alt={selected.title}
|
||||
referrerPolicy="no-referrer"
|
||||
className="max-h-[70vh] w-auto rounded object-contain"
|
||||
/>
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -56,9 +56,11 @@ export interface AutocompleteResponse {
|
||||
export async function search(
|
||||
query: string,
|
||||
category: "web" | "images" = "web",
|
||||
page: number = 1
|
||||
page: number = 1,
|
||||
imageSize: string = ""
|
||||
): Promise<SearchResponse> {
|
||||
const params = new URLSearchParams({ q: query, category, page: String(page) });
|
||||
if (imageSize) params.set("image_size", imageSize);
|
||||
const resp = await fetch(`${API_BASE}/search?${params}`);
|
||||
if (!resp.ok) throw new Error(`Search failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
|
||||
Reference in New Issue
Block a user