Add published date option and filter

This commit is contained in:
2026-03-09 17:35:36 +11:00
parent e486ca1dc7
commit 4e5b0424b0
5 changed files with 361 additions and 33 deletions
+2 -1
View File
@@ -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(
+53 -9
View File
@@ -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
+112 -22
View File
@@ -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<ImageSize>(initial.imageSize);
const [sortOrder, setSortOrder] = useState<SortOrder>(initial.sort);
const [dateFilter, setDateFilter] = useState<DateFilter>(initial.dateFilter);
const [response, setResponse] = useState<SearchResponse | null>(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() {
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
);
const filterSheet = (
<FilterSheet
open={showFilterSheet}
onClose={() => 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() {
</button>
))}
{/* 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 (
<button
onClick={() => setShowFilterSheet(true)}
aria-label={`Filters${activeCount > 0 ? `, ${activeCount} active` : ""}`}
className={cn(
"sm:hidden shrink-0 ml-1 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
activeCount > 0
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-accent"
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
Filters
{activeCount > 0 && (
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{activeCount}
</span>
)}
</button>
);
})()}
{/* Image size filter — only visible in images category, desktop only */}
{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" />
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0 text-muted-foreground hidden sm:block" 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",
"hidden sm:block 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"
@@ -607,17 +673,17 @@ function App() {
</>
)}
{/* Sort order — available for both web and images */}
{/* Sort order — desktop only */}
<>
<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>
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
<span className="shrink-0 text-xs text-muted-foreground hidden sm:block" 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",
"hidden sm:block 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"
@@ -627,6 +693,27 @@ function App() {
</button>
))}
</>
{/* Date filter — desktop only */}
<>
<div className="mx-2 h-5 w-px shrink-0 bg-border hidden sm:block" aria-hidden="true" />
<span className="shrink-0 text-xs text-muted-foreground hidden sm:block" aria-hidden="true">Date:</span>
{DATE_FILTER_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => handleDateFilterChange(value)}
aria-pressed={dateFilter === value}
className={cn(
"hidden sm:block 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",
dateFilter === value
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-accent"
)}
>
{label}
</button>
))}
</>
</nav>
</div>
@@ -731,6 +818,9 @@ function App() {
{/* Settings modal */}
{settingsModal}
{/* Mobile filter sheet */}
{filterSheet}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { Globe, ImageIcon, X } from "lucide-react";
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";
interface FilterSheetProps {
open: boolean;
onClose: () => void;
category: Category;
imageSize: ImageSize;
sortOrder: SortOrder;
dateFilter: DateFilter;
onCategoryChange: (v: Category) => void;
onImageSizeChange: (v: ImageSize) => void;
onSortChange: (v: SortOrder) => void;
onDateFilterChange: (v: DateFilter) => void;
}
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 first" },
{ value: "date_asc", label: "Oldest first" },
];
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 OptionRow<T extends string>({
options,
value,
onChange,
wrap = false,
}: {
options: { value: T; label: string }[];
value: T;
onChange: (v: T) => void;
wrap?: boolean;
}) {
return (
<div className={cn("flex gap-2", wrap ? "flex-wrap" : "flex-wrap")}>
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"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",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-accent text-muted-foreground hover:text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
);
}
export function FilterSheet({
open,
onClose,
category,
imageSize,
sortOrder,
dateFilter,
onCategoryChange,
onImageSizeChange,
onSortChange,
onDateFilterChange,
}: FilterSheetProps) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center"
role="dialog"
aria-modal="true"
aria-label="Search filters"
onClick={onClose}
>
{/* Backdrop */}
<div className="fixed inset-0 bg-black/50" />
{/* Sheet */}
<div
className="relative z-10 w-full max-w-lg rounded-t-2xl bg-card shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="h-1 w-10 rounded-full bg-border" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b">
<h2 className="text-base font-semibold">Search filters</h2>
<button
onClick={onClose}
aria-label="Close filters"
className="rounded-full p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* Filter groups */}
<div className="overflow-y-auto max-h-[60vh] px-5 py-4 space-y-5">
{/* Category */}
<section aria-labelledby="filter-category-label">
<p id="filter-category-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Category
</p>
<div className="flex gap-2">
{([
{ 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={() => { onCategoryChange(key); }}
aria-pressed={category === key}
className={cn(
"flex items-center gap-2 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"
: "bg-accent text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
</button>
))}
</div>
</section>
{/* Image size — only when images category */}
{category === "images" && (
<section aria-labelledby="filter-size-label">
<p id="filter-size-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Image size
</p>
<OptionRow options={IMAGE_SIZE_OPTIONS} value={imageSize} onChange={onImageSizeChange} />
</section>
)}
{/* Sort */}
<section aria-labelledby="filter-sort-label">
<p id="filter-sort-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Sort
</p>
<OptionRow options={SORT_OPTIONS} value={sortOrder} onChange={onSortChange} />
</section>
{/* Date */}
<section aria-labelledby="filter-date-label">
<p id="filter-date-label" className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Date published
</p>
<OptionRow options={DATE_FILTER_OPTIONS} value={dateFilter} onChange={onDateFilterChange} />
</section>
</div>
{/* Done button */}
<div className="px-5 py-4 border-t">
<button
onClick={onClose}
className="w-full rounded-xl bg-primary py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
Done
</button>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -74,11 +74,13 @@ export async function search(
category: "web" | "images" = "web",
page: number = 1,
imageSize: string = "",
sort: string = "default"
sort: string = "default",
dateFilter: string = ""
): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query, category, page: String(page) });
if (imageSize) params.set("image_size", imageSize);
if (sort !== "default") params.set("sort", sort);
if (dateFilter) params.set("date_filter", dateFilter);
const resp = await fetch(`${API_BASE}/search?${params}`);
if (!resp.ok) throw new Error(`Search failed: ${resp.status}`);
return resp.json();