mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-08 21:05:14 +10:00
Done by copilot
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.venv
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
.env
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.egg-info/
|
||||||
+28
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.main import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
@@ -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)
|
||||||
@@ -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 []
|
||||||
@@ -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
|
||||||
@@ -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 []
|
||||||
@@ -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 []
|
||||||
@@ -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 []
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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 []
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
httpx
|
||||||
|
pydantic
|
||||||
|
tenacity
|
||||||
|
lxml
|
||||||
|
beautifulsoup4
|
||||||
@@ -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?
|
||||||
@@ -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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -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>
|
||||||
Generated
+3937
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user