mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-08 21:05:14 +10:00
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:
@@ -5,3 +5,4 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.egg-info/
|
||||
backend/data/
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
+96
-9
@@ -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<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(
|
||||
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 <BackgroundGallery onBack={handleGoHome} />;
|
||||
}
|
||||
|
||||
const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null;
|
||||
|
||||
// Home page (no search yet)
|
||||
if (!hasSearched) {
|
||||
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">
|
||||
<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
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<SearchBar onSearch={(q) => doSearch(q)} className="w-full" />
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
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
|
||||
</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>
|
||||
|
||||
<footer className="absolute bottom-6 text-center">
|
||||
<footer className={cn("absolute bottom-6 flex items-center gap-4", bgUrl ? "text-white/60" : "")}>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
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" />
|
||||
API Docs
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -70,6 +71,7 @@ export function SettingsModal({ open, onClose, initialTab = "engines" }: Setting
|
||||
{tab === "engines" && <EnginesTab />}
|
||||
{tab === "excluded" && <ExcludedTab />}
|
||||
{tab === "cache" && <CacheTab />}
|
||||
{tab === "background" && <BackgroundTab />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -402,3 +404,125 @@ function CacheTab() {
|
||||
</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
@@ -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<AppSettings> {
|
||||
@@ -157,7 +159,7 @@ export async function getSettings(): Promise<AppSettings> {
|
||||
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`, {
|
||||
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<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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user