From 4bd375cb83361a2e801292551a8f0e9d5373c843 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 23 Feb 2026 18:54:44 +1100 Subject: [PATCH] feat: Unsplash background image on homepage with gallery - Fetch random nature/landscape image from Unsplash (picsum fallback) - Auto-refresh with configurable interval (1 min to 1 day, default 30 min) - Save every downloaded image to DATA_DIR/backgrounds/ for history - New 'Background' tab in Settings: enable/disable toggle + refresh presets - 'New image' button on homepage to manually fetch fresh background - /backgrounds gallery page with masonry layout and lazy loading - Background settings (bg_enabled, bg_refresh_minutes) persisted in SQLite - Homepage adapts text colors when background is active - Added backend/data/ to .gitignore, removed tracked DB file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + backend/app/api/routes.py | 97 +++++++++++++ backend/app/background.py | 125 +++++++++++++++++ backend/app/settings.py | 2 + backend/data/hey_search.db | Bin 20480 -> 0 bytes frontend/src/App.tsx | 105 ++++++++++++-- frontend/src/components/BackgroundGallery.tsx | 129 ++++++++++++++++++ frontend/src/components/SettingsModal.tsx | 128 ++++++++++++++++- frontend/src/lib/api.ts | 37 ++++- 9 files changed, 612 insertions(+), 12 deletions(-) create mode 100644 backend/app/background.py delete mode 100644 backend/data/hey_search.db create mode 100644 frontend/src/components/BackgroundGallery.tsx diff --git a/.gitignore b/.gitignore index a3e95b3..046bd6f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ dist/ .env *.egg-info/ +backend/data/ diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index f9d1fcc..ea732f9 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -237,11 +237,15 @@ class SettingsResponse(BaseModel): cache_ttl_hours: float = Field(description="Cache TTL in hours (0 = disabled, max 168 = 1 week)") cache_available: bool = Field(description="Whether Redis is connected and available") redis_url: str = Field(default="", description="Redis connection URL (e.g. redis://localhost:6379)") + bg_enabled: bool = Field(default=True, description="Whether homepage background image is enabled") + bg_refresh_minutes: int = Field(default=30, description="Background image refresh interval in minutes (1-1440)") class UpdateSettingsRequest(BaseModel): cache_ttl_hours: float | None = Field(default=None, ge=0, le=168, description="Cache TTL in hours (0 = disabled, max 168 = 1 week)") redis_url: str | None = Field(default=None, description="Redis connection URL (empty string to disconnect)") + bg_enabled: bool | None = Field(default=None, description="Enable/disable homepage background image") + bg_refresh_minutes: int | None = Field(default=None, ge=1, le=1440, description="Background refresh interval in minutes") @router.get( @@ -263,6 +267,8 @@ async def api_get_settings(): cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")), cache_available=is_cache_available(), redis_url=settings.get("redis_url", ""), + bg_enabled=settings.get("bg_enabled", "true") == "true", + bg_refresh_minutes=int(float(settings.get("bg_refresh_minutes", "30"))), ) @@ -287,11 +293,17 @@ async def api_update_settings(body: UpdateSettingsRequest): if body.redis_url is not None: set_setting("redis_url", body.redis_url) await reconnect_redis(body.redis_url) + if body.bg_enabled is not None: + set_setting("bg_enabled", "true" if body.bg_enabled else "false") + if body.bg_refresh_minutes is not None: + set_setting("bg_refresh_minutes", str(body.bg_refresh_minutes)) settings = get_all_settings() return SettingsResponse( cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")), cache_available=is_cache_available(), redis_url=settings.get("redis_url", ""), + bg_enabled=settings.get("bg_enabled", "true") == "true", + bg_refresh_minutes=int(float(settings.get("bg_refresh_minutes", "30"))), ) @@ -318,3 +330,88 @@ curl -X DELETE '$BASE_URL/api/cache' async def api_flush_cache(): count = await flush_cache() return CacheFlushResponse(keys_deleted=count, message=f"Deleted {count} cached entries") + + +# --- Background Images --- + +from fastapi.responses import FileResponse +from app.background import get_current_background, fetch_new_background, list_backgrounds, get_background_path + + +class BackgroundResponse(BaseModel): + filename: str | None = None + url: str | None = None + enabled: bool = True + + +class BackgroundListItem(BaseModel): + filename: str + url: str + size_bytes: int + created_at: float + + +@router.get( + "/background", + response_model=BackgroundResponse, + summary="Get current homepage background image info", + tags=["Background"], +) +async def api_get_background(): + from app.background import is_background_enabled + enabled = is_background_enabled() + if not enabled: + return BackgroundResponse(enabled=False) + filename = await get_current_background() + if filename: + return BackgroundResponse(filename=filename, url=f"/api/backgrounds/{filename}", enabled=True) + return BackgroundResponse(enabled=True) + + +@router.post( + "/background/refresh", + response_model=BackgroundResponse, + summary="Fetch a new background image immediately", + tags=["Background"], +) +async def api_refresh_background(): + filename = await fetch_new_background() + if filename: + return BackgroundResponse(filename=filename, url=f"/api/backgrounds/{filename}", enabled=True) + return JSONResponse(status_code=502, content={"code": "fetch_failed", "message": "Could not fetch background image"}) + + +@router.get( + "/backgrounds", + response_model=list[BackgroundListItem], + summary="List all saved background images", + tags=["Background"], +) +async def api_list_backgrounds( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), +): + all_bgs = list_backgrounds() + start = (page - 1) * per_page + items = all_bgs[start:start + per_page] + return [ + BackgroundListItem( + filename=b["filename"], + url=f"/api/backgrounds/{b['filename']}", + size_bytes=b["size_bytes"], + created_at=b["created_at"], + ) + for b in items + ] + + +@router.get( + "/backgrounds/{filename}", + summary="Serve a background image file", + tags=["Background"], +) +async def api_serve_background(filename: str): + path = get_background_path(filename) + 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"}) diff --git a/backend/app/background.py b/backend/app/background.py new file mode 100644 index 0000000..bdd2534 --- /dev/null +++ b/backend/app/background.py @@ -0,0 +1,125 @@ +"""Background image management — fetch from Unsplash, cache locally.""" + +from __future__ import annotations + +import logging +import os +import time +import uuid +from pathlib import Path + +import httpx + +from app.excluded import DATA_DIR +from app.settings import get_setting, set_setting + +logger = logging.getLogger(__name__) + +BACKGROUNDS_DIR = DATA_DIR / "backgrounds" + +# Unsplash source URL — returns a random image redirect +UNSPLASH_URL = "https://source.unsplash.com/random/1920x1080?nature,landscape" +# Fallback: picsum +PICSUM_URL = "https://picsum.photos/1920/1080" + +# In-memory state for the current background +_current: dict | None = None # {"filename": ..., "fetched_at": ...} + + +def _ensure_dir() -> None: + BACKGROUNDS_DIR.mkdir(parents=True, exist_ok=True) + + +def is_background_enabled() -> bool: + return get_setting("bg_enabled") == "true" + + +def get_refresh_minutes() -> int: + try: + return max(1, min(1440, int(float(get_setting("bg_refresh_minutes"))))) + except (ValueError, TypeError): + return 30 + + +def _needs_refresh() -> bool: + if _current is None: + return True + elapsed = time.time() - _current["fetched_at"] + return elapsed >= get_refresh_minutes() * 60 + + +def list_backgrounds() -> list[dict]: + """List all saved background images, newest first.""" + _ensure_dir() + files = sorted(BACKGROUNDS_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True) + result = [] + for f in files: + st = f.stat() + result.append({ + "filename": f.name, + "size_bytes": st.st_size, + "created_at": st.st_mtime, + }) + return result + + +async def fetch_new_background() -> str | None: + """Download a new background image from Unsplash, save it, return filename.""" + global _current + _ensure_dir() + + filename = f"{int(time.time())}_{uuid.uuid4().hex[:8]}.jpg" + filepath = BACKGROUNDS_DIR / filename + + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + # Try Unsplash first, fallback to Picsum + for url in [UNSPLASH_URL, PICSUM_URL]: + try: + resp = await client.get(url) + if resp.is_success and len(resp.content) > 1000: + filepath.write_bytes(resp.content) + _current = {"filename": filename, "fetched_at": time.time()} + logger.info("Background image saved: %s (%d bytes)", filename, len(resp.content)) + return filename + except Exception as e: + logger.warning("Failed to fetch background from %s: %s", url, e) + + return None + + +async def get_current_background() -> str | None: + """Get current background filename, fetching a new one if needed.""" + global _current + + if not is_background_enabled(): + return None + + # On first call, try to use the most recent saved image + if _current is None: + _ensure_dir() + files = sorted(BACKGROUNDS_DIR.glob("*.jpg"), key=lambda f: f.stat().st_mtime, reverse=True) + if files: + st = files[0].stat() + age_minutes = (time.time() - st.st_mtime) / 60 + _current = {"filename": files[0].name, "fetched_at": st.st_mtime} + if age_minutes < get_refresh_minutes(): + return _current["filename"] + + if _needs_refresh(): + result = await fetch_new_background() + if result: + return result + # If fetch failed but we have a previous image, use it + if _current: + return _current["filename"] + return None + + return _current["filename"] if _current else None + + +def get_background_path(filename: str) -> Path | None: + """Get the full path to a background image file.""" + path = BACKGROUNDS_DIR / filename + if path.is_file(): + return path + return None diff --git a/backend/app/settings.py b/backend/app/settings.py index 5947a55..fb5abaf 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -12,6 +12,8 @@ logger = logging.getLogger(__name__) DEFAULTS: dict[str, str] = { "cache_ttl_hours": "6", "redis_url": "", + "bg_enabled": "true", + "bg_refresh_minutes": "30", } diff --git a/backend/data/hey_search.db b/backend/data/hey_search.db deleted file mode 100644 index 8046c70b533cbab458683928dc7e154f6fbdf322..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 zcmeI%J#W)M7zgmPowl?hIRrzd$Tu*6O5SV;BocxUj9QU|k~k`zEH^%gg_A&RqtY#W zDK@r_e1=S&5EBa%;*#LdT8gN|&WHUwroF7GklgXRB>hPesU4E~ML(x@QeT$^V;lkyfB*y_009U< z00Izz00dTFU@R*e=}bl%?+5mw$9*0I?%<6dJrlK-Q8f)_R-e=j7LBr7TkK4E%Rh>q zX}mJoUTe2eZ5^=Z#=)I)F&}I%L#moS}mh#T8o*#nLd)&)9viCOyfJVit&-@xBZprzbYxJ zOurC15kDvpfB*y_009U<00Izz00bZa0SK(1fGR7herUT+nClMvT0tvi3tCpwSwXMd z(<^t&sw4uAdv0$@Kxs>4CDT(vPw9^pOb}aw00bZa0SG_<0uX=z1Rwwb2teSX0!c-c z7N-RARYl!g90|bQ$0uX=z1Rwwb2tWV=5P$##AaDr+Hx)&FIO2}$ zTj9u?H7fahv8?5ar3blUPOFsk`{f&wvQ4{ox5up@@T}f29Ql9l|0jfgr;|(AIhKR~ m1Rwwb2tWV=5P$##AOHafK;ZucrZ@kKg8}8bvb`BQ0Qe0Y1j(8J diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ed59e2..46f90ff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,13 @@ import { useState, useCallback, useEffect, useRef } from "react"; -import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal } from "lucide-react"; +import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal, RefreshCw, Images } from "lucide-react"; import { SearchBar } from "@/components/SearchBar"; import { WebResults } from "@/components/WebResults"; import { ImageResults } from "@/components/ImageResults"; import { SearchStats } from "@/components/SearchStats"; import { SettingsModal } from "@/components/SettingsModal"; import { ErrorToast } from "@/components/ErrorToast"; -import { search as apiSearch, isImageResult, type SearchResponse, type WebResult, type ImageResult } from "@/lib/api"; +import { BackgroundGallery } from "@/components/BackgroundGallery"; +import { search as apiSearch, isImageResult, getBackground, refreshBackground, type SearchResponse, type WebResult, type ImageResult, type BackgroundInfo } from "@/lib/api"; import { cn } from "@/lib/utils"; type Category = "web" | "images"; @@ -53,8 +54,18 @@ function App() { const [loading, setLoading] = useState(false); const [showSettings, setShowSettings] = useState(false); const [hasSearched, setHasSearched] = useState(!!initial.q); + const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds"); const statusRef = useRef(null); + // Background image state + const [bgInfo, setBgInfo] = useState(null); + const [bgRefreshing, setBgRefreshing] = useState(false); + + // Fetch background on mount + useEffect(() => { + getBackground().then(setBgInfo).catch(() => {}); + }, []); + const doSearch = useCallback( async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true) => { if (!q.trim()) return; @@ -100,6 +111,11 @@ function App() { // Handle browser back/forward useEffect(() => { const onPopState = () => { + if (window.location.pathname === "/backgrounds") { + setShowGallery(true); + return; + } + setShowGallery(false); const { q, cat, page: p, imageSize: size } = parseUrlState(); if (q) { doSearch(q, cat, p, size, false); @@ -137,48 +153,119 @@ function App() { setResponse(null); setPage(1); setImageSize(""); + setShowGallery(false); window.history.pushState(null, "", "/"); + // Re-fetch background in case it changed + getBackground().then(setBgInfo).catch(() => {}); + }; + + const handleRefreshBg = async () => { + setBgRefreshing(true); + try { + const info = await refreshBackground(); + setBgInfo(info); + } catch { + // ignore + } finally { + setBgRefreshing(false); + } + }; + + const handleShowGallery = () => { + setShowGallery(true); + window.history.pushState(null, "", "/backgrounds"); }; 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; + // Gallery page + if (showGallery) { + return ; + } + + const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null; + // Home page (no search yet) if (!hasSearched) { return ( -
+
+ {/* Background image */} + {bgUrl && ( +
+
+
+ )} +

- + Hey Search

-

Private metasearch engine

+

+ Private metasearch engine +

doSearch(q)} className="w-full" /> -
+
+ {bgUrl && ( + + )}
-
+ setShowSettings(false)} /> diff --git a/frontend/src/components/BackgroundGallery.tsx b/frontend/src/components/BackgroundGallery.tsx new file mode 100644 index 0000000..717af20 --- /dev/null +++ b/frontend/src/components/BackgroundGallery.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect, useCallback } from "react"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import { listBackgrounds, type BackgroundListItem } from "@/lib/api"; + +interface BackgroundGalleryProps { + onBack: () => void; +} + +export function BackgroundGallery({ onBack }: BackgroundGalleryProps) { + const [images, setImages] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + + const PER_PAGE = 20; + + const loadMore = useCallback(async (p: number) => { + setLoading(true); + try { + const items = await listBackgrounds(p, PER_PAGE); + if (p === 1) { + setImages(items); + } else { + setImages((prev) => [...prev, ...items]); + } + setHasMore(items.length >= PER_PAGE); + } catch (err) { + console.error("Failed to load backgrounds:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadMore(1); + }, [loadMore]); + + const handleLoadMore = () => { + const next = page + 1; + setPage(next); + loadMore(next); + }; + + return ( +
+ {/* Header */} +
+
+ +

Background Gallery

+ + {images.length} image{images.length !== 1 ? "s" : ""} + +
+
+ + {/* Image grid */} +
+ {images.length === 0 && !loading && ( +

No background images saved yet.

+ )} + + + + {/* Load more */} + {hasMore && ( +
+ +
+ )} + + {loading && images.length === 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 57bb037..8eb29f6 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,11 +1,11 @@ import { useState, useEffect, type FormEvent } from "react"; -import { Settings, ToggleLeft, ToggleRight, Plus, Trash2, ExternalLink, Database } from "lucide-react"; +import { Settings, ToggleLeft, ToggleRight, Plus, Trash2, ExternalLink, Database, ImageIcon } from "lucide-react"; import { getEngines, toggleEngine, type EngineInfo } from "@/lib/api"; import { getExcludedDomains, addExcludedDomain, removeExcludedDomain } from "@/lib/api"; import { getSettings, updateSettings, flushCache, type AppSettings } from "@/lib/api"; import { cn } from "@/lib/utils"; -type Tab = "engines" | "excluded" | "cache"; +type Tab = "engines" | "excluded" | "cache" | "background"; interface SettingsModalProps { open: boolean; @@ -46,6 +46,7 @@ export function SettingsModal({ open, onClose, initialTab = "engines" }: Setting { key: "engines" as const, label: "Engines" }, { key: "excluded" as const, label: "Excluded Sites" }, { key: "cache" as const, label: "Cache" }, + { key: "background" as const, label: "Background" }, ]).map(({ key, label }) => (
{/* Footer */} @@ -402,3 +404,125 @@ function CacheTab() {
); } + +const BG_REFRESH_PRESETS = [ + { minutes: 1, label: "1 min" }, + { minutes: 5, label: "5 min" }, + { minutes: 15, label: "15 min" }, + { minutes: 30, label: "30 min" }, + { minutes: 60, label: "1 hour" }, + { minutes: 360, label: "6 hours" }, + { minutes: 1440, label: "1 day" }, +]; + +function BackgroundTab() { + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [refreshMin, setRefreshMin] = useState(30); + + useEffect(() => { + getSettings() + .then((s) => { + setSettings(s); + setRefreshMin(s.bg_refresh_minutes); + }) + .finally(() => setLoading(false)); + }, []); + + const handleToggle = async () => { + setSaving(true); + try { + const updated = await updateSettings({ bg_enabled: !settings?.bg_enabled }); + setSettings(updated); + } catch { + // ignore + } finally { + setSaving(false); + } + }; + + const handleRefreshSave = async (min: number) => { + setRefreshMin(min); + setSaving(true); + try { + const updated = await updateSettings({ bg_refresh_minutes: min }); + setSettings(updated); + } catch { + // ignore + } finally { + setSaving(false); + } + }; + + if (loading) return

Loading…

; + + return ( +
+ {/* Enable/disable */} +
+
+
+
+

+ Show a random nature/landscape image from Unsplash on the home page. +

+
+ +
+ + {/* Refresh interval */} +
+ +
+ {BG_REFRESH_PRESETS.map((preset) => ( + + ))} +
+ {/* Slider */} +
+ setRefreshMin(Number(e.target.value))} + onMouseUp={() => handleRefreshSave(refreshMin)} + onTouchEnd={() => handleRefreshSave(refreshMin)} + className="flex-1 accent-primary" + aria-label="Background refresh interval minutes" + /> + + {refreshMin < 60 ? `${refreshMin}m` : refreshMin < 1440 ? `${(refreshMin / 60).toFixed(1)}h` : "1 day"} + +
+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9ea797a..f912701 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -149,6 +149,8 @@ export interface AppSettings { cache_ttl_hours: number; cache_available: boolean; redis_url: string; + bg_enabled: boolean; + bg_refresh_minutes: number; } export async function getSettings(): Promise { @@ -157,7 +159,7 @@ export async function getSettings(): Promise { return resp.json(); } -export async function updateSettings(settings: { cache_ttl_hours?: number; redis_url?: string }): Promise { +export async function updateSettings(settings: { cache_ttl_hours?: number; redis_url?: string; bg_enabled?: boolean; bg_refresh_minutes?: number }): Promise { const resp = await fetch(`${API_BASE}/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -172,3 +174,36 @@ export async function flushCache(): Promise<{ keys_deleted: number; message: str if (!resp.ok) throw new Error("Failed to flush cache"); return resp.json(); } + +// --- Background --- + +export interface BackgroundInfo { + filename: string | null; + url: string | null; + enabled: boolean; +} + +export interface BackgroundListItem { + filename: string; + url: string; + size_bytes: number; + created_at: number; +} + +export async function getBackground(): Promise { + const resp = await fetch(`${API_BASE}/background`); + if (!resp.ok) throw new Error("Failed to fetch background"); + return resp.json(); +} + +export async function refreshBackground(): Promise { + const resp = await fetch(`${API_BASE}/background/refresh`, { method: "POST" }); + if (!resp.ok) throw new Error("Failed to refresh background"); + return resp.json(); +} + +export async function listBackgrounds(page = 1, perPage = 20): Promise { + const resp = await fetch(`${API_BASE}/backgrounds?page=${page}&per_page=${perPage}`); + if (!resp.ok) throw new Error("Failed to list backgrounds"); + return resp.json(); +}