Done by copilot

This commit is contained in:
2026-02-23 14:37:07 +11:00
commit 9d36d4aba9
43 changed files with 5852 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__
*.pyc
.venv
node_modules
.git
*.egg-info
dist
.env
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.venv/
node_modules/
dist/
.env
*.egg-info/
View File
+28
View File
@@ -0,0 +1,28 @@
# Stage 1: Build frontend
FROM node:20-slim AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Python backend
FROM python:3.12-slim AS production
WORKDIR /app
# Install system deps for lxml
RUN apt-get update && apt-get install -y --no-install-recommends \
libxml2 libxslt1.1 && \
rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
# Copy frontend build output to static dir
COPY --from=frontend-build /app/frontend/dist /app/static
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+15
View File
@@ -0,0 +1,15 @@
# Hey Search
This is another [meta search](https://en.wikipedia.org/wiki/Metasearch_engine) engine web app.
# Features
- Support search via rest API, publish api page via https://github.com/Redocly/redoc
- Support searching web, images
- support auto completes when user trying in search input
- Robust error handling when upstream engines failed, it should remind user via UI when upstream search engines fails.
- Proper retry mechanism to retry upstream search engines.
- Support managing search engines, enable or disable via UI, it should support brave, duckduckgo, google, bing
- Best responsive and simple UI, A modern web UI support both desktop and mobile browsers, but put mobile first
- frontend part to use shadcn and react js, and make a Docker image for it. Backend write in Python
- Good UI to render image search results page.
+3
View File
@@ -0,0 +1,3 @@
from app.main import app
__all__ = ["app"]
View File
+87
View File
@@ -0,0 +1,87 @@
"""API routes for Hey Search."""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Query
from pydantic import BaseModel
from app.models import SearchResponse, EngineInfo
from app.search import search, get_autocomplete
from app.engines import registry
router = APIRouter()
# --- Search ---
@router.get(
"/search",
response_model=SearchResponse,
summary="Search the web or images",
description="Performs a metasearch across all enabled engines and returns aggregated results.",
tags=["Search"],
)
async def api_search(
q: str = Query(..., description="Search query string", min_length=1),
category: Literal["web", "images"] = Query("web", description="Search category"),
page: int = Query(1, ge=1, le=50, description="Page number"),
engines: str | None = Query(None, description="Comma-separated engine names to use"),
):
engine_list = [e.strip() for e in engines.split(",")] if engines else None
return await search(q, category=category, page=page, engines=engine_list)
# --- Autocomplete ---
class AutocompleteResponse(BaseModel):
query: str
suggestions: list[str]
@router.get(
"/autocomplete",
response_model=AutocompleteResponse,
summary="Get search suggestions",
description="Returns autocomplete suggestions for the given query.",
tags=["Search"],
)
async def api_autocomplete(
q: str = Query(..., description="Partial search query", min_length=1),
):
suggestions = await get_autocomplete(q)
return AutocompleteResponse(query=q, suggestions=suggestions)
# --- Engine Management ---
@router.get(
"/engines",
response_model=list[EngineInfo],
summary="List all search engines",
description="Returns all available search engines and their current enabled/disabled status.",
tags=["Engines"],
)
async def api_list_engines():
return registry.get_all_engine_info()
class EngineToggleRequest(BaseModel):
enabled: bool
@router.put(
"/engines/{engine_name}",
response_model=EngineInfo,
summary="Enable or disable a search engine",
description="Toggle an engine on or off. Disabled engines are skipped during search.",
tags=["Engines"],
)
async def api_toggle_engine(engine_name: str, body: EngineToggleRequest):
success = registry.set_engine_enabled(engine_name, body.enabled)
if not success:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Engine '{engine_name}' not found")
engines = registry.get_all_engine_info()
return next(e for e in engines if e.name == engine_name)
View File
+58
View File
@@ -0,0 +1,58 @@
"""Abstract base class for search engines."""
from __future__ import annotations
import abc
import logging
from typing import Literal
import httpx
from app.models import WebResult, ImageResult
logger = logging.getLogger(__name__)
SearchCategory = Literal["web", "images"]
# Shared async HTTP client
_client: httpx.AsyncClient | None = None
def get_http_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(10.0, connect=5.0),
follow_redirects=True,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
)
return _client
class SearchEngine(abc.ABC):
"""Abstract search engine interface."""
name: str
display_name: str
supports_web: bool = True
supports_images: bool = True
@abc.abstractmethod
async def search_web(self, query: str, page: int = 1) -> list[WebResult]:
"""Perform a web search and return results."""
@abc.abstractmethod
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
"""Perform an image search and return results."""
async def autocomplete(self, query: str) -> list[str]:
"""Return autocomplete suggestions. Override if supported."""
return []
+109
View File
@@ -0,0 +1,109 @@
"""Bing Search engine implementation."""
from __future__ import annotations
import base64
import logging
import re
from urllib.parse import urlencode, urlparse, parse_qs
from lxml import html as lxml_html
from app.models import WebResult, ImageResult
from app.engines.base import SearchEngine, get_http_client
logger = logging.getLogger(__name__)
BASE_URL = "https://www.bing.com"
class BingEngine(SearchEngine):
name = "bing"
display_name = "Bing"
supports_web = True
supports_images = True
async def search_web(self, query: str, page: int = 1) -> list[WebResult]:
offset = (page - 1) * 10 + 1
params: dict = {"q": query, "pq": query}
if page > 1:
params["first"] = offset
params["FORM"] = "PERE" if page == 2 else f"PERE{page - 2}"
client = get_http_client()
resp = await client.get(
f"{BASE_URL}/search?{urlencode(params)}",
cookies={
"_EDGE_CD": "m=en-us&u=en",
"_EDGE_S": "mkt=en-us&ui=en",
},
)
resp.raise_for_status()
results: list[WebResult] = []
dom = lxml_html.fromstring(resp.text)
for el in dom.xpath('//ol[@id="b_results"]/li[contains(@class, "b_algo")]'):
link_els = el.xpath(".//h2/a")
if not link_els:
continue
link = link_els[0]
url = link.get("href", "")
title = link.text_content().strip()
# Decode Bing redirect URLs
if url.startswith("https://www.bing.com/ck/a?"):
try:
parsed_qs = parse_qs(urlparse(url).query)
encoded = parsed_qs["u"][0][2:]
encoded += "=" * (-len(encoded) % 4)
url = base64.urlsafe_b64decode(encoded).decode()
except Exception:
pass
content_els = el.xpath(".//p")
# Remove algoSlug_icon spans
for p in content_els:
for span in p.xpath('.//span[@class="algoSlug_icon"]'):
span.getparent().remove(span)
content = " ".join(p.text_content().strip() for p in content_els)
results.append(WebResult(title=title, url=url, content=content, engine=self.name))
return results
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
offset = (page - 1) * 35
params = {"q": query, "form": "HDRSC2", "first": offset}
client = get_http_client()
resp = await client.get(
f"{BASE_URL}/images/search?{urlencode(params)}",
cookies={
"_EDGE_CD": "m=en-us&u=en",
"_EDGE_S": "mkt=en-us&ui=en",
},
)
resp.raise_for_status()
results: list[ImageResult] = []
dom = lxml_html.fromstring(resp.text)
for el in dom.xpath('//a[contains(@class, "iusc")]'):
m_attr = el.get("m", "")
if not m_attr:
continue
import json
try:
m_data = json.loads(m_attr)
results.append(ImageResult(
title=m_data.get("t", ""),
url=m_data.get("purl", ""),
img_src=m_data.get("murl", ""),
thumbnail_src=m_data.get("turl", ""),
source="Bing",
engine=self.name,
))
except (json.JSONDecodeError, KeyError):
continue
return results
+95
View File
@@ -0,0 +1,95 @@
"""Brave Search engine implementation."""
from __future__ import annotations
import logging
from urllib.parse import urlencode
from lxml import html as lxml_html
from app.models import WebResult, ImageResult
from app.engines.base import SearchEngine, get_http_client
logger = logging.getLogger(__name__)
BASE_URL = "https://search.brave.com"
class BraveEngine(SearchEngine):
name = "brave"
display_name = "Brave"
supports_web = True
supports_images = True
async def search_web(self, query: str, page: int = 1) -> list[WebResult]:
args = {"q": query, "source": "web"}
if page > 1:
args["offset"] = page - 1
client = get_http_client()
resp = await client.get(
f"{BASE_URL}/search?{urlencode(args)}",
headers={"Accept-Encoding": "gzip, deflate"},
cookies={"safesearch": "moderate", "useLocation": "0", "country": "all"},
)
resp.raise_for_status()
results: list[WebResult] = []
dom = lxml_html.fromstring(resp.text)
for el in dom.xpath("//div[contains(@class, 'snippet ')]"):
url_els = el.xpath(".//a/@href")
title_els = el.xpath(".//div[contains(@class, 'title')]")
if not url_els or not title_els:
continue
url = url_els[0]
if not url.startswith("http"):
continue
title = title_els[0].text_content().strip()
content_els = el.xpath(".//div[contains(concat(' ', @class, ' '), ' content ')]")
content = content_els[0].text_content().strip() if content_els else ""
results.append(WebResult(title=title, url=url, content=content, engine=self.name))
return results
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
args = {"q": query, "source": "web"}
client = get_http_client()
resp = await client.get(
f"{BASE_URL}/images?{urlencode(args)}",
headers={"Accept-Encoding": "gzip, deflate"},
cookies={"safesearch": "moderate", "useLocation": "0", "country": "all"},
)
resp.raise_for_status()
results: list[ImageResult] = []
dom = lxml_html.fromstring(resp.text)
for el in dom.xpath("//div[contains(@class, 'img-card')]"):
img_els = el.xpath(".//img/@src")
link_els = el.xpath(".//a/@href")
title_els = el.xpath(".//span[contains(@class, 'title')]")
if not img_els or not link_els:
continue
results.append(ImageResult(
title=title_els[0].text_content().strip() if title_els else "",
url=link_els[0],
img_src=img_els[0],
thumbnail_src=img_els[0],
source="Brave",
engine=self.name,
))
return results
async def autocomplete(self, query: str) -> list[str]:
client = get_http_client()
resp = await client.get(
f"https://search.brave.com/api/suggest?{urlencode({'q': query})}",
cookies={"country": "all"},
)
if resp.is_success:
data = resp.json()
if isinstance(data, list) and len(data) > 1:
return data[1][:10]
return []
+114
View File
@@ -0,0 +1,114 @@
"""DuckDuckGo Search engine implementation."""
from __future__ import annotations
import json
import logging
from urllib.parse import urlencode
from lxml import html as lxml_html
from app.models import WebResult, ImageResult
from app.engines.base import SearchEngine, get_http_client
logger = logging.getLogger(__name__)
DDG_HTML_URL = "https://html.duckduckgo.com/html/"
DDG_LITE_URL = "https://lite.duckduckgo.com/lite/"
class DuckDuckGoEngine(SearchEngine):
name = "duckduckgo"
display_name = "DuckDuckGo"
supports_web = True
supports_images = True
async def search_web(self, query: str, page: int = 1) -> list[WebResult]:
data = {"q": query, "b": "", "kl": "wt-wt"}
client = get_http_client()
resp = await client.post(
DDG_HTML_URL,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Referer": DDG_HTML_URL,
},
)
resp.raise_for_status()
results: list[WebResult] = []
dom = lxml_html.fromstring(resp.text)
for div in dom.xpath('//div[@id="links"]/div[contains(@class, "web-result")]'):
title_els = div.xpath(".//h2/a")
if not title_els:
continue
title = title_els[0].text_content().strip()
url_els = div.xpath(".//h2/a/@href")
if not url_els:
continue
url = url_els[0]
content_els = div.xpath('.//a[contains(@class, "result__snippet")]')
content = content_els[0].text_content().strip() if content_els else ""
results.append(WebResult(title=title, url=url, content=content, engine=self.name))
return results
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
"""Search images via DDG's i.js API (requires vqd token)."""
client = get_http_client()
# First get a vqd token from the HTML page
token_resp = await client.get(
f"https://duckduckgo.com/?{urlencode({'q': query, 'iax': 'images', 'ia': 'images'})}",
)
token_resp.raise_for_status()
vqd = ""
for line in token_resp.text.split("\n"):
if "vqd=" in line or "vqd'" in line or 'vqd"' in line:
# Try to extract vqd value
import re
m = re.search(r"vqd=['\"]?([^&'\"]+)", line)
if m:
vqd = m.group(1)
break
if not vqd:
# Fallback: try extracting from form
dom = lxml_html.fromstring(token_resp.text)
vqd_els = dom.xpath('//input[@name="vqd"]/@value')
if vqd_els:
vqd = vqd_els[0]
results: list[ImageResult] = []
if not vqd:
return results
img_resp = await client.get(
f"https://duckduckgo.com/i.js?{urlencode({'q': query, 'vqd': vqd, 'o': 'json'})}",
headers={"Referer": "https://duckduckgo.com/"},
)
if img_resp.is_success:
data = img_resp.json()
for item in data.get("results", [])[:20]:
results.append(ImageResult(
title=item.get("title", ""),
url=item.get("url", ""),
img_src=item.get("image", ""),
thumbnail_src=item.get("thumbnail", ""),
source=item.get("source", ""),
engine=self.name,
))
return results
async def autocomplete(self, query: str) -> list[str]:
client = get_http_client()
resp = await client.get(
f"https://duckduckgo.com/ac/?{urlencode({'q': query, 'type': 'list'})}",
)
if resp.is_success:
data = resp.json()
if isinstance(data, list) and len(data) > 1:
return data[1][:10]
return []
+121
View File
@@ -0,0 +1,121 @@
"""Google Search engine implementation."""
from __future__ import annotations
import json
import logging
from urllib.parse import urlencode, unquote
from lxml import html as lxml_html
from app.models import WebResult, ImageResult
from app.engines.base import SearchEngine, get_http_client
logger = logging.getLogger(__name__)
class GoogleEngine(SearchEngine):
name = "google"
display_name = "Google"
supports_web = True
supports_images = True
async def search_web(self, query: str, page: int = 1) -> list[WebResult]:
start = (page - 1) * 10
params = {
"q": query,
"hl": "en",
"lr": "",
"ie": "utf8",
"oe": "utf8",
"start": start,
"filter": "0",
}
client = get_http_client()
resp = await client.get(
f"https://www.google.com/search?{urlencode(params)}",
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
cookies={"CONSENT": "YES+"},
)
resp.raise_for_status()
results: list[WebResult] = []
dom = lxml_html.fromstring(resp.text)
for result in dom.xpath('.//div[contains(@class, "g")]'):
link_els = result.xpath('.//a/@href')
title_els = result.xpath('.//h3')
if not link_els or not title_els:
continue
raw_url = link_els[0]
# Clean google redirect URLs
if raw_url.startswith("/url?"):
from urllib.parse import parse_qs, urlparse
parsed = parse_qs(urlparse(raw_url).query)
raw_url = parsed.get("q", [raw_url])[0]
if not raw_url.startswith("http"):
continue
title = title_els[0].text_content().strip()
content_els = result.xpath('.//div[contains(@class, "VwiC3b")]')
content = content_els[0].text_content().strip() if content_els else ""
results.append(WebResult(title=title, url=raw_url, content=content, engine=self.name))
return results
async def search_images(self, query: str, page: int = 1) -> list[ImageResult]:
params = {
"q": query,
"tbm": "isch",
"hl": "en",
"ie": "utf8",
"oe": "utf8",
}
client = get_http_client()
resp = await client.get(
f"https://www.google.com/search?{urlencode(params)}",
cookies={"CONSENT": "YES+"},
)
resp.raise_for_status()
results: list[ImageResult] = []
dom = lxml_html.fromstring(resp.text)
for el in dom.xpath('//div[contains(@class, "isv-r")]'):
link_els = el.xpath('.//a/@href')
img_els = el.xpath('.//img/@src') or el.xpath('.//img/@data-src')
if not link_els or not img_els:
continue
title_els = el.xpath('.//img/@alt')
results.append(ImageResult(
title=title_els[0] if title_els else "",
url=link_els[0],
img_src=img_els[0],
thumbnail_src=img_els[0],
source="Google",
engine=self.name,
))
return results
async def autocomplete(self, query: str) -> list[str]:
client = get_http_client()
resp = await client.get(
f"https://www.google.com/complete/search?{urlencode({'q': query, 'client': 'gws-wiz', 'hl': 'en'})}",
)
if resp.is_success:
text = resp.text
try:
json_txt = text[text.index("["):text.rindex("]", -5) + 1]
data = json.loads(json_txt)
suggestions = []
for item in data[0]:
raw = item[0] if isinstance(item, list) else str(item)
# Strip HTML tags from suggestions
clean = lxml_html.fromstring(raw).text_content()
suggestions.append(clean)
return suggestions[:10]
except (ValueError, json.JSONDecodeError, IndexError):
pass
return []
+63
View File
@@ -0,0 +1,63 @@
"""Engine registry - manages available search engines."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from app.models import EngineInfo
if TYPE_CHECKING:
from app.engines.base import SearchEngine
logger = logging.getLogger(__name__)
_engines: dict[str, "SearchEngine"] = {}
_enabled: dict[str, bool] = {}
def load_default_engines() -> None:
"""Register all built-in engines."""
from app.engines.brave import BraveEngine
from app.engines.duckduckgo import DuckDuckGoEngine
from app.engines.google import GoogleEngine
from app.engines.bing import BingEngine
for engine_cls in [BraveEngine, DuckDuckGoEngine, GoogleEngine, BingEngine]:
engine = engine_cls()
_engines[engine.name] = engine
_enabled[engine.name] = True
logger.info("Loaded %d engines: %s", len(_engines), list(_engines.keys()))
def get_engine(name: str) -> "SearchEngine | None":
return _engines.get(name)
def get_enabled_engines() -> list["SearchEngine"]:
return [e for e in _engines.values() if _enabled.get(e.name, True)]
def get_all_engine_info() -> list[EngineInfo]:
return [
EngineInfo(
name=e.name,
display_name=e.display_name,
enabled=_enabled.get(e.name, True),
supports_web=e.supports_web,
supports_images=e.supports_images,
)
for e in _engines.values()
]
def set_engine_enabled(name: str, enabled: bool) -> bool:
if name not in _engines:
return False
_enabled[name] = enabled
return True
def is_engine_enabled(name: str) -> bool:
return _enabled.get(name, False)
+41
View File
@@ -0,0 +1,41 @@
"""Hey Search - A metasearch engine."""
import os
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.routes import router
from app.engines import registry
@asynccontextmanager
async def lifespan(application: FastAPI):
registry.load_default_engines()
yield
app = FastAPI(
title="Hey Search",
description="A privacy-respecting metasearch engine",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router, prefix="/api")
# Serve frontend static files if they exist (production / Docker)
static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
+48
View File
@@ -0,0 +1,48 @@
"""Result models for search engines."""
from __future__ import annotations
from pydantic import BaseModel, Field
class WebResult(BaseModel):
"""A single web search result."""
title: str
url: str
content: str = ""
engine: str = ""
class ImageResult(BaseModel):
"""A single image search result."""
title: str
url: str
img_src: str
thumbnail_src: str = ""
source: str = ""
engine: str = ""
class EngineError(BaseModel):
"""An error from a search engine."""
engine: str
message: str
is_timeout: bool = False
class SearchResponse(BaseModel):
"""Aggregated search response from all engines."""
query: str
category: str = "web"
results: list[WebResult | ImageResult] = Field(default_factory=list)
errors: list[EngineError] = Field(default_factory=list)
suggestions: list[str] = Field(default_factory=list)
class EngineInfo(BaseModel):
"""Engine metadata for management."""
name: str
display_name: str
enabled: bool = True
supports_web: bool = True
supports_images: bool = True
+122
View File
@@ -0,0 +1,122 @@
"""Search orchestrator - coordinates searches across multiple engines concurrently."""
from __future__ import annotations
import asyncio
import logging
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
from app.models import WebResult, ImageResult, EngineError, SearchResponse
from app.engines.base import SearchEngine, SearchCategory
from app.engines import registry
logger = logging.getLogger(__name__)
# Retry config: 2 retries with exponential backoff (0.5s, 1s)
RETRY_DECORATOR = retry(
stop=stop_after_attempt(2),
wait=wait_exponential(multiplier=0.5, min=0.5, max=2),
retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.ConnectError, httpx.ReadTimeout)),
reraise=True,
)
async def _search_single_engine(
engine: SearchEngine,
query: str,
category: SearchCategory,
page: int,
) -> tuple[list[WebResult] | list[ImageResult], EngineError | None]:
"""Search a single engine with retry logic."""
@RETRY_DECORATOR
async def _do_search():
if category == "images":
return await engine.search_images(query, page)
return await engine.search_web(query, page)
try:
results = await _do_search()
return results, None
except asyncio.TimeoutError:
logger.warning("Engine %s timed out for query '%s'", engine.name, query)
return [], EngineError(engine=engine.name, message="Search timed out", is_timeout=True)
except httpx.HTTPStatusError as e:
logger.warning("Engine %s HTTP error: %s", engine.name, e)
return [], EngineError(engine=engine.name, message=f"HTTP {e.response.status_code}")
except Exception as e:
logger.warning("Engine %s error: %s", engine.name, e)
return [], EngineError(engine=engine.name, message=str(e))
async def search(
query: str,
category: SearchCategory = "web",
page: int = 1,
engines: list[str] | None = None,
) -> SearchResponse:
"""Search across all enabled engines concurrently."""
enabled_engines = registry.get_enabled_engines()
if engines:
enabled_engines = [e for e in enabled_engines if e.name in engines]
# Filter engines by category support
if category == "images":
enabled_engines = [e for e in enabled_engines if e.supports_images]
else:
enabled_engines = [e for e in enabled_engines if e.supports_web]
if not enabled_engines:
return SearchResponse(query=query, category=category)
# Run all engine searches concurrently
tasks = [
_search_single_engine(engine, query, category, page)
for engine in enabled_engines
]
results_list = await asyncio.gather(*tasks)
# Aggregate results
all_results: list[WebResult | ImageResult] = []
all_errors: list[EngineError] = []
for engine_results, error in results_list:
all_results.extend(engine_results)
if error:
all_errors.append(error)
# Deduplicate by URL
seen_urls: set[str] = set()
unique_results: list[WebResult | ImageResult] = []
for r in all_results:
if r.url not in seen_urls:
seen_urls.add(r.url)
unique_results.append(r)
return SearchResponse(
query=query,
category=category,
results=unique_results,
errors=all_errors,
)
async def get_autocomplete(query: str) -> list[str]:
"""Get autocomplete suggestions from enabled engines."""
enabled = registry.get_enabled_engines()
# Try Google first, then DuckDuckGo, then Brave
priority = ["google", "duckduckgo", "brave"]
for name in priority:
engine = registry.get_engine(name)
if engine and registry.is_engine_enabled(name):
try:
suggestions = await engine.autocomplete(query)
if suggestions:
return suggestions
except Exception as e:
logger.warning("Autocomplete from %s failed: %s", name, e)
continue
return []
+7
View File
@@ -0,0 +1,7 @@
fastapi
uvicorn
httpx
pydantic
tenacity
lxml
beautifulsoup4
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hey Search</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3937
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+156
View File
@@ -0,0 +1,156 @@
import { useState, useCallback } from "react";
import { Search, Settings, Globe, ImageIcon, Loader2 } from "lucide-react";
import { SearchBar } from "@/components/SearchBar";
import { WebResults } from "@/components/WebResults";
import { ImageResults } from "@/components/ImageResults";
import { EngineSettings } from "@/components/EngineSettings";
import { ErrorToast } from "@/components/ErrorToast";
import { search as apiSearch, isImageResult, type SearchResponse, type WebResult, type ImageResult } from "@/lib/api";
import { cn } from "@/lib/utils";
type Category = "web" | "images";
function App() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState<Category>("web");
const [response, setResponse] = useState<SearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const doSearch = useCallback(
async (q: string, cat: Category = category) => {
if (!q.trim()) return;
setQuery(q);
setLoading(true);
setHasSearched(true);
try {
const res = await apiSearch(q, cat);
setResponse(res);
} catch (err) {
setResponse({
query: q,
category: cat,
results: [],
errors: [{ engine: "system", message: String(err), is_timeout: false }],
suggestions: [],
});
} finally {
setLoading(false);
}
},
[category]
);
const handleCategoryChange = (cat: Category) => {
setCategory(cat);
if (query) doSearch(query, cat);
};
const webResults = response?.results.filter((r): r is WebResult => !isImageResult(r)) ?? [];
const imageResults = response?.results.filter((r): r is ImageResult => isImageResult(r)) ?? [];
// Home page (no search yet)
if (!hasSearched) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
Hey Search
</span>
</h1>
<p className="mt-2 text-muted-foreground">Private metasearch engine</p>
</div>
<SearchBar onSearch={(q) => doSearch(q)} className="w-full" />
<div className="mt-6 flex gap-3">
<button
onClick={() => setShowSettings(true)}
className="flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
>
<Settings className="h-4 w-4" /> Engines
</button>
</div>
<EngineSettings open={showSettings} onClose={() => setShowSettings(false)} />
</div>
);
}
// Results page
return (
<div className="min-h-screen">
{/* 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={() => { setHasSearched(false); setResponse(null); }}
className="shrink-0 text-xl font-bold"
>
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
HS
</span>
</button>
<SearchBar initialQuery={query} onSearch={(q) => doSearch(q)} className="flex-1" />
<button
onClick={() => setShowSettings(true)}
className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-accent"
>
<Settings className="h-5 w-5" />
</button>
</div>
{/* Category tabs */}
<div className="flex gap-1 px-4 pb-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={() => handleCategoryChange(key)}
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
category === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent"
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
</header>
{/* Content */}
<main className="mx-auto max-w-5xl px-4 py-6">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : response && response.results.length === 0 && response.errors.length === 0 ? (
<div className="py-20 text-center">
<Search className="mx-auto h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-lg text-muted-foreground">No results found for "{query}"</p>
</div>
) : (
<>
{category === "web" && <WebResults results={webResults} />}
{category === "images" && <ImageResults results={imageResults} />}
</>
)}
</main>
{/* Error toasts */}
{response?.errors && <ErrorToast errors={response.errors} />}
{/* Settings modal */}
<EngineSettings open={showSettings} onClose={() => setShowSettings(false)} />
</div>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,82 @@
import { useState, useEffect } from "react";
import { Settings, ToggleLeft, ToggleRight } from "lucide-react";
import { getEngines, toggleEngine, type EngineInfo } from "@/lib/api";
interface EngineSettingsProps {
open: boolean;
onClose: () => void;
}
export function EngineSettings({ open, onClose }: EngineSettingsProps) {
const [engines, setEngines] = useState<EngineInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (open) {
getEngines().then(setEngines).finally(() => setLoading(false));
}
}, [open]);
const handleToggle = async (name: string, enabled: boolean) => {
try {
const updated = await toggleEngine(name, enabled);
setEngines((prev) => prev.map((e) => (e.name === updated.name ? updated : e)));
} catch (err) {
console.error("Failed to toggle engine:", err);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center" onClick={onClose}>
<div className="fixed inset-0 bg-black/50" />
<div
className="relative z-10 w-full max-w-md rounded-t-2xl bg-card p-6 shadow-2xl sm:rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Settings className="h-5 w-5" />
Search Engines
</h2>
<button onClick={onClose} className="text-sm text-muted-foreground hover:text-foreground">
Done
</button>
</div>
{loading ? (
<p className="py-4 text-center text-muted-foreground">Loading...</p>
) : (
<div className="space-y-3">
{engines.map((engine) => (
<div
key={engine.name}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{engine.display_name}</p>
<p className="text-xs text-muted-foreground">
{[engine.supports_web && "Web", engine.supports_images && "Images"]
.filter(Boolean)
.join(" • ")}
</p>
</div>
<button
onClick={() => handleToggle(engine.name, !engine.enabled)}
className="text-foreground"
>
{engine.enabled ? (
<ToggleRight className="h-8 w-8 text-green-500" />
) : (
<ToggleLeft className="h-8 w-8 text-muted-foreground" />
)}
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import type { EngineError } from "@/lib/api";
interface ErrorToastProps {
errors: EngineError[];
}
export function ErrorToast({ errors }: ErrorToastProps) {
const [visible, setVisible] = useState(true);
useEffect(() => {
setVisible(true);
const timer = setTimeout(() => setVisible(false), 8000);
return () => clearTimeout(timer);
}, [errors]);
if (!errors.length || !visible) return null;
return (
<div className="fixed bottom-4 right-4 z-50 max-w-sm space-y-2">
{errors.map((err, i) => (
<div
key={`${err.engine}-${i}`}
className="flex items-start gap-3 rounded-lg border border-destructive/50 bg-card p-3 shadow-lg animate-in slide-in-from-bottom-5"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="flex-1 text-sm">
<p className="font-medium">{err.engine} failed</p>
<p className="text-muted-foreground">{err.message}</p>
</div>
<button onClick={() => setVisible(false)} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useState } from "react";
import type { ImageResult } from "@/lib/api";
import { X } from "lucide-react";
interface ImageResultsProps {
results: ImageResult[];
}
export function ImageResults({ results }: ImageResultsProps) {
const [selected, setSelected] = useState<ImageResult | null>(null);
if (results.length === 0) return null;
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{results.map((img, i) => (
<button
key={`${img.img_src}-${i}`}
onClick={() => setSelected(img)}
className="group relative aspect-square overflow-hidden rounded-lg border bg-muted hover:ring-2 hover:ring-ring"
>
<img
src={img.thumbnail_src || img.img_src}
alt={img.title}
loading="lazy"
className="h-full w-full object-cover transition-transform group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23eee' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='%23999' font-size='12'%3ENo image%3C/text%3E%3C/svg%3E";
}}
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-xs text-white">{img.title}</p>
</div>
</button>
))}
</div>
{/* Lightbox */}
{selected && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setSelected(null)}
>
<div
className="relative max-h-[90vh] max-w-4xl overflow-auto rounded-lg bg-card p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setSelected(null)}
className="absolute right-2 top-2 rounded-full bg-background/80 p-1.5 hover:bg-background"
>
<X className="h-5 w-5" />
</button>
<img
src={selected.img_src}
alt={selected.title}
className="max-h-[70vh] w-auto rounded object-contain"
/>
<div className="mt-3">
<h3 className="font-medium">{selected.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
Source: {selected.source} Engine: {selected.engine}
</p>
<a
href={selected.url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-sm text-blue-600 hover:underline dark:text-blue-400"
>
Visit page
</a>
</div>
</div>
</div>
)}
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useState, useRef, useEffect, type FormEvent, type KeyboardEvent } from "react";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAutocomplete } from "@/hooks/useAutocomplete";
interface SearchBarProps {
initialQuery?: string;
onSearch: (query: string) => void;
className?: string;
}
export function SearchBar({ initialQuery = "", onSearch, className }: SearchBarProps) {
const [query, setQuery] = useState(initialQuery);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const { suggestions } = useAutocomplete(query, showSuggestions);
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim()) {
setShowSuggestions(false);
onSearch(query.trim());
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, -1));
} else if (e.key === "Enter" && selectedIndex >= 0) {
e.preventDefault();
const selected = suggestions[selectedIndex];
setQuery(selected);
setShowSuggestions(false);
onSearch(selected);
} else if (e.key === "Escape") {
setShowSuggestions(false);
}
};
const selectSuggestion = (s: string) => {
setQuery(s);
setShowSuggestions(false);
onSearch(s);
};
return (
<form onSubmit={handleSubmit} className={cn("relative w-full max-w-2xl", className)}>
<div className="relative flex items-center">
<Search className="absolute left-3 h-5 w-5 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setShowSuggestions(true);
setSelectedIndex(-1);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onKeyDown={handleKeyDown}
placeholder="Search the web..."
className="w-full rounded-full border border-input bg-background px-10 py-3 text-base shadow-sm
placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring
sm:text-lg"
/>
{query && (
<button
type="button"
onClick={() => { setQuery(""); inputRef.current?.focus(); }}
className="absolute right-14 p-1 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
<button
type="submit"
className="absolute right-3 rounded-full bg-primary p-1.5 text-primary-foreground hover:opacity-80"
>
<Search className="h-4 w-4" />
</button>
</div>
{showSuggestions && suggestions.length > 0 && (
<ul className="absolute z-50 mt-1 w-full rounded-lg border bg-popover shadow-lg">
{suggestions.map((s, i) => (
<li
key={s}
onMouseDown={() => selectSuggestion(s)}
className={cn(
"cursor-pointer px-4 py-2 text-sm hover:bg-accent",
i === selectedIndex && "bg-accent"
)}
>
<Search className="mr-2 inline h-3.5 w-3.5 text-muted-foreground" />
{s}
</li>
))}
</ul>
)}
</form>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { WebResult } from "@/lib/api";
import { ExternalLink } from "lucide-react";
interface WebResultsProps {
results: WebResult[];
}
export function WebResults({ results }: WebResultsProps) {
if (results.length === 0) return null;
return (
<div className="space-y-6">
{results.map((result, i) => (
<article key={`${result.url}-${i}`} className="group max-w-2xl">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<img
src={`https://www.google.com/s2/favicons?domain=${new URL(result.url).hostname}&sz=16`}
alt=""
className="h-4 w-4 rounded-sm"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<span className="truncate">{new URL(result.url).hostname}</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">{result.engine}</span>
</div>
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block text-lg font-medium text-blue-600 hover:underline dark:text-blue-400 sm:text-xl"
>
{result.title}
<ExternalLink className="mb-1 ml-1 inline h-3.5 w-3.5 opacity-0 group-hover:opacity-100" />
</a>
{result.content && (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground line-clamp-3">
{result.content}
</p>
)}
</article>
))}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { autocomplete } from "@/lib/api";
export function useAutocomplete(query: string, enabled: boolean = true) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const fetchSuggestions = useCallback(
async (q: string) => {
if (!q || q.length < 2 || !enabled) {
setSuggestions([]);
return;
}
setLoading(true);
try {
const results = await autocomplete(q);
setSuggestions(results);
} catch {
setSuggestions([]);
} finally {
setLoading(false);
}
},
[enabled]
);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchSuggestions(query), 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query, fetchSuggestions]);
return { suggestions, loading };
}
+84
View File
@@ -0,0 +1,84 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
}
+86
View File
@@ -0,0 +1,86 @@
const API_BASE = "/api";
export interface WebResult {
title: string;
url: string;
content: string;
engine: string;
}
export interface ImageResult {
title: string;
url: string;
img_src: string;
thumbnail_src: string;
source: string;
engine: string;
}
export interface EngineError {
engine: string;
message: string;
is_timeout: boolean;
}
export interface SearchResponse {
query: string;
category: string;
results: (WebResult | ImageResult)[];
errors: EngineError[];
suggestions: string[];
}
export interface EngineInfo {
name: string;
display_name: string;
enabled: boolean;
supports_web: boolean;
supports_images: boolean;
}
export interface AutocompleteResponse {
query: string;
suggestions: string[];
}
export async function search(
query: string,
category: "web" | "images" = "web",
page: number = 1
): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query, category, page: String(page) });
const resp = await fetch(`${API_BASE}/search?${params}`);
if (!resp.ok) throw new Error(`Search failed: ${resp.status}`);
return resp.json();
}
export async function autocomplete(query: string): Promise<string[]> {
const params = new URLSearchParams({ q: query });
const resp = await fetch(`${API_BASE}/autocomplete?${params}`);
if (!resp.ok) return [];
const data: AutocompleteResponse = await resp.json();
return data.suggestions;
}
export async function getEngines(): Promise<EngineInfo[]> {
const resp = await fetch(`${API_BASE}/engines`);
if (!resp.ok) throw new Error("Failed to fetch engines");
return resp.json();
}
export async function toggleEngine(
name: string,
enabled: boolean
): Promise<EngineInfo> {
const resp = await fetch(`${API_BASE}/engines/${name}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!resp.ok) throw new Error("Failed to toggle engine");
return resp.json();
}
export function isImageResult(r: WebResult | ImageResult): r is ImageResult {
return "img_src" in r;
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})