mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-09 05:06:23 +10:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user