diff --git a/CHANGELOG.md b/CHANGELOG.md index f675ec5..02bfaab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,13 @@ - **Domain exclusion** — exclude specific websites from search results - SQLite-backed persistence (`backend/data/hey_search.db`) - REST API: `GET/POST /api/excluded-domains`, `DELETE /api/excluded-domains/{domain}` - - UI modal to view, add, and remove excluded domains - Sub-domain matching (excluding `example.com` also excludes `sub.example.com`) +### Changed + +- **Unified settings modal** — replaced separate Engines and Excluded Sites buttons with a single Settings button that opens a tabbed modal +- Added footer on both home and results pages with a link to the interactive API documentation (Swagger UI at `/docs`) + ## 1.0.0 — 2026-02-23 ### Added diff --git a/FEATURES.md b/FEATURES.md index b572ae6..4d6c9f7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -33,7 +33,9 @@ - Image results displayed in a responsive grid with hover previews and a full lightbox - Engine settings modal with toggle switches - Excluded domains management modal with add/remove UI +- **Unified settings modal** — single Settings button opens a tabbed modal (Engines, Excluded Sites) - Keyboard navigation for autocomplete suggestions (↑↓ arrows, Enter, Escape) +- Footer with link to interactive API documentation (Swagger UI) ## Deployment diff --git a/backend/data/hey_search.db b/backend/data/hey_search.db index 6275b2c..67769ad 100644 Binary files a/backend/data/hey_search.db and b/backend/data/hey_search.db differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 40894fd..bd20101 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,9 @@ import { useState, useCallback } from "react"; -import { Search, Settings, Globe, ImageIcon, Loader2, Ban } from "lucide-react"; +import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink } from "lucide-react"; import { SearchBar } from "@/components/SearchBar"; import { WebResults } from "@/components/WebResults"; import { ImageResults } from "@/components/ImageResults"; -import { EngineSettings } from "@/components/EngineSettings"; -import { ExcludedDomains } from "@/components/ExcludedDomains"; +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 { cn } from "@/lib/utils"; @@ -17,7 +16,6 @@ function App() { const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); const [showSettings, setShowSettings] = useState(false); - const [showExcluded, setShowExcluded] = useState(false); const [hasSearched, setHasSearched] = useState(false); const doSearch = useCallback( @@ -67,30 +65,35 @@ function App() { doSearch(q)} className="w-full" /> -
+
-
- setShowSettings(false)} /> - setShowExcluded(false)} /> + + + setShowSettings(false)} />
); } // Results page return ( -
+
{/* Header */}
@@ -106,17 +109,10 @@ function App() { -
{/* Category tabs */} @@ -143,7 +139,7 @@ function App() {
{/* Content */} -
+
{loading ? (
@@ -161,12 +157,27 @@ function App() { )}
+ {/* Footer */} + + {/* Error toasts */} {response?.errors && } {/* Settings modal */} - setShowSettings(false)} /> - setShowExcluded(false)} /> + setShowSettings(false)} />
); } diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx new file mode 100644 index 0000000..3db2588 --- /dev/null +++ b/frontend/src/components/SettingsModal.tsx @@ -0,0 +1,223 @@ +import { useState, useEffect, type FormEvent } from "react"; +import { Settings, ToggleLeft, ToggleRight, Plus, Trash2, ExternalLink } from "lucide-react"; +import { getEngines, toggleEngine, type EngineInfo } from "@/lib/api"; +import { getExcludedDomains, addExcludedDomain, removeExcludedDomain } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +type Tab = "engines" | "excluded"; + +interface SettingsModalProps { + open: boolean; + onClose: () => void; + initialTab?: Tab; +} + +export function SettingsModal({ open, onClose, initialTab = "engines" }: SettingsModalProps) { + const [tab, setTab] = useState(initialTab); + + useEffect(() => { + if (open) setTab(initialTab); + }, [open, initialTab]); + + if (!open) return null; + + return ( +
+
+
e.stopPropagation()} + > + {/* Header */} +
+

+ + Settings +

+ +
+ + {/* Tabs */} +
+ {([ + { key: "engines" as const, label: "Engines" }, + { key: "excluded" as const, label: "Excluded Sites" }, + ]).map(({ key, label }) => ( + + ))} +
+ + {/* Content */} +
+ {tab === "engines" && } + {tab === "excluded" && } +
+ + {/* Footer */} + +
+
+ ); +} + +/* ── Engines tab ────────────────────────────────────────── */ + +function EnginesTab() { + const [engines, setEngines] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getEngines().then(setEngines).finally(() => setLoading(false)); + }, []); + + const handleToggle = async (name: string, enabled: boolean) => { + try { + const updated = await toggleEngine(name, enabled); + setEngines((prev) => prev.map((e) => (e.name === updated.name ? updated : e))); + } catch (err) { + console.error("Failed to toggle engine:", err); + } + }; + + if (loading) return

Loading…

; + + return ( +
+

+ Enable or disable search engines. Disabled engines are skipped during search. +

+ {engines.map((engine) => ( +
+
+

{engine.display_name}

+

+ {[engine.supports_web && "Web", engine.supports_images && "Images"] + .filter(Boolean) + .join(" · ")} +

+
+ +
+ ))} +
+ ); +} + +/* ── Excluded Sites tab ─────────────────────────────────── */ + +function ExcludedTab() { + const [domains, setDomains] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + getExcludedDomains() + .then(setDomains) + .finally(() => setLoading(false)); + }, []); + + const handleAdd = async (e: FormEvent) => { + e.preventDefault(); + const domain = input.trim(); + if (!domain) return; + setError(""); + try { + const updated = await addExcludedDomain(domain); + setDomains(updated); + setInput(""); + } catch { + setError("Failed to add domain"); + } + }; + + const handleRemove = async (domain: string) => { + try { + const updated = await removeExcludedDomain(domain); + setDomains(updated); + } catch { + setError("Failed to remove domain"); + } + }; + + return ( +
+

+ Results from these domains will be hidden from search results. +

+ +
+ setInput(e.target.value)} + placeholder="e.g. example.com" + className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + +
+ + {error &&

{error}

} + + {loading ? ( +

Loading…

+ ) : domains.length === 0 ? ( +

+ No excluded domains yet. Add one above. +

+ ) : ( +
    + {domains.map((domain) => ( +
  • + {domain} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index be1955a..acfb4ca 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ server: { proxy: { '/api': 'http://localhost:8000', + '/docs': 'http://localhost:8000', + '/redoc': 'http://localhost:8000', + '/openapi.json': 'http://localhost:8000', }, }, })