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>
This commit is contained in:
2026-02-23 18:54:44 +11:00
co-authored by Copilot
parent df87ce925b
commit 4bd375cb83
9 changed files with 612 additions and 12 deletions
+1
View File
@@ -5,3 +5,4 @@ node_modules/
dist/ dist/
.env .env
*.egg-info/ *.egg-info/
backend/data/
+97
View File
@@ -237,11 +237,15 @@ class SettingsResponse(BaseModel):
cache_ttl_hours: float = Field(description="Cache TTL in hours (0 = disabled, max 168 = 1 week)") 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") 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)") 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): 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)") 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)") 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( @router.get(
@@ -263,6 +267,8 @@ async def api_get_settings():
cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")), cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")),
cache_available=is_cache_available(), cache_available=is_cache_available(),
redis_url=settings.get("redis_url", ""), 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: if body.redis_url is not None:
set_setting("redis_url", body.redis_url) set_setting("redis_url", body.redis_url)
await reconnect_redis(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() settings = get_all_settings()
return SettingsResponse( return SettingsResponse(
cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")), cache_ttl_hours=float(settings.get("cache_ttl_hours", "6")),
cache_available=is_cache_available(), cache_available=is_cache_available(),
redis_url=settings.get("redis_url", ""), 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(): async def api_flush_cache():
count = await flush_cache() count = await flush_cache()
return CacheFlushResponse(keys_deleted=count, message=f"Deleted {count} cached entries") 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"})
+125
View File
@@ -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
+2
View File
@@ -12,6 +12,8 @@ logger = logging.getLogger(__name__)
DEFAULTS: dict[str, str] = { DEFAULTS: dict[str, str] = {
"cache_ttl_hours": "6", "cache_ttl_hours": "6",
"redis_url": "", "redis_url": "",
"bg_enabled": "true",
"bg_refresh_minutes": "30",
} }
Binary file not shown.
+96 -9
View File
@@ -1,12 +1,13 @@
import { useState, useCallback, useEffect, useRef } from "react"; 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 { SearchBar } from "@/components/SearchBar";
import { WebResults } from "@/components/WebResults"; import { WebResults } from "@/components/WebResults";
import { ImageResults } from "@/components/ImageResults"; import { ImageResults } from "@/components/ImageResults";
import { SearchStats } from "@/components/SearchStats"; import { SearchStats } from "@/components/SearchStats";
import { SettingsModal } from "@/components/SettingsModal"; import { SettingsModal } from "@/components/SettingsModal";
import { ErrorToast } from "@/components/ErrorToast"; 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"; import { cn } from "@/lib/utils";
type Category = "web" | "images"; type Category = "web" | "images";
@@ -53,8 +54,18 @@ function App() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [hasSearched, setHasSearched] = useState(!!initial.q); const [hasSearched, setHasSearched] = useState(!!initial.q);
const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds");
const statusRef = useRef<HTMLDivElement>(null); const statusRef = useRef<HTMLDivElement>(null);
// Background image state
const [bgInfo, setBgInfo] = useState<BackgroundInfo | null>(null);
const [bgRefreshing, setBgRefreshing] = useState(false);
// Fetch background on mount
useEffect(() => {
getBackground().then(setBgInfo).catch(() => {});
}, []);
const doSearch = useCallback( const doSearch = useCallback(
async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true) => { async (q: string, cat: Category = category, p: number = 1, size: ImageSize = imageSize, updateUrl = true) => {
if (!q.trim()) return; if (!q.trim()) return;
@@ -100,6 +111,11 @@ function App() {
// Handle browser back/forward // Handle browser back/forward
useEffect(() => { useEffect(() => {
const onPopState = () => { const onPopState = () => {
if (window.location.pathname === "/backgrounds") {
setShowGallery(true);
return;
}
setShowGallery(false);
const { q, cat, page: p, imageSize: size } = parseUrlState(); const { q, cat, page: p, imageSize: size } = parseUrlState();
if (q) { if (q) {
doSearch(q, cat, p, size, false); doSearch(q, cat, p, size, false);
@@ -137,48 +153,119 @@ function App() {
setResponse(null); setResponse(null);
setPage(1); setPage(1);
setImageSize(""); setImageSize("");
setShowGallery(false);
window.history.pushState(null, "", "/"); 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 webResults = response?.results.filter((r): r is WebResult => !isImageResult(r)) ?? [];
const imageResults = response?.results.filter((r): r is ImageResult => isImageResult(r)) ?? []; const imageResults = response?.results.filter((r): r is ImageResult => isImageResult(r)) ?? [];
const hasResults = (response?.results.length ?? 0) > 0; const hasResults = (response?.results.length ?? 0) > 0;
// Gallery page
if (showGallery) {
return <BackgroundGallery onBack={handleGoHome} />;
}
const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null;
// Home page (no search yet) // Home page (no search yet)
if (!hasSearched) { if (!hasSearched) {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center px-4"> <div className="relative flex min-h-screen flex-col items-center justify-center px-4">
{/* Background image */}
{bgUrl && (
<div
className="absolute inset-0 -z-10 bg-cover bg-center transition-opacity duration-700"
style={{ backgroundImage: `url(${bgUrl})` }}
>
<div className="absolute inset-0 bg-black/40 dark:bg-black/60" />
</div>
)}
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl"> <h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"> <span className={cn(
"bg-clip-text text-transparent",
bgUrl
? "bg-gradient-to-r from-white to-white/90"
: "bg-gradient-to-r from-blue-600 to-purple-600"
)}>
Hey Search Hey Search
</span> </span>
</h1> </h1>
<p className="mt-2 text-muted-foreground">Private metasearch engine</p> <p className={cn("mt-2", bgUrl ? "text-white/70" : "text-muted-foreground")}>
Private metasearch engine
</p>
</div> </div>
<SearchBar onSearch={(q) => doSearch(q)} className="w-full" /> <SearchBar onSearch={(q) => doSearch(q)} className="w-full" />
<div className="mt-6"> <div className="mt-6 flex items-center gap-3">
<button <button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
aria-label="Open settings" aria-label="Open settings"
className="flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm text-muted-foreground hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" className={cn(
"flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm hover:bg-accent/20 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
bgUrl ? "border-white/30 text-white/80 hover:text-white" : "text-muted-foreground"
)}
> >
<Settings className="h-4 w-4" aria-hidden="true" /> Settings <Settings className="h-4 w-4" aria-hidden="true" /> Settings
</button> </button>
{bgUrl && (
<button
onClick={handleRefreshBg}
disabled={bgRefreshing}
aria-label="New background image"
className="flex items-center gap-1.5 rounded-full border border-white/30 px-4 py-2 text-sm text-white/80 hover:text-white hover:bg-accent/20 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<RefreshCw className={cn("h-4 w-4", bgRefreshing && "animate-spin")} aria-hidden="true" />
{bgRefreshing ? "Loading…" : "New image"}
</button>
)}
</div> </div>
<footer className="absolute bottom-6 text-center"> <footer className={cn("absolute bottom-6 flex items-center gap-4", bgUrl ? "text-white/60" : "")}>
<a <a
href="/docs" href="/docs"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors" 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"
)}
> >
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" /> <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
API Docs API Docs
</a> </a>
<button
onClick={handleShowGallery}
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"
)}
>
<Images className="h-3.5 w-3.5" aria-hidden="true" />
Backgrounds
</button>
</footer> </footer>
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} /> <SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
@@ -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<BackgroundListItem[]>([]);
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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3">
<button
onClick={onBack}
aria-label="Go back"
className="rounded-full p-2 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h1 className="text-lg font-semibold">Background Gallery</h1>
<span className="text-sm text-muted-foreground">
{images.length} image{images.length !== 1 ? "s" : ""}
</span>
</div>
</header>
{/* Image grid */}
<main className="mx-auto max-w-6xl px-4 py-6">
{images.length === 0 && !loading && (
<p className="text-center text-muted-foreground py-12">No background images saved yet.</p>
)}
<div className="columns-1 gap-4 sm:columns-2 md:columns-3">
{images.map((img) => (
<a
key={img.filename}
href={img.url}
target="_blank"
rel="noopener noreferrer"
className="group relative mb-4 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
src={img.url}
alt={img.filename}
loading="lazy"
className="w-full object-cover transition-transform group-hover:scale-[1.02]"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
<p className="text-xs text-white/80">
{new Date(img.created_at * 1000).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
<p className="text-[10px] text-white/60">
{(img.size_bytes / 1024).toFixed(0)} KB
</p>
</div>
</a>
))}
</div>
{/* Load more */}
{hasMore && (
<div className="mt-8 text-center">
<button
onClick={handleLoadMore}
disabled={loading}
className="rounded-full border px-6 py-2.5 text-sm font-medium hover:bg-accent disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</span>
) : (
"Load more"
)}
</button>
</div>
)}
{loading && images.length === 0 && (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
</main>
</div>
);
}
+126 -2
View File
@@ -1,11 +1,11 @@
import { useState, useEffect, type FormEvent } from "react"; 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 { getEngines, toggleEngine, type EngineInfo } from "@/lib/api";
import { getExcludedDomains, addExcludedDomain, removeExcludedDomain } from "@/lib/api"; import { getExcludedDomains, addExcludedDomain, removeExcludedDomain } from "@/lib/api";
import { getSettings, updateSettings, flushCache, type AppSettings } from "@/lib/api"; import { getSettings, updateSettings, flushCache, type AppSettings } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Tab = "engines" | "excluded" | "cache"; type Tab = "engines" | "excluded" | "cache" | "background";
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
@@ -46,6 +46,7 @@ export function SettingsModal({ open, onClose, initialTab = "engines" }: Setting
{ key: "engines" as const, label: "Engines" }, { key: "engines" as const, label: "Engines" },
{ key: "excluded" as const, label: "Excluded Sites" }, { key: "excluded" as const, label: "Excluded Sites" },
{ key: "cache" as const, label: "Cache" }, { key: "cache" as const, label: "Cache" },
{ key: "background" as const, label: "Background" },
]).map(({ key, label }) => ( ]).map(({ key, label }) => (
<button <button
key={key} key={key}
@@ -70,6 +71,7 @@ export function SettingsModal({ open, onClose, initialTab = "engines" }: Setting
{tab === "engines" && <EnginesTab />} {tab === "engines" && <EnginesTab />}
{tab === "excluded" && <ExcludedTab />} {tab === "excluded" && <ExcludedTab />}
{tab === "cache" && <CacheTab />} {tab === "cache" && <CacheTab />}
{tab === "background" && <BackgroundTab />}
</div> </div>
{/* Footer */} {/* Footer */}
@@ -402,3 +404,125 @@ function CacheTab() {
</div> </div>
); );
} }
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<AppSettings | null>(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 <p className="py-4 text-center text-muted-foreground">Loading</p>;
return (
<div className="space-y-5">
{/* Enable/disable */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium">Homepage Background</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
Show a random nature/landscape image from Unsplash on the home page.
</p>
</div>
<button
onClick={handleToggle}
disabled={saving}
aria-label={settings?.bg_enabled ? "Disable background" : "Enable background"}
className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded disabled:opacity-50"
>
{settings?.bg_enabled ? (
<ToggleRight className="h-8 w-8 text-primary" />
) : (
<ToggleLeft className="h-8 w-8 text-muted-foreground" />
)}
</button>
</div>
{/* Refresh interval */}
<div className={cn(!settings?.bg_enabled && "opacity-50 pointer-events-none")}>
<label className="text-sm font-medium block mb-2">Refresh interval</label>
<div className="flex flex-wrap gap-2">
{BG_REFRESH_PRESETS.map((preset) => (
<button
key={preset.minutes}
onClick={() => handleRefreshSave(preset.minutes)}
disabled={saving}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:opacity-40",
refreshMin === preset.minutes
? "bg-primary text-primary-foreground"
: "border text-muted-foreground hover:bg-accent"
)}
>
{preset.label}
</button>
))}
</div>
{/* Slider */}
<div className="mt-3 flex items-center gap-3">
<input
type="range"
min={1}
max={1440}
step={1}
value={refreshMin}
onChange={(e) => setRefreshMin(Number(e.target.value))}
onMouseUp={() => handleRefreshSave(refreshMin)}
onTouchEnd={() => handleRefreshSave(refreshMin)}
className="flex-1 accent-primary"
aria-label="Background refresh interval minutes"
/>
<span className="w-20 text-right text-sm tabular-nums text-muted-foreground">
{refreshMin < 60 ? `${refreshMin}m` : refreshMin < 1440 ? `${(refreshMin / 60).toFixed(1)}h` : "1 day"}
</span>
</div>
</div>
</div>
);
}
+36 -1
View File
@@ -149,6 +149,8 @@ export interface AppSettings {
cache_ttl_hours: number; cache_ttl_hours: number;
cache_available: boolean; cache_available: boolean;
redis_url: string; redis_url: string;
bg_enabled: boolean;
bg_refresh_minutes: number;
} }
export async function getSettings(): Promise<AppSettings> { export async function getSettings(): Promise<AppSettings> {
@@ -157,7 +159,7 @@ export async function getSettings(): Promise<AppSettings> {
return resp.json(); return resp.json();
} }
export async function updateSettings(settings: { cache_ttl_hours?: number; redis_url?: string }): Promise<AppSettings> { export async function updateSettings(settings: { cache_ttl_hours?: number; redis_url?: string; bg_enabled?: boolean; bg_refresh_minutes?: number }): Promise<AppSettings> {
const resp = await fetch(`${API_BASE}/settings`, { const resp = await fetch(`${API_BASE}/settings`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, 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"); if (!resp.ok) throw new Error("Failed to flush cache");
return resp.json(); 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<BackgroundInfo> {
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<BackgroundInfo> {
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<BackgroundListItem[]> {
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();
}