feat: add bookmarks for web and image results

- Bookmark icon on every web and image search result card
- Bookmark toggle in image lightbox viewer
- Dedicated /bookmarks page with All/Web/Images filter tabs
- Web bookmarks as cards, image bookmarks in masonry grid
- Full CRUD API: GET/POST /api/bookmarks, DELETE by id/url
- SQLite persistence in shared database
- Links in homepage footer and results page footer

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-23 19:04:46 +11:00
co-authored by Copilot
parent 8ac1fb078a
commit 2f9889004c
10 changed files with 699 additions and 22 deletions
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## 1.8.0
### Added
- **Bookmarks** — save any web or image result for later reading
- Bookmark icon on every search result card (web and image)
- Bookmark toggle in image lightbox viewer
- Dedicated `/bookmarks` page with filter tabs (All / Web / Images)
- Web bookmarks shown as cards with favicons, engine badges, and content snippets
- Image bookmarks displayed in masonry grid with hover overlays
- Remove individual bookmarks with trash button
- Paginated with "Load more" for large collections
- Bookmarks link in homepage footer and results page footer
- Stored in SQLite (same database as other app settings)
- Full CRUD API: `GET/POST /api/bookmarks`, `DELETE /api/bookmarks/{id}`, `DELETE /api/bookmarks/by-url/{url}`, `GET /api/bookmarks/urls`
## 1.5.0
### Added
+9
View File
@@ -44,6 +44,15 @@
- **Optional** — when `REDIS_URL` is not set or Redis is unreachable, caching is silently disabled
- **Deterministic keys** — cache key derived from query + category + page + image_size + engines
## Bookmarks
- **Bookmark any result** — click the bookmark icon on web or image search results to save them
- **Bookmarks page** — dedicated `/bookmarks` page to browse all saved items
- **Filter by type** — tabs to filter All / Web / Images
- **Masonry image grid** — saved images displayed in a masonry layout
- **Remove bookmarks** — delete individual bookmarks from the bookmarks page
- **Persistent storage** — bookmarks stored in SQLite alongside other app data
## UI / UX
- **Mobile-first** responsive design built with React, Tailwind CSS, and shadcn theming
+110
View File
@@ -428,3 +428,113 @@ async def api_serve_background(filename: str):
if path is None:
return JSONResponse(status_code=404, content={"code": "not_found", "message": "Image not found"})
return FileResponse(path, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=86400"})
# --- Bookmarks ---
from app.bookmarks import add_bookmark, remove_bookmark, remove_bookmark_by_url, get_bookmarks, get_bookmarked_urls, count_bookmarks
class BookmarkCreate(BaseModel):
type: Literal["web", "image"] = "web"
title: str
url: str
content: str = ""
img_src: str = ""
thumbnail_src: str = ""
source: str = ""
engine: str = ""
width: int = 0
height: int = 0
class BookmarkItem(BaseModel):
id: str
type: str
title: str
url: str
content: str = ""
img_src: str = ""
thumbnail_src: str = ""
source: str = ""
engine: str = ""
width: int = 0
height: int = 0
created_at: str
class BookmarkListResponse(BaseModel):
bookmarks: list[BookmarkItem]
total: int
page: int
per_page: int
class BookmarkedUrlsResponse(BaseModel):
urls: list[str]
@router.get(
"/bookmarks",
response_model=BookmarkListResponse,
summary="List bookmarks",
tags=["Bookmarks"],
)
async def api_list_bookmarks(
page: int = Query(1, ge=1),
per_page: int = Query(30, ge=1, le=100),
type: str = Query("", description="Filter by type: 'web' or 'image'"),
):
items = get_bookmarks(page, per_page, type)
total = count_bookmarks(type)
return BookmarkListResponse(
bookmarks=[BookmarkItem(**b) for b in items],
total=total,
page=page,
per_page=per_page,
)
@router.get(
"/bookmarks/urls",
response_model=BookmarkedUrlsResponse,
summary="Get all bookmarked URLs for quick lookup",
tags=["Bookmarks"],
)
async def api_bookmarked_urls():
return BookmarkedUrlsResponse(urls=list(get_bookmarked_urls()))
@router.post(
"/bookmarks",
response_model=BookmarkItem,
summary="Add a bookmark",
tags=["Bookmarks"],
)
async def api_add_bookmark(body: BookmarkCreate):
result = add_bookmark(body.model_dump())
return BookmarkItem(**result)
@router.delete(
"/bookmarks/{bookmark_id}",
summary="Remove a bookmark by ID",
tags=["Bookmarks"],
)
async def api_remove_bookmark(bookmark_id: str):
removed = remove_bookmark(bookmark_id)
if not removed:
return JSONResponse(status_code=404, content={"code": "not_found", "message": "Bookmark not found"})
return {"message": "Bookmark removed"}
@router.delete(
"/bookmarks/by-url/{url:path}",
summary="Remove a bookmark by URL",
tags=["Bookmarks"],
)
async def api_remove_bookmark_by_url(url: str):
removed = remove_bookmark_by_url(url)
if not removed:
return JSONResponse(status_code=404, content={"code": "not_found", "message": "Bookmark not found"})
return {"message": "Bookmark removed"}
+139
View File
@@ -0,0 +1,139 @@
"""SQLite-backed bookmarks storage."""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from app.excluded import _get_conn
logger = logging.getLogger(__name__)
def init_bookmarks_table() -> None:
"""Create the bookmarks table if it doesn't exist."""
conn = _get_conn()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- 'web' or 'image'
title TEXT NOT NULL,
url TEXT NOT NULL,
content TEXT DEFAULT '', -- snippet for web results
img_src TEXT DEFAULT '', -- image URL for image results
thumbnail_src TEXT DEFAULT '',
source TEXT DEFAULT '',
engine TEXT DEFAULT '',
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.commit()
conn.close()
logger.info("Bookmarks table initialized")
def add_bookmark(data: dict) -> dict:
"""Add a bookmark. Returns the created bookmark."""
conn = _get_conn()
bookmark_id = uuid.uuid4().hex[:12]
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"""
INSERT INTO bookmarks (id, type, title, url, content, img_src, thumbnail_src, source, engine, width, height, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
bookmark_id,
data.get("type", "web"),
data.get("title", ""),
data.get("url", ""),
data.get("content", ""),
data.get("img_src", ""),
data.get("thumbnail_src", ""),
data.get("source", ""),
data.get("engine", ""),
data.get("width", 0),
data.get("height", 0),
now,
),
)
conn.commit()
conn.close()
return {
"id": bookmark_id,
"type": data.get("type", "web"),
"title": data.get("title", ""),
"url": data.get("url", ""),
"content": data.get("content", ""),
"img_src": data.get("img_src", ""),
"thumbnail_src": data.get("thumbnail_src", ""),
"source": data.get("source", ""),
"engine": data.get("engine", ""),
"width": data.get("width", 0),
"height": data.get("height", 0),
"created_at": now,
}
def remove_bookmark(bookmark_id: str) -> bool:
"""Remove a bookmark by ID. Returns True if deleted."""
conn = _get_conn()
cursor = conn.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
conn.commit()
conn.close()
return cursor.rowcount > 0
def remove_bookmark_by_url(url: str) -> bool:
"""Remove a bookmark by URL. Returns True if deleted."""
conn = _get_conn()
cursor = conn.execute("DELETE FROM bookmarks WHERE url = ?", (url,))
conn.commit()
conn.close()
return cursor.rowcount > 0
def get_bookmarks(page: int = 1, per_page: int = 30, type_filter: str = "") -> list[dict]:
"""Get bookmarks, newest first. Optionally filter by type."""
conn = _get_conn()
offset = (page - 1) * per_page
if type_filter:
rows = conn.execute(
"SELECT id, type, title, url, content, img_src, thumbnail_src, source, engine, width, height, created_at "
"FROM bookmarks WHERE type = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(type_filter, per_page, offset),
).fetchall()
else:
rows = conn.execute(
"SELECT id, type, title, url, content, img_src, thumbnail_src, source, engine, width, height, created_at "
"FROM bookmarks ORDER BY created_at DESC LIMIT ? OFFSET ?",
(per_page, offset),
).fetchall()
conn.close()
cols = ["id", "type", "title", "url", "content", "img_src", "thumbnail_src", "source", "engine", "width", "height", "created_at"]
return [dict(zip(cols, row)) for row in rows]
def get_bookmarked_urls() -> set[str]:
"""Get all bookmarked URLs as a set (for quick lookup)."""
conn = _get_conn()
rows = conn.execute("SELECT url FROM bookmarks").fetchall()
conn.close()
return {row[0] for row in rows}
def count_bookmarks(type_filter: str = "") -> int:
"""Count total bookmarks."""
conn = _get_conn()
if type_filter:
row = conn.execute("SELECT COUNT(*) FROM bookmarks WHERE type = ?", (type_filter,)).fetchone()
else:
row = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()
conn.close()
return row[0] if row else 0
+2
View File
@@ -17,6 +17,7 @@ from app.engines import registry
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.models import APIError
@@ -24,6 +25,7 @@ from app.models import APIError
async def lifespan(application: FastAPI):
init_db()
init_settings_table()
init_bookmarks_table()
registry.load_default_engines()
await init_redis()
yield
+84 -15
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal, RefreshCw, Images } from "lucide-react";
import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal, RefreshCw, Images, BookmarkIcon } from "lucide-react";
import { SearchBar } from "@/components/SearchBar";
import { WebResults } from "@/components/WebResults";
import { ImageResults } from "@/components/ImageResults";
@@ -7,7 +7,8 @@ import { SearchStats } from "@/components/SearchStats";
import { SettingsModal } from "@/components/SettingsModal";
import { ErrorToast } from "@/components/ErrorToast";
import { BackgroundGallery } from "@/components/BackgroundGallery";
import { search as apiSearch, isImageResult, getBackground, refreshBackground, type SearchResponse, type WebResult, type ImageResult, type BackgroundInfo } from "@/lib/api";
import { Bookmarks } from "@/components/Bookmarks";
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";
type Category = "web" | "images";
@@ -55,15 +56,20 @@ function App() {
const [showSettings, setShowSettings] = useState(false);
const [hasSearched, setHasSearched] = useState(!!initial.q);
const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds");
const [showBookmarks, setShowBookmarks] = useState(window.location.pathname === "/bookmarks");
const statusRef = useRef<HTMLDivElement>(null);
// Background image state
const [bgInfo, setBgInfo] = useState<BackgroundInfo | null>(null);
const [bgRefreshing, setBgRefreshing] = useState(false);
// Fetch background on mount
// Bookmarked URLs for toggle state
const [bookmarkedUrls, setBookmarkedUrls] = useState<Set<string>>(new Set());
// Fetch background and bookmarked URLs on mount
useEffect(() => {
getBackground().then(setBgInfo).catch(() => {});
getBookmarkedUrls().then(setBookmarkedUrls).catch(() => {});
}, []);
const doSearch = useCallback(
@@ -113,9 +119,16 @@ function App() {
const onPopState = () => {
if (window.location.pathname === "/backgrounds") {
setShowGallery(true);
setShowBookmarks(false);
return;
}
if (window.location.pathname === "/bookmarks") {
setShowBookmarks(true);
setShowGallery(false);
return;
}
setShowGallery(false);
setShowBookmarks(false);
const { q, cat, page: p, imageSize: size } = parseUrlState();
if (q) {
doSearch(q, cat, p, size, false);
@@ -154,8 +167,8 @@ function App() {
setPage(1);
setImageSize("");
setShowGallery(false);
setShowBookmarks(false);
window.history.pushState(null, "", "/");
// Re-fetch background in case it changed
getBackground().then(setBgInfo).catch(() => {});
};
@@ -173,9 +186,41 @@ function App() {
const handleShowGallery = () => {
setShowGallery(true);
setShowBookmarks(false);
window.history.pushState(null, "", "/backgrounds");
};
const handleShowBookmarks = () => {
setShowBookmarks(true);
setShowGallery(false);
window.history.pushState(null, "", "/bookmarks");
};
const handleToggleBookmark = async (result: WebResult | ImageResult) => {
const isImage = "img_src" in result;
const url = result.url;
if (bookmarkedUrls.has(url)) {
await removeBookmarkByUrl(url);
setBookmarkedUrls((prev) => { const next = new Set(prev); next.delete(url); return next; });
} else {
await addBookmark({
type: isImage ? "image" : "web",
title: result.title,
url: result.url,
content: "content" in result ? result.content : "",
engine: result.engine,
...(isImage && {
img_src: (result as ImageResult).img_src,
thumbnail_src: (result as ImageResult).thumbnail_src,
source: (result as ImageResult).source,
width: (result as ImageResult).width,
height: (result as ImageResult).height,
}),
});
setBookmarkedUrls((prev) => new Set(prev).add(url));
}
};
const webResults = response?.results.filter((r): r is WebResult => !isImageResult(r)) ?? [];
const imageResults = response?.results.filter((r): r is ImageResult => isImageResult(r)) ?? [];
const hasResults = (response?.results.length ?? 0) > 0;
@@ -185,6 +230,11 @@ function App() {
return <BackgroundGallery onBack={handleGoHome} />;
}
// Bookmarks page
if (showBookmarks) {
return <Bookmarks />;
}
const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null;
// Home page (no search yet)
@@ -277,6 +327,16 @@ function App() {
<Images className="h-3.5 w-3.5" aria-hidden="true" />
Backgrounds
</button>
<button
onClick={handleShowBookmarks}
className={cn(
"flex items-center gap-1.5 text-xs hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors",
bgUrl ? "text-white/60 hover:text-white" : "text-muted-foreground"
)}
>
<BookmarkIcon className="h-3.5 w-3.5" aria-hidden="true" />
Bookmarks
</button>
</footer>
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
@@ -408,8 +468,8 @@ function App() {
<div className="flex gap-6">
{/* Results column */}
<div className="min-w-0 flex-1">
{category === "web" && <WebResults results={webResults} />}
{category === "images" && <ImageResults results={imageResults} />}
{category === "web" && <WebResults results={webResults} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
{category === "images" && <ImageResults results={imageResults} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
{/* Pagination */}
{hasResults && (
@@ -461,15 +521,24 @@ function App() {
<footer className="border-t px-4 py-3">
<div className="mx-auto flex max-w-6xl items-center justify-between text-xs text-muted-foreground">
<span>Hey Search</span>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors"
>
<ExternalLink className="h-3 w-3" aria-hidden="true" />
API Docs
</a>
<div className="flex items-center gap-3">
<button
onClick={handleShowBookmarks}
className="flex items-center gap-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors"
>
<BookmarkIcon className="h-3 w-3" aria-hidden="true" />
Bookmarks
</button>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors"
>
<ExternalLink className="h-3 w-3" aria-hidden="true" />
API Docs
</a>
</div>
</div>
</footer>
+214
View File
@@ -0,0 +1,214 @@
import { useState, useEffect, useCallback } from "react";
import { ArrowLeft, Trash2, ExternalLink, Loader2 } from "lucide-react";
import type { Bookmark } from "@/lib/api";
import { getBookmarks, removeBookmark } from "@/lib/api";
type FilterType = "" | "web" | "image";
export function Bookmarks() {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterType>("");
const [loading, setLoading] = useState(true);
const perPage = 30;
const load = useCallback(async (p: number, f: FilterType) => {
setLoading(true);
try {
const data = await getBookmarks(p, perPage, f);
setBookmarks(p === 1 ? data.bookmarks : (prev) => [...prev, ...data.bookmarks]);
setTotal(data.total);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setBookmarks([]);
setPage(1);
load(1, filter);
}, [filter, load]);
const handleRemove = async (b: Bookmark) => {
await removeBookmark(b.id);
setBookmarks((prev) => prev.filter((x) => x.id !== b.id));
setTotal((prev) => prev - 1);
};
const handleLoadMore = () => {
const next = page + 1;
setPage(next);
load(next, filter);
};
const goHome = () => {
window.history.pushState({}, "", "/");
window.dispatchEvent(new PopStateEvent("popstate"));
};
const tabs: { label: string; value: FilterType }[] = [
{ label: "All", value: "" },
{ label: "Web", value: "web" },
{ label: "Images", value: "image" },
];
const hasMore = bookmarks.length < total;
return (
<div className="mx-auto min-h-screen max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<button
onClick={goHome}
aria-label="Go back home"
className="rounded-full p-2 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h1 className="text-2xl font-bold">Bookmarks</h1>
<span className="text-sm text-muted-foreground">({total})</span>
</div>
{/* Filter tabs */}
<div className="mb-6 flex gap-2" role="tablist" aria-label="Bookmark type filter">
{tabs.map((tab) => (
<button
key={tab.value}
role="tab"
aria-selected={filter === tab.value}
onClick={() => setFilter(tab.value)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none ${
filter === tab.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Empty state */}
{!loading && bookmarks.length === 0 && (
<div className="py-20 text-center text-muted-foreground">
<p className="text-lg">No bookmarks yet</p>
<p className="mt-1 text-sm">Click the bookmark icon on search results to save them here.</p>
</div>
)}
{/* Web bookmarks */}
{bookmarks.filter((b) => b.type === "web").length > 0 && filter !== "image" && (
<section className="mb-8">
{filter === "" && <h2 className="mb-4 text-lg font-semibold">Web Results</h2>}
<div className="space-y-4">
{bookmarks
.filter((b) => b.type === "web")
.map((b) => (
<article key={b.id} className="group flex items-start gap-3 rounded-lg border p-4 transition-colors hover:bg-accent/50">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<img
src={`https://www.google.com/s2/favicons?domain=${new URL(b.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(b.url).hostname}</span>
{b.engine && <span className="rounded bg-muted px-1.5 py-0.5 text-xs">{b.engine}</span>}
</div>
<a
href={b.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{b.title}
<ExternalLink className="mb-0.5 ml-1 inline h-3.5 w-3.5 opacity-0 group-hover:opacity-100" />
</a>
{b.content && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{b.content}</p>
)}
<time className="mt-1 block text-xs text-muted-foreground/60">
{new Date(b.created_at).toLocaleDateString()}
</time>
</div>
<button
onClick={() => handleRemove(b)}
aria-label={`Remove bookmark: ${b.title}`}
className="shrink-0 rounded p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</article>
))}
</div>
</section>
)}
{/* Image bookmarks */}
{bookmarks.filter((b) => b.type === "image").length > 0 && filter !== "web" && (
<section>
{filter === "" && <h2 className="mb-4 text-lg font-semibold">Image Results</h2>}
<div className="columns-2 gap-3 sm:columns-3 md:columns-4 lg:columns-5">
{bookmarks
.filter((b) => b.type === "image")
.map((b) => (
<div key={b.id} className="group relative mb-3 inline-block w-full break-inside-avoid overflow-hidden rounded-lg">
<a href={b.url} target="_blank" rel="noopener noreferrer">
<img
src={b.thumbnail_src || b.img_src}
alt={b.title}
loading="lazy"
referrerPolicy="no-referrer"
className="w-full rounded-lg object-cover"
/>
{/* Hover overlay */}
<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">{b.title}</p>
<div className="flex items-center gap-1">
<p className="truncate text-[10px] text-white/60">{b.source || new URL(b.url).hostname}</p>
{b.width > 0 && b.height > 0 && (
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-white/60">
{b.width} × {b.height}
</span>
)}
</div>
</div>
</a>
{/* Remove button */}
<button
onClick={() => handleRemove(b)}
aria-label={`Remove bookmark: ${b.title}`}
className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-white opacity-0 transition-opacity hover:bg-destructive group-hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</section>
)}
{/* Load more */}
{hasMore && (
<div className="mt-8 flex justify-center">
<button
onClick={handleLoadMore}
disabled={loading}
className="inline-flex items-center gap-2 rounded-full bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Load more
</button>
</div>
)}
{loading && bookmarks.length === 0 && (
<div className="flex justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
}
+33 -3
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, type MouseEvent } from "react";
import type { ImageResult } from "@/lib/api";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
import { X, ChevronLeft, ChevronRight, Bookmark, BookmarkCheck } from "lucide-react";
function formatSize(w: number, h: number): string {
return `${w} × ${h}`;
@@ -8,9 +8,11 @@ function formatSize(w: number, h: number): string {
interface ImageResultsProps {
results: ImageResult[];
bookmarkedUrls?: Set<string>;
onToggleBookmark?: (result: ImageResult) => void;
}
export function ImageResults({ results }: ImageResultsProps) {
export function ImageResults({ results, 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 }>>({});
@@ -114,7 +116,22 @@ export function ImageResults({ results }: ImageResultsProps) {
/>
{/* Hover overlay with title + size */}
<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 className="flex items-center gap-1">
<p className="truncate text-xs text-white flex-1">{img.title}</p>
{onToggleBookmark && (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggleBookmark(img); }}
aria-label={bookmarkedUrls?.has(img.url) ? "Remove bookmark" : "Add bookmark"}
className="shrink-0 rounded p-0.5 hover:bg-white/20 transition-colors"
>
{bookmarkedUrls?.has(img.url) ? (
<BookmarkCheck className="h-4 w-4 text-yellow-400" />
) : (
<Bookmark className="h-4 w-4 text-white/70" />
)}
</button>
)}
</div>
<div className="flex items-center gap-1">
<p className="truncate text-[10px] text-white/60">{img.source}</p>
{getSize(i) && (
@@ -194,6 +211,19 @@ export function ImageResults({ results }: ImageResultsProps) {
>
Visit page
</a>
{onToggleBookmark && (
<button
onClick={() => onToggleBookmark(selected)}
aria-label={bookmarkedUrls?.has(selected.url) ? "Remove bookmark" : "Add bookmark"}
className="ml-3 inline-flex items-center gap-1 rounded px-2 py-1 text-sm hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors"
>
{bookmarkedUrls?.has(selected.url) ? (
<><BookmarkCheck className="h-4 w-4 text-primary" /> Bookmarked</>
) : (
<><Bookmark className="h-4 w-4" /> Bookmark</>
)}
</button>
)}
</div>
</div>
</div>
+22 -4
View File
@@ -1,16 +1,20 @@
import type { WebResult } from "@/lib/api";
import { ExternalLink } from "lucide-react";
import { ExternalLink, Bookmark, BookmarkCheck } from "lucide-react";
interface WebResultsProps {
results: WebResult[];
bookmarkedUrls?: Set<string>;
onToggleBookmark?: (result: WebResult) => void;
}
export function WebResults({ results }: WebResultsProps) {
export function WebResults({ results, bookmarkedUrls, onToggleBookmark }: WebResultsProps) {
if (results.length === 0) return null;
return (
<div className="space-y-6">
{results.map((result, i) => (
{results.map((result, i) => {
const isBookmarked = bookmarkedUrls?.has(result.url) ?? false;
return (
<article key={`${result.url}-${i}`} className="group max-w-2xl">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<img
@@ -21,6 +25,19 @@ export function WebResults({ results }: WebResultsProps) {
/>
<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>
{onToggleBookmark && (
<button
onClick={() => onToggleBookmark(result)}
aria-label={isBookmarked ? "Remove bookmark" : "Add bookmark"}
className="ml-auto rounded p-1 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors"
>
{isBookmarked ? (
<BookmarkCheck className="h-4 w-4 text-primary" />
) : (
<Bookmark className="h-4 w-4 text-muted-foreground group-hover:text-foreground" />
)}
</button>
)}
</div>
<a
href={result.url}
@@ -37,7 +54,8 @@ export function WebResults({ results }: WebResultsProps) {
</p>
)}
</article>
))}
);
})}
</div>
);
}
+70
View File
@@ -209,3 +209,73 @@ export async function listBackgrounds(page = 1, perPage = 20): Promise<Backgroun
if (!resp.ok) throw new Error("Failed to list backgrounds");
return resp.json();
}
// --- Bookmarks ---
export interface Bookmark {
id: string;
type: "web" | "image";
title: string;
url: string;
content: string;
img_src: string;
thumbnail_src: string;
source: string;
engine: string;
width: number;
height: number;
created_at: string;
}
export interface BookmarkListResponse {
bookmarks: Bookmark[];
total: number;
page: number;
per_page: number;
}
export async function getBookmarks(page = 1, perPage = 30, type = ""): Promise<BookmarkListResponse> {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) });
if (type) params.set("type", type);
const resp = await fetch(`${API_BASE}/bookmarks?${params}`);
if (!resp.ok) throw new Error("Failed to fetch bookmarks");
return resp.json();
}
export async function getBookmarkedUrls(): Promise<Set<string>> {
const resp = await fetch(`${API_BASE}/bookmarks/urls`);
if (!resp.ok) throw new Error("Failed to fetch bookmarked URLs");
const data: { urls: string[] } = await resp.json();
return new Set(data.urls);
}
export async function addBookmark(data: {
type: "web" | "image";
title: string;
url: string;
content?: string;
img_src?: string;
thumbnail_src?: string;
source?: string;
engine?: string;
width?: number;
height?: number;
}): Promise<Bookmark> {
const resp = await fetch(`${API_BASE}/bookmarks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!resp.ok) throw new Error("Failed to add bookmark");
return resp.json();
}
export async function removeBookmark(id: string): Promise<void> {
const resp = await fetch(`${API_BASE}/bookmarks/${id}`, { method: "DELETE" });
if (!resp.ok) throw new Error("Failed to remove bookmark");
}
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");
}