Author SHA1 Message Date
junvandCopilot bdffeafa17 fix: update minimatch to fix ReDoS vulnerability (CVE-2026-27903)
Bumps minimatch from 3.1.3 → 3.1.5 and 9.0.6 → 9.0.9 to address
GHSA-7r86-cg39-jmmj (high severity ReDoS via combinatorial backtracking
in matchOne() with multiple non-adjacent GLOBSTAR segments).

Both instances are transitive dev dependencies of eslint and
typescript-eslint — no runtime exposure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 17:58:22 +11:00
junv c5c18ae74a Add format=llm support which get rid of extra content and only focus on title, url, snippet 2026-03-09 17:55:38 +11:00
junv 4e5b0424b0 Add published date option and filter 2026-03-09 17:35:36 +11:00
junv e486ca1dc7 Update readme 2026-03-07 12:23:15 +11:00
junv e63ac6ee59 Load REDIS_URL and save it to db when it exists 2026-03-07 12:19:00 +11:00
junv 9c9c52bf8b Add docker-compose 2026-03-07 12:15:53 +11:00
junv 102f89a4bb Update ui for stats page 2026-03-07 11:49:54 +11:00
junv 91499c5133 Update readme about how to use curl to search 2026-03-07 11:47:20 +11:00
junv fe3b27ff86 Update menu style 2026-03-07 11:41:33 +11:00
junv d7a3c8549d Update readme 2026-03-07 11:28:26 +11:00
junvandGitHub 49e35a6b53 Merge pull request #3 from wahyd4/feat/update-guide
Showing app version at the bottom
2026-03-07 11:25:13 +11:00
14 changed files with 1068 additions and 213 deletions
+88 -45
View File
@@ -3,13 +3,15 @@
## Search
- **Web search** — aggregates results from Brave, DuckDuckGo, Google, and Bing concurrently
- **Image search** — Pinterest-style masonry layout respecting natural image aspect ratios, with lightbox viewer and keyboard navigation (←/→ to browse, Escape to close)
- **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)
- **Image search** — masonry grid respecting natural aspect ratios, with lightbox viewer and keyboard navigation (←/→ to browse, Escape to close)
- **Image size filter** — filter images by size (Large, Medium, Small) applied server-side across all engines
- **Sort order** — sort results by engine rank (default), newest first, or oldest first
- **Autocomplete** — live search suggestions as you type (200ms debounce, ↑↓/Enter/Escape keyboard support)
- **Pagination** — navigate through result pages; URL reflects current state (`?q=...&page=2`)
- **URL deduplication** — duplicate results from multiple engines are merged automatically
- **Domain exclusion** — exclude specific websites from search results; settings persist in SQLite
- **Search stats** — per-engine result counts, status indicators (ok/error/timeout), collapsible sidebar
- **Domain exclusion** — exclude specific websites from all search results; settings persist in SQLite
- **Engine filter** — restrict a search to specific engines via the `engines` query parameter (e.g. `engines=google,bing`)
- **Per-engine stats** — result counts, status (ok / error / timeout), and error messages per engine shown in a collapsible bar
## Search Engines
@@ -18,80 +20,121 @@
- **DuckDuckGo** — HTML Lite scraping for web, API for images
- **Brave** — Brave Search API for web and images
## REST API
- Full REST API for search, autocomplete, and engine management
- `/api/search` accepts both **GET and POST** requests with query string parameters
- Compatibility parameters on `/api/search`: `pageNumber` (alias for `page`), `numResults`, `format`, `imageProxy`, `safesearch`
- OpenAPI specification with interactive docs via [Swagger UI](https://swagger.io/tools/swagger-ui/) (`/docs`) and [Redoc](https://github.com/Redocly/redoc) (`/redoc`)
## Engine Management
- **4 built-in engines**: Brave, DuckDuckGo, Google, Bing
- Enable or disable engines via the UI or API at runtime
- Enable or disable engines via the UI or API at runtime; settings persist in SQLite
- **Drag-and-drop reordering** — set engine priority order; affects result ordering and display badges
- Each engine supports both web and image search categories
- Google and Bing include automatic fallback mechanisms for resilience
## Reliability
## Search History
- **Retry mechanism** — failed upstream requests are retried with exponential backoff (via [tenacity](https://github.com/jd/tenacity))
- **Error reporting** — when an upstream engine fails or times out, the UI shows a toast notification identifying which engine had issues
- **Graceful degradation** — partial failures don't block results from other engines
- **Automatic recording** — every search query is saved with timestamp and category
- **History page** (`/history`) — paginated, date-grouped list of recent searches (Today, Yesterday, etc.)
- **Re-run any query** — click a history entry to instantly re-search it
- **Delete entries** — remove individual entries or clear all history at once
## Analytics Dashboard
- **Stats page** (`/stats`) — full analytics dashboard with configurable time range (7 / 30 / 90 days)
- **KPI cards** — total searches, total clicks, click-through rate, average clicked result position
- **Daily trend chart** — bar chart of search volume per day
- **Top queries** — ranked list of most-searched terms with counts
- **Clicks by engine** — bar chart showing which engines' results get clicked most
- **Click position distribution** — histogram of which result ranks users click on
- **Top clicked results** — ranked list of most-clicked URLs with title, engine, and count
- **Click tracking** — every result click is recorded server-side (fire-and-forget from the frontend)
## Caching
- **Redis cache** — identical searches served from Redis cache to reduce upstream load and latency
- **Redis cache** — identical searches served from Redis to reduce upstream load and latency
- **Configurable TTL** — cache duration adjustable from 0 (disabled) to 168 hours (1 week); default 6 hours
- **UI controls** — preset buttons + slider in Settings → Cache tab, with flush button
- **Cache status** — search response includes a `cached` flag; the stats bar indicates cache hits
- **UI controls** — TTL slider + preset buttons, Redis URL input, and flush button in Settings → Cache tab
- **Optional** — when `REDIS_URL` is not set or Redis is unreachable, caching is silently disabled
- **Deterministic keys** — cache key derived from query + category + page + image_size + engines
## Bookmarks
- **Bookmark any result** — click the bookmark icon on web or image search results to save them
- **Bookmarks page** — dedicated `/bookmarks` page to browse all saved items
- **Bookmark any result** — click the bookmark icon on web or image results to save them
- **Bookmarks page** (`/bookmarks`) — dedicated page to browse all saved items
- **Filter by type** — tabs to filter All / Web / Images
- **Masonry image grid** — saved images displayed in a masonry layout
- **Masonry image grid** — saved images displayed in a masonry layout with hover overlays
- **Remove bookmarks** — delete individual bookmarks from the bookmarks page
- **Persistent storage** — bookmarks stored in SQLite alongside other app data
## Background Images
- **Homepage backgrounds** — random background image displayed on the home page (sourced from Unsplash / Picsum)
- **Local caching** — images are downloaded and stored in `data/backgrounds/` to avoid re-fetching
- **Auto-refresh** — configurable refresh interval (11440 minutes, default 30) fetches new images automatically
- **Background gallery** (`/backgrounds`) — browse and manage all downloaded backgrounds; click to preview, view file size and source
- **Manual refresh** — refresh button in the gallery to fetch a new image immediately
- **Enable / disable** — toggle backgrounds on or off in Settings → Background tab
- **iOS color sampling** — samples edge pixels from the background to set the `theme-color` meta tag, preventing white/black bars in Safari
## Settings
- **Unified settings modal** — single ⚙ button opens a tabbed modal with four tabs:
1. **Engines** — enable/disable and drag-to-reorder search engines
2. **Excluded Sites** — add or remove domains from the search blocklist
3. **Cache** — view Redis status, set TTL, update Redis URL, flush cache
4. **Background** — enable/disable background images, set refresh interval
## REST API
- Full REST API for search, autocomplete, engine management, bookmarks, history, stats, and settings
- `/api/search` accepts both **GET and POST** requests with query string parameters
- **`format=llm`** — minimal LLM-optimised response: only `query`, `results` (title, url, snippet, date), and `total_results`; no engine noise
- **`max_results`** — hard limit on returned results (1100); `numResults` is a supported alias
- Compatibility parameters: `pageNumber` (alias for `page`), `numResults`, `format`, `imageProxy`, `safesearch`
- Every result includes `result_id`, `rank`, `engine`, and `published_date`
- `has_next` and `total_results` fields for cursor-aware pagination
- `X-Response-Time-Ms` response header on all endpoints
- OpenAPI specification with interactive docs via [Swagger UI](https://swagger.io/tools/swagger-ui/) (`/docs`) and [Redoc](https://github.com/Redocly/redoc) (`/redoc`)
## MCP Tool Server
- **`/api/mcp`** — [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint; exposes HeySearch as a native tool for LLMs
- Compatible with Claude Desktop, Cursor, Continue, VS Code Copilot, and any MCP-capable client
- **Transport**: Streamable HTTP (JSON-RPC 2.0 POST)
- **Available tools**: `search` (web + image search) and `autocomplete`
- `search` tool supports `query`, `category`, `num_results` (120), `engines`, `sort`, and `date_filter` arguments
- Add to Claude Desktop by pointing `url` at `http://your-host/api/mcp` with `"transport": "http"`
## Reliability
- **Retry mechanism** — failed upstream requests are retried with exponential backoff (via [tenacity](https://github.com/jd/tenacity), 2 attempts)
- **Error reporting** — when an upstream engine fails or times out, the UI shows a toast notification identifying which engine had issues
- **Graceful degradation** — partial failures don't block results from other engines
## UI / UX
- **Mobile-first** responsive design built with React, Tailwind CSS, and shadcn theming
- Clean search home page with branded gradient header
- Web results show favicons, engine badges, and content snippets
- Image results displayed in a masonry grid with hover previews and a full lightbox with arrow-key navigation
- **Visited link colors** — clicked links turn purple to distinguish from unvisited results
- Engine settings modal with toggle switches
- Excluded domains management modal with add/remove UI
- **Unified settings modal** — single Settings button opens a tabbed modal (Engines, Excluded Sites)
- Web results show favicons, engine badges, publish dates, and content snippets
- Image results in a masonry grid with hover previews and a full-screen lightbox
- **Visited link colours** — clicked links turn purple to distinguish from unvisited results
- **Dropdown navigation menu** — consistent ☰ menu across all pages (search, bookmarks, history, stats, gallery)
- Keyboard navigation for autocomplete suggestions (↑↓ arrows, Enter, Escape)
- Footer with link to interactive API documentation (Swagger UI)
- URL-based routing with browser history support — shareable search URLs
## Accessibility
- **Skip to main content** link for keyboard users
- Image cards are real `<a>` links (Cmd/Ctrl-click, right-click, open-in-new-tab)
- Image cards are real `<a>` links (Cmd/Ctrl-click, right-click, open-in-new-tab all work)
- `aria-label` on all icon-only buttons and interactive elements
- Search input with `<label>`, `name`, `type="search"`, and ARIA combobox pattern
- Visible `focus-visible` ring on all focusable elements
- `aria-live` regions for loading/results/error announcements
- `aria-live` regions for loading / results / error announcements
- Proper heading hierarchy (`h1`/`h2`) and landmark elements (`<nav>`, `<main>`, `<aside>`)
- Dialog semantics on modals and lightbox (`role="dialog"`, `aria-modal`)
## REST API
- Full REST API for search, autocomplete, and engine management
- `/api/search` accepts both **GET and POST** requests with query string parameters
- Compatibility parameters on `/api/search`: `pageNumber` (alias for `page`), `numResults`, `format`, `imageProxy`, `safesearch`
- Every result includes `result_id`, `rank`, `engine`, and `timestamp` for agent integration
- `has_next` and `total_results` fields for cursor-aware pagination
- Standardized error schema (`code`/`message`/`details`/`retry_hint`) on all error responses
- `X-Response-Time-Ms` and rate-limit headers on all responses
- Copy-paste `curl` examples in OpenAPI docs for every endpoint
- OpenAPI specification with interactive docs via [Swagger UI](https://swagger.io/tools/swagger-ui/) (`/docs`) and [Redoc](https://github.com/Redocly/redoc) (`/redoc`)
## Deployment
- **Docker** — multi-stage Dockerfile: Node.js frontend build → Python production image
- Backend uses [uv](https://docs.astral.sh/uv/) for fast, reproducible dependency management
- **GHCR image** — pre-built image published to GitHub Container Registry on every release
- **Data volume** — `/app/data` stores SQLite databases and background images; mount for persistence
- **Environment variables** — `DATA_DIR` (data directory path), `REDIS_URL` (optional Redis connection string)
- Backend uses [uv](https://docs.astral.sh/uv/) for fast, reproducible Python dependency management
+92 -10
View File
@@ -18,9 +18,9 @@ Both are open-source, self-hosted, privacy-respecting metasearch engines. Here's
| | HeySearch | SearXNG |
|---|---|---|
| **Setup** | `docker run -p 8000:8000 ghcr.io/…/hey-search` — one command, zero config | Requires YAML config, engine tuning, often breaks out of the box |
| **UI** | Modern, clean React UI with dark mode, background images, image lightbox | Functional but dated — not optimised for mobile or daily use |
| **AI agent friendly** | Clean JSON REST API, OpenAPI docs at `/docs`, designed to be queried programmatically | API exists but less documented; HTML-heavy responses |
| **Setup** | `docker compose up -d` — one command, zero config | Requires YAML config, engine tuning, sometimes breaks |
| **UI** | Modern, clean React UI with dark mode, background images, image lightbox | Functional but dated — not mobile friendly |
| **AI agent friendly** | MCP tool server at `/api/mcp` (Claude Desktop, Cursor, Continue), `format=llm` for minimal responses, clean JSON REST API, OpenAPI docs at `/docs` | API exists but less documented; HTML-heavy responses |
| **Bookmarks** | Built-in bookmark manager for results | ❌ |
| **Search history** | Full search history with timestamps, re-run any past query in one click | ❌ |
| **Usage stats** | Built-in analytics dashboard — top queries, click-through rates, engine usage | ❌ |
@@ -53,20 +53,28 @@ just dev
REDIS_URL=redis://192.168.1.2:6399 just dev
```
### 2. Docker (production)
### 2. Docker Compose
The easiest way to run HeySearch with Redis caching in one command:
```bash
docker build -t hey-search .
docker compose up -d
```
Then open [http://localhost:8000](http://localhost:8000). Data and Redis are persisted in named volumes automatically.
### 3. Docker (standalone)
```bash
# Basic — data stored in anonymous volume
docker run -p 8000:8000 hey-search
docker run -p 8000:8000 ghcr.io/wahyd4/hey-search
# Recommended — mount data directory for persistence
docker run -p 8000:8000 -v ./hey-search-data:/app/data hey-search
docker run -p 8000:8000 -v ./hey-search-data:/app/data ghcr.io/wahyd4/hey-search
# With Redis
docker run -p 8000:8000 -v ./hey-search-data:/app/data \
-e REDIS_URL=redis://your-redis:6379 hey-search
-e REDIS_URL=redis://your-redis:6379 ghcr.io/wahyd4/hey-search
```
The `/app/data` volume stores the SQLite database (engine settings, excluded domains, cache config). Mount it to preserve your settings across container restarts.
@@ -82,6 +90,7 @@ The `/app/data` volume stores the SQLite database (engine settings, excluded dom
| GET | `/api/settings` | Get app settings (cache TTL)|
| PUT | `/api/settings` | Update settings |
| DELETE | `/api/cache` | Flush search cache |
| POST | `/api/mcp` | MCP tool server (for LLMs) |
### Search endpoint parameters
@@ -91,13 +100,86 @@ The `/app/data` volume stores the SQLite database (engine settings, excluded dom
| `category` | `web` | `web` or `images` |
| `page` | `1` | Page number (150) |
| `pageNumber` | — | Alias for `page` (takes precedence when provided) |
| `numResults` | — | Requested result count hint (informational) |
| `format` | — | Response format hint (e.g. `json`) |
| `max_results`| — | Hard limit on results returned (1100) |
| `numResults` | — | Alias for `max_results` |
| `format` | — | `llm` for minimal LLM-friendly response (see below) |
| `imageProxy` | — | Client image-proxy preference flag (informational) |
| `safesearch` | — | Safe search level: `0` off, `1` moderate, `2` strict |
| `engines` | — | Comma-separated engine names to restrict (e.g. `google,bing`) |
| `image_size` | — | `large`, `medium`, or `small` (images only) |
| `sort` | `default`| `default`, `date_asc`, or `date_desc` |
| `date_filter`| — | `day`, `week`, `month`, or `year` |
## Using with AI Agents / LLMs
HeySearch is designed to be used by LLMs and AI agents. There are two integration methods:
### 1. MCP Tool Server (recommended)
[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) is the standard for LLM tool use. Add HeySearch to any MCP-compatible client:
**Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"heysearch": {
"url": "http://localhost:8000/api/mcp",
"transport": "http"
}
}
}
```
**Cursor / Continue / VS Code Copilot** — add `http://localhost:8000/api/mcp` as an MCP server URL in the tool settings.
**Manual test:**
```bash
# List available tools
curl -X POST http://localhost:8000/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Call the search tool
curl -X POST http://localhost:8000/api/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"python async","num_results":3}}}'
```
**Available MCP tools:** `search`, `autocomplete`
### 2. REST API with `format=llm`
For direct API calls from LLM agents, use `format=llm` to get a minimal, token-efficient response:
```bash
# LLM-optimised response — only title, url, snippet, date. No engine noise.
curl "http://localhost:8000/api/search?q=python+async&format=llm&max_results=5" | jq
```
Response shape:
```json
{
"query": "python async",
"category": "web",
"results": [
{ "title": "...", "url": "https://...", "snippet": "...", "date": "2024-01-15" }
],
"total_results": 5
}
```
```bash
# Restrict to specific engines
curl "http://localhost:8000/api/search?q=rust+programming&engines=brave,google&format=llm" | jq
# Image search with size filter
curl "http://localhost:8000/api/search?q=mountain+landscape&category=images&image_size=large" | jq
# Limit results (hard limit, not a hint)
curl "http://localhost:8000/api/search?q=openai&max_results=3&format=llm" | jq
```
> Interactive API docs (Swagger UI) are available at `http://localhost:8000/docs`.
## Features
+41 -4
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.models import SearchResponse, EngineInfo, APIError
from app.models import SearchResponse, EngineInfo, APIError, LLMWebResult, LLMImageResult, LLMSearchResponse
from app.search import search, get_autocomplete
from app.engines import registry
from app.excluded import get_excluded_domains, add_excluded_domain, remove_excluded_domain
@@ -35,6 +35,12 @@ Supports both GET and POST methods with query parameters.
curl '$BASE_URL/api/search?q=hello+world&category=web&page=1'
```
**LLM / AI-agent optimised response (`format=llm`):**
```bash
curl '$BASE_URL/api/search?q=hello+world&format=llm&max_results=5'
```
Returns a minimal JSON response with only `query`, `results` (title, url, snippet, date), and `total_results` — ideal for RAG pipelines and tool-calling.
**Example response (truncated):**
```json
{
@@ -75,17 +81,21 @@ async def api_search(
category: Literal["web", "images"] = Query("web", description="Search category"),
page: int = Query(1, ge=1, le=50, description="Page number"),
pageNumber: int | None = Query(None, ge=1, le=50, description="Alias for page (1-based page number)"),
numResults: int | None = Query(None, ge=1, le=100, description="Number of results requested (informational)"),
format: str | None = Query(None, description="Response format hint (e.g. 'json')"),
numResults: int | None = Query(None, ge=1, le=100, description="Number of results to return (applies as hard limit)"),
max_results: int | None = Query(None, ge=1, le=100, description="Maximum number of results to return"),
format: str | None = Query(None, description="Response format: 'llm' for a minimal LLM-friendly response, omit for full JSON"),
imageProxy: bool | None = Query(None, description="Whether the client wants image proxying"),
safesearch: str | None = Query(None, description="Safe search level (0=off, 1=moderate, 2=strict)"),
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)
# max_results takes precedence; numResults is a supported alias
effective_max = max_results if max_results is not None else numResults
result = await search(q, category=category, page=effective_page, engines=engine_list, image_size=image_size, sort=sort, date_filter=date_filter, max_results=effective_max)
origin_ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
_stats.record_search(
@@ -96,6 +106,33 @@ async def api_search(
result_count=result.total_results,
cached=result.cached,
)
if format == "llm":
llm_results: list[LLMWebResult | LLMImageResult] = []
for r in result.results:
if category == "images":
llm_results.append(LLMImageResult(
title=r.title,
url=r.url,
img_src=getattr(r, "img_src", ""),
date=r.published_date or "",
))
else:
llm_results.append(LLMWebResult(
title=r.title,
url=r.url,
snippet=getattr(r, "content", ""),
date=r.published_date or "",
))
# Return JSONResponse directly to bypass response_model=SearchResponse
# coercion, which would otherwise strip LLM-only fields (snippet, date).
return JSONResponse(content=LLMSearchResponse(
query=result.query,
category=result.category,
results=llm_results,
total_results=len(llm_results),
).model_dump())
return result
+15 -1
View File
@@ -1,6 +1,7 @@
"""HeySearch - A metasearch engine."""
import json
import logging
import os
import time
from pathlib import Path
@@ -12,14 +13,17 @@ from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.mcp_server import router as mcp_router
from app.engines import registry
from app.excluded import init_db
from app.settings import init_settings_table
from app.settings import init_settings_table, set_setting
from app.cache import init_redis, close_redis
from app.bookmarks import init_bookmarks_table
from app.stats import init_db as init_stats_db
from app.models import APIError
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(application: FastAPI):
@@ -27,6 +31,15 @@ async def lifespan(application: FastAPI):
init_settings_table()
init_bookmarks_table()
init_stats_db()
# If REDIS_URL is provided via environment, persist it to the DB so it
# shows up in the settings UI and takes effect even if the DB had a
# different (or empty) value.
env_redis_url = os.environ.get("REDIS_URL", "").strip()
if env_redis_url:
set_setting("redis_url", env_redis_url)
logger.info("REDIS_URL from environment saved to settings: %s", env_redis_url)
registry.load_default_engines()
await init_redis()
yield
@@ -119,6 +132,7 @@ async def global_exception_handler(request: Request, exc: Exception):
app.include_router(router, prefix="/api")
app.include_router(mcp_router, prefix="/api")
# Alias /search → /api/search for compatibility with external clients
@app.api_route("/search", methods=["GET", "POST"], include_in_schema=False)
+313
View File
@@ -0,0 +1,313 @@
"""MCP (Model Context Protocol) server for HeySearch.
Exposes HeySearch as an MCP tool server so LLMs (Claude, Cursor, Continue,
VS Code Copilot, etc.) can invoke search directly via the standard MCP
JSON-RPC 2.0 protocol.
Transport: Streamable HTTP — clients POST JSON-RPC messages to /mcp.
Supported methods:
initialize — capability negotiation
tools/list — enumerate available tools
tools/call — invoke a tool (search, autocomplete)
ping — liveness check
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, Response
logger = logging.getLogger(__name__)
router = APIRouter(tags=["MCP"])
MCP_PROTOCOL_VERSION = "2024-11-05"
SERVER_INFO = {"name": "HeySearch", "version": "1.4.0"}
# ---------------------------------------------------------------------------
# Tool definitions (JSON Schema)
# ---------------------------------------------------------------------------
_SEARCH_TOOL: dict[str, Any] = {
"name": "search",
"description": (
"Search the web or images using the HeySearch privacy-respecting "
"metasearch engine. Results are aggregated from Brave, DuckDuckGo, "
"Google, and Bing and deduplicated. Returns titles, URLs, snippets, "
"and publication dates."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string.",
},
"category": {
"type": "string",
"enum": ["web", "images"],
"default": "web",
"description": "Search category: 'web' for text results, 'images' for image results.",
},
"num_results": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5,
"description": "Maximum number of results to return (120).",
},
"engines": {
"type": "string",
"description": (
"Comma-separated engine names to restrict the search "
"(e.g. 'google,bing'). Omit to use all enabled engines."
),
},
"sort": {
"type": "string",
"enum": ["default", "date_asc", "date_desc"],
"default": "default",
"description": "Sort order: default (relevance), date_asc, or date_desc.",
},
"date_filter": {
"type": "string",
"enum": ["", "day", "week", "month", "year"],
"default": "",
"description": "Filter results by recency: day (24 h), week, month, or year.",
},
},
"required": ["query"],
},
}
_AUTOCOMPLETE_TOOL: dict[str, Any] = {
"name": "autocomplete",
"description": (
"Get search query autocomplete suggestions from HeySearch. "
"Useful for expanding or refining a partial query."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Partial search query to get suggestions for.",
},
},
"required": ["query"],
},
}
_ALL_TOOLS = [_SEARCH_TOOL, _AUTOCOMPLETE_TOOL]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ok(req_id: Any, result: Any) -> dict:
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def _err(req_id: Any, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
def _tool_result(text: str, is_error: bool = False) -> dict:
return {"content": [{"type": "text", "text": text}], "isError": is_error}
# ---------------------------------------------------------------------------
# Tool handlers
# ---------------------------------------------------------------------------
async def _handle_search(arguments: dict) -> str:
from app.search import search # local import to avoid circular deps
query: str = arguments.get("query", "").strip()
if not query:
return "Error: 'query' argument is required."
category: str = arguments.get("category", "web")
num_results: int = min(int(arguments.get("num_results", 5)), 20)
engines_str: str | None = arguments.get("engines")
sort: str = arguments.get("sort", "default")
date_filter: str = arguments.get("date_filter", "")
engine_list = [e.strip() for e in engines_str.split(",")] if engines_str else None
result = await search(
query,
category=category,
page=1,
engines=engine_list,
sort=sort,
date_filter=date_filter,
max_results=num_results,
)
if not result.results:
return f"No results found for: {query}"
lines: list[str] = [f"Search results for: {query}\n"]
for i, r in enumerate(result.results, 1):
lines.append(f"{i}. {r.title}")
lines.append(f" URL: {r.url}")
snippet = getattr(r, "content", "") or getattr(r, "img_src", "")
if snippet:
lines.append(f" {snippet}")
if r.published_date:
lines.append(f" Date: {r.published_date}")
lines.append("")
return "\n".join(lines)
async def _handle_autocomplete(arguments: dict) -> str:
from app.search import get_autocomplete # local import
query: str = arguments.get("query", "").strip()
if not query:
return "Error: 'query' argument is required."
suggestions = await get_autocomplete(query)
if not suggestions:
return f"No suggestions found for: {query}"
return "Suggestions:\n" + "\n".join(f"- {s}" for s in suggestions)
# ---------------------------------------------------------------------------
# Request dispatcher
# ---------------------------------------------------------------------------
async def _dispatch(req: dict) -> dict | None:
"""Handle one JSON-RPC request object. Returns None for notifications."""
method: str = req.get("method", "")
req_id = req.get("id")
params: dict = req.get("params") or {}
# Notifications (no id) — acknowledge silently
if req_id is None:
return None
if method == "initialize":
return _ok(req_id, {
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": SERVER_INFO,
})
if method == "ping":
return _ok(req_id, {})
if method == "tools/list":
cursor = params.get("cursor") # pagination cursor (unused — all tools fit in one page)
return _ok(req_id, {"tools": _ALL_TOOLS})
if method == "tools/call":
tool_name: str = params.get("name", "")
arguments: dict = params.get("arguments") or {}
if tool_name == "search":
try:
text = await _handle_search(arguments)
return _ok(req_id, _tool_result(text))
except Exception as exc:
logger.exception("MCP search tool error")
return _ok(req_id, _tool_result(f"Search failed: {exc}", is_error=True))
if tool_name == "autocomplete":
try:
text = await _handle_autocomplete(arguments)
return _ok(req_id, _tool_result(text))
except Exception as exc:
logger.exception("MCP autocomplete tool error")
return _ok(req_id, _tool_result(f"Autocomplete failed: {exc}", is_error=True))
return _err(req_id, -32601, f"Unknown tool: {tool_name}")
return _err(req_id, -32601, f"Method not found: {method}")
# ---------------------------------------------------------------------------
# FastAPI endpoint
# ---------------------------------------------------------------------------
@router.post(
"/mcp",
summary="MCP tool server",
description="""[Model Context Protocol](https://modelcontextprotocol.io/) (MCP) endpoint.
Exposes HeySearch as an MCP tool server. LLMs and AI coding assistants
(Claude Desktop, Cursor, Continue, VS Code Copilot, etc.) can add this
server to their MCP configuration to invoke search directly.
**Transport:** Streamable HTTP — POST JSON-RPC 2.0 messages to this endpoint.
**Available tools:** `search`, `autocomplete`
**Quick config example (Claude Desktop / `claude_desktop_config.json`):**
```json
{
"mcpServers": {
"heysearch": {
"url": "$BASE_URL/api/mcp",
"transport": "http"
}
}
}
```
**Manual test:**
```bash
# List available tools
curl -X POST $BASE_URL/api/mcp \\
-H 'Content-Type: application/json' \\
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# Invoke the search tool
curl -X POST $BASE_URL/api/mcp \\
-H 'Content-Type: application/json' \\
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"query":"python async","num_results":3}}}'
```
""",
include_in_schema=True,
)
async def mcp_endpoint(request: Request):
"""Handle MCP JSON-RPC 2.0 requests (single or batch)."""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content=_err(None, -32700, "Parse error: request body must be valid JSON"),
)
# Batch request
if isinstance(body, list):
responses = [await _dispatch(req) for req in body if isinstance(req, dict)]
responses = [r for r in responses if r is not None]
if not responses:
return Response(status_code=204)
return JSONResponse(content=responses)
# Single request
if not isinstance(body, dict):
return JSONResponse(
status_code=400,
content=_err(None, -32600, "Invalid request: expected a JSON object or array"),
)
result = await _dispatch(body)
if result is None:
# Notification — no response body
return Response(status_code=204)
return JSONResponse(content=result)
+30
View File
@@ -84,3 +84,33 @@ class APIError(BaseModel):
message: str
details: str = ""
retry_hint: str = ""
# --- LLM-optimised response models ---
class LLMWebResult(BaseModel):
"""A single web result stripped to the fields LLMs need."""
title: str
url: str
snippet: str = ""
date: str = ""
class LLMImageResult(BaseModel):
"""A single image result stripped to the fields LLMs need."""
title: str
url: str
img_src: str
date: str = ""
class LLMSearchResponse(BaseModel):
"""Minimal search response for LLM / AI-agent consumption.
Contains only the fields needed for RAG and tool-calling workflows.
Omits engine metadata, error details, and other browser-UI noise.
"""
query: str
category: str = "web"
results: list[LLMWebResult | LLMImageResult] = Field(default_factory=list)
total_results: int = 0
+58 -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,24 @@ async def search(
engines: list[str] | None = None,
image_size: str = "",
sort: SortOrder = "default",
date_filter: DateFilter = "",
max_results: int | None = None,
) -> 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)
if max_results is not None:
resp.results = resp.results[:max_results]
resp.total_results = len(resp.results)
return resp
enabled_engines = registry.get_enabled_engines()
@@ -153,12 +198,16 @@ 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)
if max_results is not None:
response.results = response.results[:max_results]
response.total_results = len(response.results)
return response
+31
View File
@@ -0,0 +1,31 @@
services:
app:
image: ghcr.io/wahyd4/hey-search:latest
build: .
ports:
- "8000:8000"
environment:
- REDIS_URL=redis://redis:6379
- DATA_DIR=/app/data
volumes:
- hey-search-data:/app/data
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- hey-search-redis:/data
command: redis-server --save 60 1 --loglevel warning
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
hey-search-data:
hey-search-redis:
+11 -24
View File
@@ -1921,37 +1921,24 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
"integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -3264,9 +3251,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
"integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
+119 -23
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 <>
@@ -389,7 +426,13 @@ function App() {
// Stats page
if (showStats) {
return <>
<StatsPage onGoHome={handleGoHome} />
<StatsPage
onGoHome={handleGoHome}
onShowSettings={() => setShowSettings(true)}
onShowBookmarks={handleShowBookmarks}
onShowGallery={handleShowGallery}
onShowHistory={handleShowHistory}
/>
{settingsModal}
</>;
}
@@ -578,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"
@@ -601,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"
@@ -621,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>
@@ -725,6 +818,9 @@ function App() {
{/* Settings modal */}
{settingsModal}
{/* Mobile filter sheet */}
{filterSheet}
</div>
);
}
+56 -81
View File
@@ -17,8 +17,6 @@ interface AppHeaderProps {
children?: React.ReactNode;
}
// Items ordered left→right. Rightmost item (Settings) gets the shortest delay,
// so it appears first creating the right-to-left unfold effect.
const NAV_ITEMS = [
{ id: "docs", icon: ExternalLink, label: "API Docs", href: "/docs" },
{ id: "stats", icon: BarChart2, label: "Stats", href: null },
@@ -28,8 +26,6 @@ const NAV_ITEMS = [
{ id: "settings", icon: Settings, label: "Settings", href: null },
] as const;
const STAGGER_MS = 60;
export function AppHeader({
onGoHome,
onShowSettings,
@@ -47,11 +43,11 @@ export function AppHeader({
const close = () => setMenuOpen(false);
const actions: Record<string, () => void> = {
settings: () => { onShowSettings(); close(); },
bookmarks: () => { onShowBookmarks(); close(); },
gallery: () => { onShowGallery(); close(); },
stats: () => { onShowStats(); close(); },
history: () => { onShowHistory?.(); close(); },
settings: () => { onShowSettings(); close(); },
bookmarks: () => { onShowBookmarks(); close(); },
gallery: () => { onShowGallery(); close(); },
stats: () => { onShowStats(); close(); },
history: () => { onShowHistory?.(); close(); },
docs: close,
};
@@ -84,12 +80,10 @@ export function AppHeader({
: "text-muted-foreground hover:bg-accent"
);
const itemBase = cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium whitespace-nowrap",
"transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
transparent
? "text-white/80 hover:text-white hover:bg-white/10"
: "text-muted-foreground hover:bg-accent"
const dropdownItemBase = cn(
"flex w-full items-center gap-3 px-4 py-3 text-sm font-medium transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
"text-foreground hover:bg-accent"
);
return (
@@ -117,72 +111,8 @@ export function AppHeader({
: <div className="flex-1" />
}
{/* Right side: animated nav items + toggle button */}
<div ref={menuRef} className="flex items-center gap-1 shrink-0">
{/* Nav items — animate right→left on open */}
{NAV_ITEMS.map((item, i) => {
// Rightmost item (Settings, index 3) gets 0ms delay → appears first
const delay = menuOpen
? (NAV_ITEMS.length - 1 - i) * STAGGER_MS
: 0;
// Outer wrapper collapses to width 0 when closed so it doesn't
// squeeze sibling content (e.g. the search bar on the results page).
const wrapperStyle: React.CSSProperties = {
maxWidth: menuOpen ? "120px" : "0px",
overflow: "hidden",
transition: `max-width 180ms cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms`,
};
const innerStyle: React.CSSProperties = {
transitionProperty: "opacity, transform",
transitionDuration: "180ms",
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
transitionDelay: `${delay}ms`,
opacity: menuOpen ? 1 : 0,
transform: menuOpen ? "translateX(0)" : "translateX(16px)",
pointerEvents: menuOpen ? "auto" : "none",
};
const Icon = item.icon;
if (item.href) {
return (
<div key={item.id} style={wrapperStyle}>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
aria-label={item.label}
title={item.label}
className={itemBase}
style={innerStyle}
onClick={close}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">{item.label}</span>
</a>
</div>
);
}
return (
<div key={item.id} style={wrapperStyle}>
<button
onClick={actions[item.id]}
aria-label={item.label}
title={item.label}
className={itemBase}
style={innerStyle}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">{item.label}</span>
</button>
</div>
);
})}
{/* Toggle button */}
{/* Menu toggle */}
<div ref={menuRef} className="relative shrink-0">
<button
onClick={() => setMenuOpen((v) => !v)}
aria-label={menuOpen ? "Close menu" : "Open menu"}
@@ -200,6 +130,51 @@ export function AppHeader({
}
</span>
</button>
{/* Dropdown panel */}
<div
className={cn(
"absolute right-0 top-full mt-2 w-52 rounded-xl border shadow-lg",
"bg-background/95 backdrop-blur",
"overflow-hidden transition-all duration-200 origin-top-right",
menuOpen
? "opacity-100 scale-100 pointer-events-auto"
: "opacity-0 scale-95 pointer-events-none"
)}
role="menu"
aria-label="Navigation menu"
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
if (item.href) {
return (
<a
key={item.id}
href={item.href}
target="_blank"
rel="noopener noreferrer"
role="menuitem"
className={dropdownItemBase}
onClick={close}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
{item.label}
</a>
);
}
return (
<button
key={item.id}
role="menuitem"
onClick={actions[item.id]}
className={dropdownItemBase}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
{item.label}
</button>
);
})}
</div>
</div>
</div>
</header>
+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>
);
}
+20 -15
View File
@@ -1,9 +1,14 @@
import { useEffect, useState } from "react";
import { fetchStats, type StatsSummary } from "@/lib/api";
import { BarChart2, TrendingUp, MousePointerClick, Search } from "lucide-react";
import { AppHeader } from "@/components/AppHeader";
interface StatsPageProps {
onGoHome: () => void;
onShowSettings: () => void;
onShowBookmarks: () => void;
onShowGallery: () => void;
onShowHistory?: () => void;
}
function Card({ title, value, sub }: { title: string; value: string | number; sub?: string }) {
@@ -16,7 +21,7 @@ function Card({ title, value, sub }: { title: string; value: string | number; su
);
}
export function StatsPage({ onGoHome }: StatsPageProps) {
export function StatsPage({ onGoHome, onShowSettings, onShowBookmarks, onShowGallery, onShowHistory }: StatsPageProps) {
const [days, setDays] = useState(7);
const [data, setData] = useState<StatsSummary | null>(null);
const [loading, setLoading] = useState(true);
@@ -33,20 +38,20 @@ export function StatsPage({ onGoHome }: StatsPageProps) {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur">
<div className="flex items-center gap-3 px-4 py-3">
<button
onClick={onGoHome}
className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded"
>
HS
</button>
<div className="flex items-center gap-2 text-sm font-medium">
<BarChart2 className="h-4 w-4" />
<AppHeader
onGoHome={onGoHome}
onShowSettings={onShowSettings}
onShowBookmarks={onShowBookmarks}
onShowGallery={onShowGallery}
onShowStats={() => {}}
onShowHistory={onShowHistory}
>
<div className="flex items-center gap-3 min-w-0">
<h1 className="flex items-center gap-2 text-sm font-semibold whitespace-nowrap">
<BarChart2 className="h-4 w-4 shrink-0" />
Search Analytics
</div>
<div className="ml-auto flex items-center gap-1">
</h1>
<div className="flex items-center gap-1">
{[7, 30, 90].map((d) => (
<button
key={d}
@@ -60,7 +65,7 @@ export function StatsPage({ onGoHome }: StatsPageProps) {
))}
</div>
</div>
</header>
</AppHeader>
<main className="mx-auto max-w-5xl px-4 py-8 space-y-8">
{loading && <p className="text-center text-muted-foreground py-16">Loading</p>}
+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();