add masonry image layout

This commit is contained in:
2026-02-23 16:09:52 +11:00
parent 7c0430f2dc
commit 16968b8565
12 changed files with 119 additions and 36 deletions
+8
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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 ---
+5 -2
View File
@@ -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."""
+8 -4
View File
@@ -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:
+6 -2
View File
@@ -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)}",
+8 -2
View File
@@ -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:
+6 -2
View File
@@ -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(
+4 -2
View File
@@ -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
View File
@@ -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>
+11 -5
View File
@@ -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">
+3 -1
View File
@@ -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();