From 4e5b0424b09a5abe49a5f69579d9d093a8650e1d Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 9 Mar 2026 17:35:36 +1100 Subject: [PATCH] Add published date option and filter --- backend/app/api/routes.py | 3 +- backend/app/search.py | 62 ++++++-- frontend/src/App.tsx | 134 ++++++++++++++--- frontend/src/components/FilterSheet.tsx | 191 ++++++++++++++++++++++++ frontend/src/lib/api.ts | 4 +- 5 files changed, 361 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/FilterSheet.tsx diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 9eeb3be..1f5bcb2 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -82,10 +82,11 @@ async def api_search( engines: str | None = Query(None, description="Comma-separated engine names to use (e.g. 'google,bing')"), image_size: Literal["", "large", "medium", "small"] = Query("", description="Filter images by size (images category only)"), sort: Literal["default", "date_asc", "date_desc"] = Query("default", description="Sort results by publish date"), + date_filter: Literal["", "day", "week", "month", "year"] = Query("", description="Filter results by publish date recency (day=24h, week=7d, month=30d, year=365d)"), ): effective_page = pageNumber if pageNumber is not None else page engine_list = [e.strip() for e in engines.split(",")] if engines else None - result = await search(q, category=category, page=effective_page, engines=engine_list, image_size=image_size, sort=sort) + result = await search(q, category=category, page=effective_page, engines=engine_list, image_size=image_size, sort=sort, date_filter=date_filter) origin_ip = request.client.host if request.client else "" user_agent = request.headers.get("user-agent", "") _stats.record_search( diff --git a/backend/app/search.py b/backend/app/search.py index 15d5fc5..5c5d4af 100644 --- a/backend/app/search.py +++ b/backend/app/search.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +from datetime import datetime, timedelta, timezone from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type import httpx @@ -17,6 +18,43 @@ from app import cache logger = logging.getLogger(__name__) SortOrder = str # "default" | "date_asc" | "date_desc" +DateFilter = str # "" | "day" | "week" | "month" | "year" + +_DATE_FILTER_DELTAS: dict[str, timedelta] = { + "day": timedelta(days=1), + "week": timedelta(weeks=1), + "month": timedelta(days=30), + "year": timedelta(days=365), +} + + +def _parse_published_date(date_str: str) -> datetime | None: + """Parse a published_date string into an aware datetime, or return None.""" + if not date_str: + return None + try: + dt = datetime.fromisoformat(date_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, TypeError): + return None + + +def _apply_date_filter(results: list, date_filter: DateFilter) -> None: + """Remove results whose published_date falls outside the requested window. + + Results with no published_date are excluded when a filter is active, since + their recency cannot be determined. + """ + delta = _DATE_FILTER_DELTAS.get(date_filter) + if delta is None: + return + cutoff = datetime.now(tz=timezone.utc) - delta + results[:] = [ + r for r in results + if (dt := _parse_published_date(r.published_date)) is not None and dt >= cutoff + ] def _apply_sort(results: list, sort: SortOrder) -> None: @@ -73,17 +111,21 @@ async def search( engines: list[str] | None = None, image_size: str = "", sort: SortOrder = "default", + date_filter: DateFilter = "", ) -> SearchResponse: """Search across all enabled engines concurrently, with optional Redis caching.""" engines_key = ",".join(sorted(engines)) if engines else "" - # Check cache first (cache stores default-order results; sort applied after) - cached_data = await cache.get_cached(query, category, page, image_size, engines_key) - if cached_data is not None: - resp = SearchResponse(**cached_data) - resp.cached = True - _apply_sort(resp.results, sort) - return resp + # Skip cache when a date filter is active — cached results may lack published_date + # on many entries, causing the filter to produce sparse or empty result sets. + if not date_filter: + cached_data = await cache.get_cached(query, category, page, image_size, engines_key) + if cached_data is not None: + resp = SearchResponse(**cached_data) + resp.cached = True + _apply_sort(resp.results, sort) + resp.total_results = len(resp.results) + return resp enabled_engines = registry.get_enabled_engines() @@ -153,12 +195,14 @@ async def search( has_next=any(s.result_count > 0 for s in all_stats if s.status == "ok"), ) - # Store in cache before sorting (cache always holds default-order results) + # Store in cache before filtering/sorting (cache always holds full, default-order results) if unique_results: await cache.set_cached(query, category, page, image_size, engines_key, response.model_dump()) - # Apply requested sort order + # Apply date filter, then sort + _apply_date_filter(response.results, date_filter) _apply_sort(response.results, sort) + response.total_results = len(response.results) return response diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba81c5d..30e489d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { WebResults } from "@/components/WebResults"; import { ImageResults } from "@/components/ImageResults"; import { SearchStats } from "@/components/SearchStats"; import { SettingsModal } from "@/components/SettingsModal"; +import { FilterSheet } from "@/components/FilterSheet"; import { ErrorToast } from "@/components/ErrorToast"; import { BackgroundGallery } from "@/components/BackgroundGallery"; import { Bookmarks } from "@/components/Bookmarks"; @@ -17,6 +18,7 @@ import { cn } from "@/lib/utils"; type Category = "web" | "images"; type ImageSize = "" | "large" | "medium" | "small"; type SortOrder = "default" | "date_desc" | "date_asc"; +type DateFilter = "" | "day" | "week" | "month" | "year"; const IMAGE_SIZE_OPTIONS: { value: ImageSize; label: string }[] = [ { value: "", label: "All sizes" }, @@ -31,7 +33,15 @@ const SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_asc", label: "Oldest" }, ]; -function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize; engines: string; sort: SortOrder } { +const DATE_FILTER_OPTIONS: { value: DateFilter; label: string }[] = [ + { value: "", label: "Any time" }, + { value: "day", label: "Past day" }, + { value: "week", label: "Past week" }, + { value: "month", label: "Past month" }, + { value: "year", label: "Past year" }, +]; + +function parseUrlState(): { q: string; cat: Category; page: number; imageSize: ImageSize; engines: string; sort: SortOrder; dateFilter: DateFilter } { const params = new URLSearchParams(window.location.search); const q = params.get("q") ?? ""; const cat = params.get("category") === "images" ? "images" : "web"; @@ -41,10 +51,12 @@ function parseUrlState(): { q: string; cat: Category; page: number; imageSize: I 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 }; + const rawDateFilter = params.get("date_filter") ?? ""; + const dateFilter: DateFilter = (["day", "week", "month", "year"].includes(rawDateFilter) ? rawDateFilter : "") as DateFilter; + return { q, cat, page, imageSize, engines, sort, dateFilter }; } -function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "", engines: string = "", sort: SortOrder = "default") { +function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = "", engines: string = "", sort: SortOrder = "default", dateFilter: DateFilter = "") { const params = new URLSearchParams(); params.set("q", q); if (cat !== "web") params.set("category", cat); @@ -52,6 +64,7 @@ function pushUrl(q: string, cat: Category, page: number, imageSize: ImageSize = if (imageSize) params.set("image_size", imageSize); if (engines) params.set("engines", engines); if (sort !== "default") params.set("sort", sort); + if (dateFilter) params.set("date_filter", dateFilter); const url = `/?${params.toString()}`; if (window.location.pathname + window.location.search !== url) { window.history.pushState(null, "", url); @@ -65,9 +78,11 @@ function App() { const [page, setPage] = useState(initial.page); const [imageSize, setImageSize] = useState(initial.imageSize); const [sortOrder, setSortOrder] = useState(initial.sort); + const [dateFilter, setDateFilter] = useState(initial.dateFilter); const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); const [showSettings, setShowSettings] = useState(false); + const [showFilterSheet, setShowFilterSheet] = useState(false); const [hasSearched, setHasSearched] = useState(!!initial.q); const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds"); const [showBookmarks, setShowBookmarks] = useState(window.location.pathname === "/bookmarks"); @@ -172,7 +187,7 @@ function App() { const doSearch = useCallback( - async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true, sort: SortOrder = sortOrder) => { + async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true, sort: SortOrder = sortOrder, df: DateFilter = dateFilter) => { if (!q.trim()) return; setQuery(q); setCategory(cat); @@ -180,9 +195,9 @@ function App() { setImageSize(size); setLoading(true); setHasSearched(true); - if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "", "", sort); + if (updateUrl) pushUrl(q, cat, p, cat === "images" ? size : "", "", sort, df); try { - const res = await apiSearch(q, cat, p, cat === "images" ? size : "", sort); + const res = await apiSearch(q, cat, p, cat === "images" ? size : "", sort, df); setResponse(res); } catch (err) { setResponse({ @@ -202,13 +217,13 @@ function App() { setLoading(false); } }, - [category, imageSize, sortOrder] + [category, imageSize, sortOrder, dateFilter] ); // Restore search from URL on initial load useEffect(() => { if (initial.q) { - doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false, initial.sort); + doSearch(initial.q, initial.cat, initial.page, initial.imageSize, false, initial.sort, initial.dateFilter); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -236,10 +251,11 @@ function App() { setShowBookmarks(false); setShowStats(false); setShowHistory(false); - const { q, cat, page: p, imageSize: size, sort } = parseUrlState(); + const { q, cat, page: p, imageSize: size, sort, dateFilter: df } = parseUrlState(); if (q) { setSortOrder(sort); - doSearch(q, cat, p, size, false, sort); + setDateFilter(df); + doSearch(q, cat, p, size, false, sort, df); } else { setHasSearched(false); setResponse(null); @@ -255,23 +271,28 @@ function App() { const handleCategoryChange = (cat: Category) => { setCategory(cat); - if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "", true, sortOrder); + if (query) doSearch(query, cat, 1, cat === "images" ? imageSize : "", true, sortOrder, dateFilter); }; const handlePageChange = (newPage: number) => { if (newPage < 1) return; - doSearch(query, category, newPage, imageSize); + doSearch(query, category, newPage, imageSize, true, sortOrder, dateFilter); window.scrollTo({ top: 0, behavior: "smooth" }); }; const handleImageSizeChange = (size: ImageSize) => { setImageSize(size); - if (query) doSearch(query, category, 1, size, true, sortOrder); + if (query) doSearch(query, category, 1, size, true, sortOrder, dateFilter); }; const handleSortChange = (sort: SortOrder) => { setSortOrder(sort); - if (query) doSearch(query, category, page, imageSize, true, sort); + if (query) doSearch(query, category, page, imageSize, true, sort, dateFilter); + }; + + const handleDateFilterChange = (df: DateFilter) => { + setDateFilter(df); + if (query) doSearch(query, category, 1, imageSize, true, sortOrder, df); }; const handleGoHome = () => { @@ -279,6 +300,7 @@ function App() { setResponse(null); setPage(1); setImageSize(""); + setDateFilter(""); setShowGallery(false); setShowBookmarks(false); setShowStats(false); @@ -370,6 +392,21 @@ function App() { setShowSettings(false)} /> ); + const filterSheet = ( + setShowFilterSheet(false)} + category={category} + imageSize={imageSize} + sortOrder={sortOrder} + dateFilter={dateFilter} + onCategoryChange={(v) => { handleCategoryChange(v); setShowFilterSheet(false); }} + onImageSizeChange={(v) => { handleImageSizeChange(v); setShowFilterSheet(false); }} + onSortChange={(v) => { handleSortChange(v); setShowFilterSheet(false); }} + onDateFilterChange={(v) => { handleDateFilterChange(v); setShowFilterSheet(false); }} + /> + ); + // Gallery page if (showGallery) { return <> @@ -584,18 +621,47 @@ function App() { ))} - {/* Image size filter — only visible in images category */} + {/* Mobile: Filters button with active-count badge — hidden on sm+ */} + {(() => { + const activeCount = [ + category === "images" && imageSize !== "", + sortOrder !== "default", + dateFilter !== "", + ].filter(Boolean).length; + return ( + + ); + })()} + + {/* Image size filter — only visible in images category, desktop only */} {category === "images" && ( <> -