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/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.egg-info/
|
*.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_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"})
|
||||||
|
|||||||
@@ -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] = {
|
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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user