mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-09 05:06:23 +10:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdffeafa17 | ||
|
|
c5c18ae74a | ||
|
|
4e5b0424b0 | ||
|
|
e486ca1dc7 | ||
|
|
e63ac6ee59 | ||
|
|
9c9c52bf8b | ||
|
|
102f89a4bb | ||
|
|
91499c5133 | ||
|
|
fe3b27ff86 | ||
|
|
d7a3c8549d | ||
|
|
49e35a6b53 |
+88
-45
@@ -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 (1–1440 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 (1–100); `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` (1–20), `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
|
||||
|
||||
|
||||
@@ -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 (1–50) |
|
||||
| `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 (1–100) |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 (1–20).",
|
||||
},
|
||||
"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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
Generated
+11
-24
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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