mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-09 05:06:23 +10:00
Add stats
This commit is contained in:
@@ -13,6 +13,7 @@ from app.engines import registry
|
||||
from app.excluded import get_excluded_domains, add_excluded_domain, remove_excluded_domain
|
||||
from app.settings import get_all_settings, get_setting, set_setting
|
||||
from app.cache import is_cache_available, flush_cache, reconnect_redis
|
||||
from app import stats as _stats
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -65,6 +66,7 @@ curl '$BASE_URL/api/search?q=cats&category=images&image_size=large'
|
||||
},
|
||||
)
|
||||
async def api_search(
|
||||
request: Request,
|
||||
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"),
|
||||
@@ -72,7 +74,18 @@ async def api_search(
|
||||
image_size: Literal["", "large", "medium", "small"] = Query("", description="Filter images by size (images category only)"),
|
||||
):
|
||||
engine_list = [e.strip() for e in engines.split(",")] if engines else None
|
||||
return await search(q, category=category, page=page, engines=engine_list, image_size=image_size)
|
||||
result = await search(q, category=category, page=page, engines=engine_list, image_size=image_size)
|
||||
origin_ip = request.client.host if request.client else ""
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
_stats.record_search(
|
||||
query=q,
|
||||
category=category,
|
||||
origin_ip=origin_ip,
|
||||
user_agent=user_agent,
|
||||
result_count=result.total_results,
|
||||
cached=result.cached,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --- Autocomplete ---
|
||||
@@ -538,3 +551,56 @@ async def api_remove_bookmark_by_url(url: str):
|
||||
if not removed:
|
||||
return JSONResponse(status_code=404, content={"code": "not_found", "message": "Bookmark not found"})
|
||||
return {"message": "Bookmark removed"}
|
||||
|
||||
|
||||
# --- Analytics / Stats ---
|
||||
|
||||
class ClickEventRequest(BaseModel):
|
||||
query: str
|
||||
category: str = "web"
|
||||
position: int = 0
|
||||
url: str
|
||||
title: str = ""
|
||||
engine: str = ""
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
period_days: int
|
||||
total_searches: int
|
||||
total_clicks: int
|
||||
top_queries: list[dict]
|
||||
top_clicked_urls: list[dict]
|
||||
top_positions: list[dict]
|
||||
engine_clicks: list[dict]
|
||||
daily_searches: list[dict]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/stats/click",
|
||||
summary="Record a result click",
|
||||
description="Called by the frontend when a user clicks a search result.",
|
||||
tags=["Stats"],
|
||||
)
|
||||
async def api_record_click(body: ClickEventRequest, request: Request):
|
||||
origin_ip = request.client.host if request.client else ""
|
||||
_stats.record_click(
|
||||
query=body.query,
|
||||
category=body.category,
|
||||
position=body.position,
|
||||
url=body.url,
|
||||
title=body.title,
|
||||
engine=body.engine,
|
||||
origin_ip=origin_ip,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=StatsResponse,
|
||||
summary="Get search analytics summary",
|
||||
description="Returns aggregated search and click statistics for the last N days.",
|
||||
tags=["Stats"],
|
||||
)
|
||||
async def api_get_stats(days: int = Query(7, ge=1, le=365)):
|
||||
return _stats.get_summary(days)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.excluded import init_db
|
||||
from app.settings import init_settings_table
|
||||
from app.cache import init_redis, close_redis
|
||||
from app.bookmarks import init_bookmarks_table
|
||||
from app.stats import init_db as init_stats_db
|
||||
from app.models import APIError
|
||||
|
||||
|
||||
@@ -26,6 +27,7 @@ async def lifespan(application: FastAPI):
|
||||
init_db()
|
||||
init_settings_table()
|
||||
init_bookmarks_table()
|
||||
init_stats_db()
|
||||
registry.load_default_engines()
|
||||
await init_redis()
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Search analytics – persisted in a local SQLite database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_DB_PATH = Path(__file__).parent.parent / "data" / "stats.db"
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
con = sqlite3.connect(str(_DB_PATH), check_same_thread=False)
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with _lock, _conn() as con:
|
||||
con.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS search_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
query TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'web',
|
||||
origin_ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
result_count INTEGER NOT NULL DEFAULT 0,
|
||||
cached INTEGER NOT NULL DEFAULT 0,
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_se_ts ON search_events(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_se_query ON search_events(query COLLATE NOCASE);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS click_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
query TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'web',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
engine TEXT NOT NULL DEFAULT '',
|
||||
origin_ip TEXT NOT NULL DEFAULT '',
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_ts ON click_events(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_query ON click_events(query COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_url ON click_events(url);
|
||||
CREATE INDEX IF NOT EXISTS idx_ce_engine ON click_events(engine);
|
||||
""")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def record_search(
|
||||
query: str,
|
||||
category: str,
|
||||
origin_ip: str,
|
||||
user_agent: str,
|
||||
result_count: int,
|
||||
cached: bool,
|
||||
) -> None:
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
with _lock, _conn() as con:
|
||||
con.execute(
|
||||
"INSERT INTO search_events (query, category, origin_ip, user_agent, result_count, cached, ts) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(query, category, origin_ip, user_agent, result_count, int(cached), ts),
|
||||
)
|
||||
|
||||
|
||||
def record_click(
|
||||
query: str,
|
||||
category: str,
|
||||
position: int,
|
||||
url: str,
|
||||
title: str,
|
||||
engine: str,
|
||||
origin_ip: str,
|
||||
) -> None:
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
with _lock, _conn() as con:
|
||||
con.execute(
|
||||
"INSERT INTO click_events (query, category, position, url, title, engine, origin_ip, ts) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(query, category, position, url, title, engine, origin_ip, ts),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_summary(days: int = 7) -> dict:
|
||||
"""Return aggregated stats for the last *days* days."""
|
||||
cutoff = f"{datetime.now(timezone.utc).date().isoformat()}T00:00:00+00:00"
|
||||
# Rough: use string comparison on ISO dates (works because format is fixed)
|
||||
import datetime as dt
|
||||
cutoff_dt = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
|
||||
cutoff = cutoff_dt.isoformat()
|
||||
|
||||
with _lock, _conn() as con:
|
||||
total_searches = con.execute(
|
||||
"SELECT COUNT(*) FROM search_events WHERE ts >= ?", (cutoff,)
|
||||
).fetchone()[0]
|
||||
|
||||
total_clicks = con.execute(
|
||||
"SELECT COUNT(*) FROM click_events WHERE ts >= ?", (cutoff,)
|
||||
).fetchone()[0]
|
||||
|
||||
top_queries = [
|
||||
{"query": r["query"], "count": r["cnt"]}
|
||||
for r in con.execute(
|
||||
"SELECT query, COUNT(*) AS cnt FROM search_events WHERE ts >= ? "
|
||||
"GROUP BY query COLLATE NOCASE ORDER BY cnt DESC LIMIT 20",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
top_clicked_urls = [
|
||||
{"url": r["url"], "title": r["title"], "engine": r["engine"], "count": r["cnt"]}
|
||||
for r in con.execute(
|
||||
"SELECT url, title, engine, COUNT(*) AS cnt FROM click_events WHERE ts >= ? "
|
||||
"GROUP BY url ORDER BY cnt DESC LIMIT 20",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
top_positions = [
|
||||
{"position": r["position"], "count": r["cnt"]}
|
||||
for r in con.execute(
|
||||
"SELECT position, COUNT(*) AS cnt FROM click_events WHERE ts >= ? "
|
||||
"GROUP BY position ORDER BY position ASC LIMIT 20",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
engine_clicks = [
|
||||
{"engine": r["engine"], "count": r["cnt"]}
|
||||
for r in con.execute(
|
||||
"SELECT engine, COUNT(*) AS cnt FROM click_events WHERE ts >= ? "
|
||||
"GROUP BY engine ORDER BY cnt DESC",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
daily_searches = [
|
||||
{"date": r["day"], "searches": r["cnt"]}
|
||||
for r in con.execute(
|
||||
"SELECT substr(ts, 1, 10) AS day, COUNT(*) AS cnt "
|
||||
"FROM search_events WHERE ts >= ? GROUP BY day ORDER BY day DESC LIMIT 30",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"period_days": days,
|
||||
"total_searches": total_searches,
|
||||
"total_clicks": total_clicks,
|
||||
"top_queries": top_queries,
|
||||
"top_clicked_urls": top_clicked_urls,
|
||||
"top_positions": top_positions,
|
||||
"engine_clicks": engine_clicks,
|
||||
"daily_searches": daily_searches,
|
||||
}
|
||||
+34
-9
@@ -9,6 +9,7 @@ import { ErrorToast } from "@/components/ErrorToast";
|
||||
import { BackgroundGallery } from "@/components/BackgroundGallery";
|
||||
import { Bookmarks } from "@/components/Bookmarks";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { StatsPage } from "@/components/StatsPage";
|
||||
import { search as apiSearch, isImageResult, getBackground, refreshBackground, getBookmarkedUrls, addBookmark, removeBookmarkByUrl, type SearchResponse, type WebResult, type ImageResult, type BackgroundInfo } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -58,6 +59,7 @@ function App() {
|
||||
const [hasSearched, setHasSearched] = useState(!!initial.q);
|
||||
const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds");
|
||||
const [showBookmarks, setShowBookmarks] = useState(window.location.pathname === "/bookmarks");
|
||||
const [showStats, setShowStats] = useState(window.location.pathname === "/stats");
|
||||
const statusRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Background image state
|
||||
@@ -74,7 +76,7 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null;
|
||||
const isHome = !hasSearched && !showGallery && !showBookmarks;
|
||||
const isHome = !hasSearched && !showGallery && !showBookmarks && !showStats;
|
||||
|
||||
// iOS Safari fills top/bottom browser areas from page background color,
|
||||
// so sample image edge colors to avoid white/black bars.
|
||||
@@ -198,17 +200,20 @@ function App() {
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
if (window.location.pathname === "/backgrounds") {
|
||||
setShowGallery(true);
|
||||
setShowBookmarks(false);
|
||||
setShowGallery(true); setShowBookmarks(false); setShowStats(false);
|
||||
return;
|
||||
}
|
||||
if (window.location.pathname === "/bookmarks") {
|
||||
setShowBookmarks(true);
|
||||
setShowGallery(false);
|
||||
setShowBookmarks(true); setShowGallery(false); setShowStats(false);
|
||||
return;
|
||||
}
|
||||
if (window.location.pathname === "/stats") {
|
||||
setShowStats(true); setShowGallery(false); setShowBookmarks(false);
|
||||
return;
|
||||
}
|
||||
setShowGallery(false);
|
||||
setShowBookmarks(false);
|
||||
setShowStats(false);
|
||||
const { q, cat, page: p, imageSize: size } = parseUrlState();
|
||||
if (q) {
|
||||
doSearch(q, cat, p, size, false);
|
||||
@@ -248,6 +253,7 @@ function App() {
|
||||
setImageSize("");
|
||||
setShowGallery(false);
|
||||
setShowBookmarks(false);
|
||||
setShowStats(false);
|
||||
window.history.pushState(null, "", "/");
|
||||
getBackground().then(setBgInfo).catch(() => {});
|
||||
};
|
||||
@@ -267,15 +273,24 @@ function App() {
|
||||
const handleShowGallery = () => {
|
||||
setShowGallery(true);
|
||||
setShowBookmarks(false);
|
||||
setShowStats(false);
|
||||
window.history.pushState(null, "", "/backgrounds");
|
||||
};
|
||||
|
||||
const handleShowBookmarks = () => {
|
||||
setShowBookmarks(true);
|
||||
setShowGallery(false);
|
||||
setShowStats(false);
|
||||
window.history.pushState(null, "", "/bookmarks");
|
||||
};
|
||||
|
||||
const handleShowStats = () => {
|
||||
setShowStats(true);
|
||||
setShowGallery(false);
|
||||
setShowBookmarks(false);
|
||||
window.history.pushState(null, "", "/stats");
|
||||
};
|
||||
|
||||
const handleToggleBookmark = async (result: WebResult | ImageResult) => {
|
||||
const isImage = "img_src" in result;
|
||||
const url = result.url;
|
||||
@@ -313,7 +328,7 @@ function App() {
|
||||
// Gallery page
|
||||
if (showGallery) {
|
||||
return <>
|
||||
<BackgroundGallery onBack={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowBookmarks={handleShowBookmarks} />
|
||||
<BackgroundGallery onBack={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowBookmarks={handleShowBookmarks} onShowStats={handleShowStats} />
|
||||
{settingsModal}
|
||||
</>;
|
||||
}
|
||||
@@ -321,7 +336,15 @@ function App() {
|
||||
// Bookmarks page
|
||||
if (showBookmarks) {
|
||||
return <>
|
||||
<Bookmarks onGoHome={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowGallery={handleShowGallery} />
|
||||
<Bookmarks onGoHome={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowGallery={handleShowGallery} onShowStats={handleShowStats} />
|
||||
{settingsModal}
|
||||
</>;
|
||||
}
|
||||
|
||||
// Stats page
|
||||
if (showStats) {
|
||||
return <>
|
||||
<StatsPage onGoHome={handleGoHome} />
|
||||
{settingsModal}
|
||||
</>;
|
||||
}
|
||||
@@ -351,6 +374,7 @@ function App() {
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
onShowBookmarks={handleShowBookmarks}
|
||||
onShowGallery={handleShowGallery}
|
||||
onShowStats={handleShowStats}
|
||||
transparent={!!bgUrl}
|
||||
hideLogo
|
||||
/>
|
||||
@@ -464,6 +488,7 @@ function App() {
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
onShowBookmarks={handleShowBookmarks}
|
||||
onShowGallery={handleShowGallery}
|
||||
onShowStats={handleShowStats}
|
||||
>
|
||||
<SearchBar initialQuery={query} onSearch={(q) => doSearch(q)} />
|
||||
</AppHeader>
|
||||
@@ -549,8 +574,8 @@ function App() {
|
||||
<div className="flex gap-6">
|
||||
{/* Results column */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{category === "web" && <WebResults results={webResults} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
|
||||
{category === "images" && <ImageResults results={imageResults} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
|
||||
{category === "web" && <WebResults results={webResults} query={query} category={category} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
|
||||
{category === "images" && <ImageResults results={imageResults} query={query} category={category} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
|
||||
|
||||
{/* Pagination */}
|
||||
{hasResults && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Menu, X, Settings, BookmarkIcon, Images, ExternalLink } from "lucide-react";
|
||||
import { Menu, X, Settings, BookmarkIcon, Images, ExternalLink, BarChart2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AppHeaderProps {
|
||||
@@ -7,6 +7,7 @@ interface AppHeaderProps {
|
||||
onShowSettings: () => void;
|
||||
onShowBookmarks: () => void;
|
||||
onShowGallery: () => void;
|
||||
onShowStats: () => void;
|
||||
/** Hides the logo button (used on the home page) */
|
||||
hideLogo?: boolean;
|
||||
/** Transparent header overlay (home page with background image) */
|
||||
@@ -19,6 +20,7 @@ interface AppHeaderProps {
|
||||
// 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 },
|
||||
{ id: "gallery", icon: Images, label: "Backgrounds", href: null },
|
||||
{ id: "bookmarks", icon: BookmarkIcon, label: "Bookmarks", href: null },
|
||||
{ id: "settings", icon: Settings, label: "Settings", href: null },
|
||||
@@ -31,6 +33,7 @@ export function AppHeader({
|
||||
onShowSettings,
|
||||
onShowBookmarks,
|
||||
onShowGallery,
|
||||
onShowStats,
|
||||
hideLogo = false,
|
||||
transparent = false,
|
||||
children,
|
||||
@@ -44,6 +47,7 @@ export function AppHeader({
|
||||
settings: () => { onShowSettings(); close(); },
|
||||
bookmarks: () => { onShowBookmarks(); close(); },
|
||||
gallery: () => { onShowGallery(); close(); },
|
||||
stats: () => { onShowStats(); close(); },
|
||||
docs: close,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ interface BackgroundGalleryProps {
|
||||
onBack: () => void;
|
||||
onShowSettings: () => void;
|
||||
onShowBookmarks: () => void;
|
||||
onShowStats: () => void;
|
||||
}
|
||||
|
||||
export function BackgroundGallery({ onBack, onShowSettings, onShowBookmarks }: BackgroundGalleryProps) {
|
||||
export function BackgroundGallery({ onBack, onShowSettings, onShowBookmarks, onShowStats }: BackgroundGalleryProps) {
|
||||
const [images, setImages] = useState<BackgroundListItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -51,6 +52,7 @@ export function BackgroundGallery({ onBack, onShowSettings, onShowBookmarks }: B
|
||||
onShowSettings={onShowSettings}
|
||||
onShowBookmarks={onShowBookmarks}
|
||||
onShowGallery={() => {}}
|
||||
onShowStats={onShowStats}
|
||||
>
|
||||
<h1 className="text-base font-semibold">
|
||||
Background Gallery
|
||||
|
||||
@@ -10,9 +10,10 @@ interface BookmarksProps {
|
||||
onGoHome: () => void;
|
||||
onShowSettings: () => void;
|
||||
onShowGallery: () => void;
|
||||
onShowStats: () => void;
|
||||
}
|
||||
|
||||
export function Bookmarks({ onGoHome, onShowSettings, onShowGallery }: BookmarksProps) {
|
||||
export function Bookmarks({ onGoHome, onShowSettings, onShowGallery, onShowStats }: BookmarksProps) {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -64,6 +65,7 @@ export function Bookmarks({ onGoHome, onShowSettings, onShowGallery }: Bookmarks
|
||||
onShowSettings={onShowSettings}
|
||||
onShowBookmarks={() => {}}
|
||||
onShowGallery={onShowGallery}
|
||||
onShowStats={onShowStats}
|
||||
>
|
||||
<h1 className="text-base font-semibold">
|
||||
Bookmarks
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, type MouseEvent } from "react";
|
||||
import type { ImageResult } from "@/lib/api";
|
||||
import { trackClick } from "@/lib/api";
|
||||
import { X, ChevronLeft, ChevronRight, Bookmark, BookmarkCheck } from "lucide-react";
|
||||
|
||||
function formatSize(w: number, h: number): string {
|
||||
@@ -8,11 +9,13 @@ function formatSize(w: number, h: number): string {
|
||||
|
||||
interface ImageResultsProps {
|
||||
results: ImageResult[];
|
||||
query?: string;
|
||||
category?: string;
|
||||
bookmarkedUrls?: Set<string>;
|
||||
onToggleBookmark?: (result: ImageResult) => void;
|
||||
}
|
||||
|
||||
export function ImageResults({ results, bookmarkedUrls, onToggleBookmark }: ImageResultsProps) {
|
||||
export function ImageResults({ results, query = "", category = "images", bookmarkedUrls, onToggleBookmark }: ImageResultsProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
// Track detected dimensions per result index
|
||||
const [dims, setDims] = useState<Record<number, { w: number; h: number }>>({});
|
||||
@@ -98,7 +101,7 @@ export function ImageResults({ results, bookmarkedUrls, onToggleBookmark }: Imag
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
role="listitem"
|
||||
onClick={(e) => handleCardClick(e, i)}
|
||||
onClick={(e) => { handleCardClick(e, i); trackClick({ query, category, position: i + 1, url: img.url, title: img.title, engine: img.engine }); }}
|
||||
className="group relative mb-3 inline-block w-full overflow-hidden rounded-lg border bg-muted break-inside-avoid hover:ring-2 hover:ring-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-shadow"
|
||||
>
|
||||
<img
|
||||
@@ -207,6 +210,7 @@ export function ImageResults({ results, bookmarkedUrls, onToggleBookmark }: Imag
|
||||
href={selected.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackClick({ query, category, position: selectedIndex + 1, url: selected.url, title: selected.title, engine: selected.engine })}
|
||||
className="mt-2 inline-block text-sm text-blue-600 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none dark:text-blue-400"
|
||||
>
|
||||
Visit page →
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchStats, type StatsSummary } from "@/lib/api";
|
||||
import { BarChart2, TrendingUp, MousePointerClick, Search } from "lucide-react";
|
||||
|
||||
interface StatsPageProps {
|
||||
onGoHome: () => void;
|
||||
}
|
||||
|
||||
function Card({ title, value, sub }: { title: string; value: string | number; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-5 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="mt-1 text-3xl font-bold">{value}</p>
|
||||
{sub && <p className="mt-1 text-xs text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPage({ onGoHome }: StatsPageProps) {
|
||||
const [days, setDays] = useState(7);
|
||||
const [data, setData] = useState<StatsSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
fetchStats(days)
|
||||
.then(setData)
|
||||
.catch(() => setError("Failed to load stats."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [days]);
|
||||
|
||||
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" />
|
||||
Search Analytics
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{[7, 30, 90].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none ${
|
||||
days === d ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>}
|
||||
{error && <p className="text-center text-destructive py-16">{error}</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card title="Searches" value={data.total_searches.toLocaleString()} sub={`last ${days} days`} />
|
||||
<Card title="Clicks" value={data.total_clicks.toLocaleString()} sub={`last ${days} days`} />
|
||||
<Card
|
||||
title="CTR"
|
||||
value={data.total_searches > 0 ? `${((data.total_clicks / data.total_searches) * 100).toFixed(1)}%` : "—"}
|
||||
sub="clicks / searches"
|
||||
/>
|
||||
<Card
|
||||
title="Avg position"
|
||||
value={
|
||||
data.top_positions.length > 0
|
||||
? (
|
||||
data.top_positions.reduce((s, p) => s + p.position * p.count, 0) /
|
||||
data.top_positions.reduce((s, p) => s + p.count, 0)
|
||||
).toFixed(1)
|
||||
: "—"
|
||||
}
|
||||
sub="clicked result rank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Daily trend */}
|
||||
{data.daily_searches.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<TrendingUp className="h-4 w-4" /> Daily Searches
|
||||
</h2>
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-end gap-1 h-28">
|
||||
{[...data.daily_searches].reverse().map((d) => {
|
||||
const max = Math.max(...data.daily_searches.map((x) => x.searches), 1);
|
||||
const pct = (d.searches / max) * 100;
|
||||
return (
|
||||
<div key={d.date} className="flex flex-1 flex-col items-center gap-1 min-w-0" title={`${d.date}: ${d.searches}`}>
|
||||
<div
|
||||
className="w-full rounded-sm bg-primary/70 transition-all"
|
||||
style={{ height: `${Math.max(pct, 2)}%` }}
|
||||
/>
|
||||
<span className="text-[9px] text-muted-foreground truncate w-full text-center hidden sm:block">
|
||||
{d.date.slice(5)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Top queries */}
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<Search className="h-4 w-4" /> Top Queries
|
||||
</h2>
|
||||
<div className="rounded-xl border bg-card divide-y">
|
||||
{data.top_queries.length === 0 && (
|
||||
<p className="px-4 py-6 text-sm text-center text-muted-foreground">No data yet</p>
|
||||
)}
|
||||
{data.top_queries.map((q, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<span className="w-5 shrink-0 text-center text-xs text-muted-foreground">{i + 1}</span>
|
||||
<span className="flex-1 truncate font-medium">{q.query}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{q.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Engine clicks */}
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<MousePointerClick className="h-4 w-4" /> Clicks by Engine
|
||||
</h2>
|
||||
<div className="rounded-xl border bg-card divide-y">
|
||||
{data.engine_clicks.length === 0 && (
|
||||
<p className="px-4 py-6 text-sm text-center text-muted-foreground">No data yet</p>
|
||||
)}
|
||||
{data.engine_clicks.map((e, i) => {
|
||||
const total = data.engine_clicks.reduce((s, x) => s + x.count, 0);
|
||||
const pct = total > 0 ? Math.round((e.count / total) * 100) : 0;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<span className="flex-1 capitalize font-medium">{e.engine || "unknown"}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="tabular-nums text-muted-foreground w-8 text-right">{e.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Position distribution */}
|
||||
{data.top_positions.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<MousePointerClick className="h-4 w-4" /> Click Position Distribution
|
||||
</h2>
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-end gap-2 h-24">
|
||||
{data.top_positions.map((p) => {
|
||||
const max = Math.max(...data.top_positions.map((x) => x.count), 1);
|
||||
const pct = (p.count / max) * 100;
|
||||
return (
|
||||
<div key={p.position} className="flex flex-1 flex-col items-center gap-1" title={`#${p.position}: ${p.count} clicks`}>
|
||||
<div
|
||||
className="w-full rounded-sm bg-blue-500/70 transition-all"
|
||||
style={{ height: `${Math.max(pct, 3)}%` }}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">#{p.position}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Top clicked URLs */}
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
|
||||
<MousePointerClick className="h-4 w-4" /> Top Clicked Results
|
||||
</h2>
|
||||
<div className="rounded-xl border bg-card divide-y">
|
||||
{data.top_clicked_urls.length === 0 && (
|
||||
<p className="px-4 py-6 text-sm text-center text-muted-foreground">No clicks recorded yet</p>
|
||||
)}
|
||||
{data.top_clicked_urls.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<span className="w-5 shrink-0 text-center text-xs text-muted-foreground">{i + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-medium">{r.title || r.url}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{r.url}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs capitalize">{r.engine}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{r.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { WebResult } from "@/lib/api";
|
||||
import { trackClick } from "@/lib/api";
|
||||
import { ExternalLink, Bookmark, BookmarkCheck } from "lucide-react";
|
||||
|
||||
interface WebResultsProps {
|
||||
results: WebResult[];
|
||||
query?: string;
|
||||
category?: string;
|
||||
bookmarkedUrls?: Set<string>;
|
||||
onToggleBookmark?: (result: WebResult) => void;
|
||||
}
|
||||
|
||||
export function WebResults({ results, bookmarkedUrls, onToggleBookmark }: WebResultsProps) {
|
||||
export function WebResults({ results, query = "", category = "web", bookmarkedUrls, onToggleBookmark }: WebResultsProps) {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -43,6 +46,7 @@ export function WebResults({ results, bookmarkedUrls, onToggleBookmark }: WebRes
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackClick({ query, category, position: i + 1, url: result.url, title: result.title, engine: result.engine })}
|
||||
className="mt-1 block text-lg font-medium text-blue-600 visited:text-purple-600 hover:underline dark:text-blue-400 dark:visited:text-purple-400 sm:text-xl"
|
||||
>
|
||||
{result.title}
|
||||
|
||||
@@ -279,3 +279,38 @@ export async function removeBookmarkByUrl(url: string): Promise<void> {
|
||||
const resp = await fetch(`${API_BASE}/bookmarks/by-url/${encodeURIComponent(url)}`, { method: "DELETE" });
|
||||
if (!resp.ok) throw new Error("Failed to remove bookmark");
|
||||
}
|
||||
|
||||
// --- Analytics ---
|
||||
|
||||
export interface StatsSummary {
|
||||
period_days: number;
|
||||
total_searches: number;
|
||||
total_clicks: number;
|
||||
top_queries: { query: string; count: number }[];
|
||||
top_clicked_urls: { url: string; title: string; engine: string; count: number }[];
|
||||
top_positions: { position: number; count: number }[];
|
||||
engine_clicks: { engine: string; count: number }[];
|
||||
daily_searches: { date: string; searches: number }[];
|
||||
}
|
||||
|
||||
export async function trackClick(data: {
|
||||
query: string;
|
||||
category: string;
|
||||
position: number;
|
||||
url: string;
|
||||
title: string;
|
||||
engine: string;
|
||||
}): Promise<void> {
|
||||
// fire-and-forget — don't block the user
|
||||
fetch(`${API_BASE}/stats/click`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export async function fetchStats(days = 7): Promise<StatsSummary> {
|
||||
const resp = await fetch(`${API_BASE}/stats?days=${days}`);
|
||||
if (!resp.ok) throw new Error("Failed to fetch stats");
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user