mirror of
https://github.com/wahyd4/hey-search.git
synced 2026-08-08 21:05:14 +10:00
Update settings!
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
+37
-26
@@ -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<SearchResponse | null>(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() {
|
||||
|
||||
<SearchBar onSearch={(q) => doSearch(q)} className="w-full" />
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4" /> Engines
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExcluded(true)}
|
||||
className="flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Ban className="h-4 w-4" /> Excluded Sites
|
||||
<Settings className="h-4 w-4" /> Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EngineSettings open={showSettings} onClose={() => setShowSettings(false)} />
|
||||
<ExcludedDomains open={showExcluded} onClose={() => setShowExcluded(false)} />
|
||||
<footer className="absolute bottom-6 text-center">
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
API Docs
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Results page
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
@@ -106,17 +109,10 @@ function App() {
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-accent"
|
||||
title="Engine settings"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExcluded(true)}
|
||||
className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-accent"
|
||||
title="Excluded sites"
|
||||
>
|
||||
<Ban className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
@@ -143,7 +139,7 @@ function App() {
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="mx-auto max-w-5xl px-4 py-6">
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
@@ -161,12 +157,27 @@ function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t px-4 py-3">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Hey Search</span>
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
API Docs
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Error toasts */}
|
||||
{response?.errors && <ErrorToast errors={response.errors} />}
|
||||
|
||||
{/* Settings modal */}
|
||||
<EngineSettings open={showSettings} onClose={() => setShowSettings(false)} />
|
||||
<ExcludedDomains open={showExcluded} onClose={() => setShowExcluded(false)} />
|
||||
<SettingsModal open={showSettings} onClose={() => setShowSettings(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Tab>(initialTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setTab(initialTab);
|
||||
}, [open, initialTab]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center" onClick={onClose}>
|
||||
<div className="fixed inset-0 bg-black/50" />
|
||||
<div
|
||||
className="relative z-10 flex w-full max-w-lg flex-col rounded-t-2xl bg-card shadow-2xl sm:rounded-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-6 pt-5 pb-0">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<Settings className="h-5 w-5" />
|
||||
Settings
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-sm text-muted-foreground hover:text-foreground">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b px-6">
|
||||
{([
|
||||
{ key: "engines" as const, label: "Engines" },
|
||||
{ key: "excluded" as const, label: "Excluded Sites" },
|
||||
]).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"relative px-4 py-3 text-sm font-medium transition-colors",
|
||||
tab === key
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{tab === key && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-h-[60vh] overflow-y-auto p-6">
|
||||
{tab === "engines" && <EnginesTab />}
|
||||
{tab === "excluded" && <ExcludedTab />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t px-6 py-3">
|
||||
<a
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
API Documentation — try requests interactively
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Engines tab ────────────────────────────────────────── */
|
||||
|
||||
function EnginesTab() {
|
||||
const [engines, setEngines] = useState<EngineInfo[]>([]);
|
||||
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 <p className="py-4 text-center text-muted-foreground">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable or disable search engines. Disabled engines are skipped during search.
|
||||
</p>
|
||||
{engines.map((engine) => (
|
||||
<div key={engine.name} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<p className="font-medium">{engine.display_name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[engine.supports_web && "Web", engine.supports_images && "Images"]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => handleToggle(engine.name, !engine.enabled)} className="text-foreground">
|
||||
{engine.enabled ? (
|
||||
<ToggleRight className="h-8 w-8 text-green-500" />
|
||||
) : (
|
||||
<ToggleLeft className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Excluded Sites tab ─────────────────────────────────── */
|
||||
|
||||
function ExcludedTab() {
|
||||
const [domains, setDomains] = useState<string[]>([]);
|
||||
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 (
|
||||
<div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Results from these domains will be hidden from search results.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAdd} className="mb-4 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-80 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="mb-2 text-sm text-destructive">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="py-4 text-center text-muted-foreground">Loading…</p>
|
||||
) : domains.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No excluded domains yet. Add one above.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{domains.map((domain) => (
|
||||
<li key={domain} className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<span className="truncate font-mono text-sm">{domain}</span>
|
||||
<button
|
||||
onClick={() => handleRemove(domain)}
|
||||
className="ml-2 shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user