mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-08 21:05:14 +10:00
Merge pull request #4 from wahyd4/feat/published-date
Add published date option and filter
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user