diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e28f1d..c64ee7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/FEATURES.md b/FEATURES.md index 012d05e..2cc549b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 9c17322..cb466b0 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -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 --- diff --git a/backend/app/engines/base.py b/backend/app/engines/base.py index 9d91472..c88387f 100644 --- a/backend/app/engines/base.py +++ b/backend/app/engines/base.py @@ -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.""" diff --git a/backend/app/engines/bing.py b/backend/app/engines/bing.py index 739b749..3bab05e 100644 --- a/backend/app/engines/bing.py +++ b/backend/app/engines/bing.py @@ -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: diff --git a/backend/app/engines/brave.py b/backend/app/engines/brave.py index c9c68f2..16b3ad5 100644 --- a/backend/app/engines/brave.py +++ b/backend/app/engines/brave.py @@ -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)}", diff --git a/backend/app/engines/duckduckgo.py b/backend/app/engines/duckduckgo.py index 65a9e8a..d469eb5 100644 --- a/backend/app/engines/duckduckgo.py +++ b/backend/app/engines/duckduckgo.py @@ -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: diff --git a/backend/app/engines/google.py b/backend/app/engines/google.py index 9a9e8ac..0fe2ea0 100644 --- a/backend/app/engines/google.py +++ b/backend/app/engines/google.py @@ -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( diff --git a/backend/app/search.py b/backend/app/search.py index 98ace97..cbccdd8 100644 --- a/backend/app/search.py +++ b/backend/app/search.py @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c59ecc..43ef6b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(initial.cat); const [page, setPage] = useState(initial.page); + const [imageSize, setImageSize] = useState(initial.imageSize); const [response, setResponse] = useState(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() { {/* Category tabs */} -
+
{([ { key: "web" as const, label: "Web", icon: Globe }, { key: "images" as const, label: "Images", icon: ImageIcon }, @@ -202,6 +222,28 @@ function App() { {label} ))} + + {/* Image size filter — only visible in images category */} + {category === "images" && ( + <> +
+ + {IMAGE_SIZE_OPTIONS.map(({ value, label }) => ( + + ))} + + )}
diff --git a/frontend/src/components/ImageResults.tsx b/frontend/src/components/ImageResults.tsx index 73dbd69..1766df4 100644 --- a/frontend/src/components/ImageResults.tsx +++ b/frontend/src/components/ImageResults.tsx @@ -13,25 +13,30 @@ export function ImageResults({ results }: ImageResultsProps) { return ( <> -
+ {/* Masonry grid using CSS columns */} +
{results.map((img, i) => ( ))} @@ -56,6 +61,7 @@ export function ImageResults({ results }: ImageResultsProps) { {selected.title}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f56252d..20294d0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 { 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();