Author SHA1 Message Date
junv 102f89a4bb Update ui for stats page 2026-03-07 11:49:54 +11:00
junv 91499c5133 Update readme about how to use curl to search 2026-03-07 11:47:20 +11:00
junv fe3b27ff86 Update menu style 2026-03-07 11:41:33 +11:00
junv d7a3c8549d Update readme 2026-03-07 11:28:26 +11:00
junvandGitHub 49e35a6b53 Merge pull request #3 from wahyd4/feat/update-guide
Showing app version at the bottom
2026-03-07 11:25:13 +11:00
4 changed files with 111 additions and 100 deletions
+28 -3
View File
@@ -53,13 +53,12 @@ just dev
REDIS_URL=redis://192.168.1.2:6399 just dev
```
### 2. Docker (production)
### 2. Docker
```bash
docker build -t hey-search .
# Basic — data stored in anonymous volume
docker run -p 8000:8000 hey-search
docker run -p 8000:8000 ghcr.io/wahyd4/hey-search
# Recommended — mount data directory for persistence
docker run -p 8000:8000 -v ./hey-search-data:/app/data hey-search
@@ -99,6 +98,32 @@ The `/app/data` volume stores the SQLite database (engine settings, excluded dom
| `image_size` | — | `large`, `medium`, or `small` (images only) |
| `sort` | `default`| `default`, `date_asc`, or `date_desc` |
## Using with AI Agents / curl
The `/api/search` endpoint returns clean JSON — ideal for LLMs and AI agents to consume directly.
```bash
# Web search
curl "http://localhost:8000/api/search?q=python+async&format=json" | jq
# Restrict to specific engines
curl "http://localhost:8000/api/search?q=rust+programming&engines=brave,google" | jq
# Image search
curl "http://localhost:8000/api/search?q=mountain+landscape&category=images&image_size=large" | jq
# Paginate results
curl "http://localhost:8000/api/search?q=machine+learning&page=2" | jq
# Extract just titles and URLs from web results
curl "http://localhost:8000/api/search?q=openai" | \
jq '[.results[] | {title, url, snippet}]'
```
> Interactive API docs (Swagger UI) are available at `http://localhost:8000/docs`.
## Features
See [FEATURES.md](FEATURES.md) for the full feature list.
+7 -1
View File
@@ -389,7 +389,13 @@ function App() {
// Stats page
if (showStats) {
return <>
<StatsPage onGoHome={handleGoHome} />
<StatsPage
onGoHome={handleGoHome}
onShowSettings={() => setShowSettings(true)}
onShowBookmarks={handleShowBookmarks}
onShowGallery={handleShowGallery}
onShowHistory={handleShowHistory}
/>
{settingsModal}
</>;
}
+56 -81
View File
@@ -17,8 +17,6 @@ interface AppHeaderProps {
children?: React.ReactNode;
}
// Items ordered left→right. Rightmost item (Settings) gets the shortest delay,
// so it appears first creating the right-to-left unfold effect.
const NAV_ITEMS = [
{ id: "docs", icon: ExternalLink, label: "API Docs", href: "/docs" },
{ id: "stats", icon: BarChart2, label: "Stats", href: null },
@@ -28,8 +26,6 @@ const NAV_ITEMS = [
{ id: "settings", icon: Settings, label: "Settings", href: null },
] as const;
const STAGGER_MS = 60;
export function AppHeader({
onGoHome,
onShowSettings,
@@ -47,11 +43,11 @@ export function AppHeader({
const close = () => setMenuOpen(false);
const actions: Record<string, () => void> = {
settings: () => { onShowSettings(); close(); },
bookmarks: () => { onShowBookmarks(); close(); },
gallery: () => { onShowGallery(); close(); },
stats: () => { onShowStats(); close(); },
history: () => { onShowHistory?.(); close(); },
settings: () => { onShowSettings(); close(); },
bookmarks: () => { onShowBookmarks(); close(); },
gallery: () => { onShowGallery(); close(); },
stats: () => { onShowStats(); close(); },
history: () => { onShowHistory?.(); close(); },
docs: close,
};
@@ -84,12 +80,10 @@ export function AppHeader({
: "text-muted-foreground hover:bg-accent"
);
const itemBase = cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium whitespace-nowrap",
"transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
transparent
? "text-white/80 hover:text-white hover:bg-white/10"
: "text-muted-foreground hover:bg-accent"
const dropdownItemBase = cn(
"flex w-full items-center gap-3 px-4 py-3 text-sm font-medium transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
"text-foreground hover:bg-accent"
);
return (
@@ -117,72 +111,8 @@ export function AppHeader({
: <div className="flex-1" />
}
{/* Right side: animated nav items + toggle button */}
<div ref={menuRef} className="flex items-center gap-1 shrink-0">
{/* Nav items — animate right→left on open */}
{NAV_ITEMS.map((item, i) => {
// Rightmost item (Settings, index 3) gets 0ms delay → appears first
const delay = menuOpen
? (NAV_ITEMS.length - 1 - i) * STAGGER_MS
: 0;
// Outer wrapper collapses to width 0 when closed so it doesn't
// squeeze sibling content (e.g. the search bar on the results page).
const wrapperStyle: React.CSSProperties = {
maxWidth: menuOpen ? "120px" : "0px",
overflow: "hidden",
transition: `max-width 180ms cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms`,
};
const innerStyle: React.CSSProperties = {
transitionProperty: "opacity, transform",
transitionDuration: "180ms",
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
transitionDelay: `${delay}ms`,
opacity: menuOpen ? 1 : 0,
transform: menuOpen ? "translateX(0)" : "translateX(16px)",
pointerEvents: menuOpen ? "auto" : "none",
};
const Icon = item.icon;
if (item.href) {
return (
<div key={item.id} style={wrapperStyle}>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
aria-label={item.label}
title={item.label}
className={itemBase}
style={innerStyle}
onClick={close}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">{item.label}</span>
</a>
</div>
);
}
return (
<div key={item.id} style={wrapperStyle}>
<button
onClick={actions[item.id]}
aria-label={item.label}
title={item.label}
className={itemBase}
style={innerStyle}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">{item.label}</span>
</button>
</div>
);
})}
{/* Toggle button */}
{/* Menu toggle */}
<div ref={menuRef} className="relative shrink-0">
<button
onClick={() => setMenuOpen((v) => !v)}
aria-label={menuOpen ? "Close menu" : "Open menu"}
@@ -200,6 +130,51 @@ export function AppHeader({
}
</span>
</button>
{/* Dropdown panel */}
<div
className={cn(
"absolute right-0 top-full mt-2 w-52 rounded-xl border shadow-lg",
"bg-background/95 backdrop-blur",
"overflow-hidden transition-all duration-200 origin-top-right",
menuOpen
? "opacity-100 scale-100 pointer-events-auto"
: "opacity-0 scale-95 pointer-events-none"
)}
role="menu"
aria-label="Navigation menu"
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
if (item.href) {
return (
<a
key={item.id}
href={item.href}
target="_blank"
rel="noopener noreferrer"
role="menuitem"
className={dropdownItemBase}
onClick={close}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
{item.label}
</a>
);
}
return (
<button
key={item.id}
role="menuitem"
onClick={actions[item.id]}
className={dropdownItemBase}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
{item.label}
</button>
);
})}
</div>
</div>
</div>
</header>
+20 -15
View File
@@ -1,9 +1,14 @@
import { useEffect, useState } from "react";
import { fetchStats, type StatsSummary } from "@/lib/api";
import { BarChart2, TrendingUp, MousePointerClick, Search } from "lucide-react";
import { AppHeader } from "@/components/AppHeader";
interface StatsPageProps {
onGoHome: () => void;
onShowSettings: () => void;
onShowBookmarks: () => void;
onShowGallery: () => void;
onShowHistory?: () => void;
}
function Card({ title, value, sub }: { title: string; value: string | number; sub?: string }) {
@@ -16,7 +21,7 @@ function Card({ title, value, sub }: { title: string; value: string | number; su
);
}
export function StatsPage({ onGoHome }: StatsPageProps) {
export function StatsPage({ onGoHome, onShowSettings, onShowBookmarks, onShowGallery, onShowHistory }: StatsPageProps) {
const [days, setDays] = useState(7);
const [data, setData] = useState<StatsSummary | null>(null);
const [loading, setLoading] = useState(true);
@@ -33,20 +38,20 @@ export function StatsPage({ onGoHome }: StatsPageProps) {
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur">
<div className="flex items-center gap-3 px-4 py-3">
<button
onClick={onGoHome}
className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded"
>
HS
</button>
<div className="flex items-center gap-2 text-sm font-medium">
<BarChart2 className="h-4 w-4" />
<AppHeader
onGoHome={onGoHome}
onShowSettings={onShowSettings}
onShowBookmarks={onShowBookmarks}
onShowGallery={onShowGallery}
onShowStats={() => {}}
onShowHistory={onShowHistory}
>
<div className="flex items-center gap-3 min-w-0">
<h1 className="flex items-center gap-2 text-sm font-semibold whitespace-nowrap">
<BarChart2 className="h-4 w-4 shrink-0" />
Search Analytics
</div>
<div className="ml-auto flex items-center gap-1">
</h1>
<div className="flex items-center gap-1">
{[7, 30, 90].map((d) => (
<button
key={d}
@@ -60,7 +65,7 @@ export function StatsPage({ onGoHome }: StatsPageProps) {
))}
</div>
</div>
</header>
</AppHeader>
<main className="mx-auto max-w-5xl px-4 py-8 space-y-8">
{loading && <p className="text-center text-muted-foreground py-16">Loading</p>}