feat: unified header with hamburger menu across all pages

- New AppHeader component: logo on left, optional center slot, menu icon on right
- Clicking menu icon opens dropdown: Settings, Bookmarks, Backgrounds, API Docs
- Dropdown closes on outside click and Escape key
- Transparent mode for home page when background image is active
- All pages (home, results, gallery, bookmarks) now use AppHeader consistently
- Results page: search bar in header center slot, category tabs in separate bar below
- Home page: flex-col layout, content vertically centered in remaining space
- Footer simplified to just the app name

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-23 19:17:08 +11:00
co-authored by Copilot
parent d9a4fa9b8b
commit 092d9ad94b
4 changed files with 246 additions and 178 deletions
+71 -141
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Search, Settings, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal, RefreshCw, Images, BookmarkIcon } from "lucide-react";
import { Search, Globe, ImageIcon, Loader2, ExternalLink, ChevronLeft, ChevronRight, SlidersHorizontal, RefreshCw } from "lucide-react";
import { SearchBar } from "@/components/SearchBar";
import { WebResults } from "@/components/WebResults";
import { ImageResults } from "@/components/ImageResults";
@@ -8,6 +8,7 @@ import { SettingsModal } from "@/components/SettingsModal";
import { ErrorToast } from "@/components/ErrorToast";
import { BackgroundGallery } from "@/components/BackgroundGallery";
import { Bookmarks } from "@/components/Bookmarks";
import { AppHeader } from "@/components/AppHeader";
import { search as apiSearch, isImageResult, getBackground, refreshBackground, getBookmarkedUrls, addBookmark, removeBookmarkByUrl, type SearchResponse, type WebResult, type ImageResult, type BackgroundInfo } from "@/lib/api";
import { cn } from "@/lib/utils";
@@ -227,12 +228,12 @@ function App() {
// Gallery page
if (showGallery) {
return <BackgroundGallery onBack={handleGoHome} />;
return <BackgroundGallery onBack={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowBookmarks={handleShowBookmarks} />;
}
// Bookmarks page
if (showBookmarks) {
return <Bookmarks />;
return <Bookmarks onGoHome={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowGallery={handleShowGallery} />;
}
const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null;
@@ -240,7 +241,7 @@ function App() {
// Home page (no search yet)
if (!hasSearched) {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center px-4">
<div className="relative flex min-h-screen flex-col">
{/* Background image */}
{bgUrl && (
<div
@@ -251,101 +252,61 @@ function App() {
</div>
)}
{/* Top-right: settings + nav menu */}
<div className="absolute top-4 right-4 flex items-center gap-1">
<button
onClick={handleShowBookmarks}
aria-label="Bookmarks"
title="Bookmarks"
className={cn(
"rounded-full p-2 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
bgUrl ? "text-white/70 hover:text-white hover:bg-white/10" : "text-muted-foreground hover:bg-accent"
)}
>
<BookmarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
<button
onClick={handleShowGallery}
aria-label="Background gallery"
title="Backgrounds"
className={cn(
"rounded-full p-2 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
bgUrl ? "text-white/70 hover:text-white hover:bg-white/10" : "text-muted-foreground hover:bg-accent"
)}
>
<Images className="h-5 w-5" aria-hidden="true" />
</button>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
aria-label="API Docs"
title="API Docs"
className={cn(
"rounded-full p-2 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
bgUrl ? "text-white/70 hover:text-white hover:bg-white/10" : "text-muted-foreground hover:bg-accent"
)}
>
<ExternalLink className="h-5 w-5" aria-hidden="true" />
</a>
<div className={cn("mx-1 h-5 w-px", bgUrl ? "bg-white/20" : "bg-border")} aria-hidden="true" />
<button
onClick={() => setShowSettings(true)}
aria-label="Open settings"
title="Settings"
className={cn(
"rounded-full p-2 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
bgUrl ? "text-white/80 hover:text-white hover:bg-white/10" : "text-muted-foreground hover:bg-accent"
)}
>
<Settings className="h-5 w-5" aria-hidden="true" />
</button>
</div>
{/* Shared header */}
<AppHeader
onGoHome={handleGoHome}
onShowSettings={() => setShowSettings(true)}
onShowBookmarks={handleShowBookmarks}
onShowGallery={handleShowGallery}
transparent={!!bgUrl}
/>
{/* Center content */}
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
<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={cn("mt-2", bgUrl ? "text-white/70" : "text-muted-foreground")}>
Private metasearch engine
</p>
</div>
<div className="flex flex-1 flex-col items-center justify-center px-4 pb-20">
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
<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={cn("mt-2", bgUrl ? "text-white/70" : "text-muted-foreground")}>
Private metasearch engine
</p>
</div>
<SearchBar onSearch={(q) => doSearch(q, category)} className="w-full" />
<SearchBar onSearch={(q) => doSearch(q, category)} className="w-full" />
{/* Category switch */}
<div className="mt-4 flex items-center gap-2" role="group" aria-label="Search category">
{([
{ key: "web" as const, label: "Web", icon: Globe },
{ key: "images" as const, label: "Images", icon: ImageIcon },
]).map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setCategory(key)}
aria-pressed={category === key}
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
category === key
? bgUrl
? "bg-white/20 text-white border border-white/30"
: "bg-primary text-primary-foreground"
: bgUrl
? "text-white/60 hover:text-white hover:bg-white/10 border border-transparent"
: "text-muted-foreground hover:bg-accent border border-transparent"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
</button>
))}
{/* Category switch */}
<div className="mt-4 flex items-center gap-2" role="group" aria-label="Search category">
{([
{ key: "web" as const, label: "Web", icon: Globe },
{ key: "images" as const, label: "Images", icon: ImageIcon },
]).map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setCategory(key)}
aria-pressed={category === key}
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
category === key
? bgUrl
? "bg-white/20 text-white border border-white/30"
: "bg-primary text-primary-foreground"
: bgUrl
? "text-white/60 hover:text-white hover:bg-white/10 border border-transparent"
: "text-muted-foreground hover:bg-accent border border-transparent"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{label}
</button>
))}
</div>
</div>
{/* Bottom-right: new image button */}
@@ -402,30 +363,19 @@ function App() {
{statusMessage}
</div>
{/* 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">
<button
onClick={handleGoHome}
aria-label="Go to homepage"
className="shrink-0 text-xl font-bold focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded"
>
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
HS
</span>
</button>
<SearchBar initialQuery={query} onSearch={(q) => doSearch(q)} className="flex-1" />
<button
onClick={() => setShowSettings(true)}
className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label="Open settings"
>
<Settings className="h-5 w-5" aria-hidden="true" />
</button>
</div>
{/* Shared header with search bar in center slot */}
<AppHeader
onGoHome={handleGoHome}
onShowSettings={() => setShowSettings(true)}
onShowBookmarks={handleShowBookmarks}
onShowGallery={handleShowGallery}
>
<SearchBar initialQuery={query} onSearch={(q) => doSearch(q)} />
</AppHeader>
{/* Category tabs */}
<nav aria-label="Search categories" className="flex items-center gap-1 px-4 pb-2">
{/* Category / filter bar */}
<div className="sticky top-[57px] z-30 border-b bg-background/95 backdrop-blur">
<nav aria-label="Search categories" className="flex items-center gap-1 px-4 py-2">
{([
{ key: "web" as const, label: "Web", icon: Globe },
{ key: "images" as const, label: "Images", icon: ImageIcon },
@@ -469,7 +419,7 @@ function App() {
</>
)}
</nav>
</header>
</div>
{/* Content */}
<main id="main-content" className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
@@ -554,28 +504,8 @@ function App() {
</main>
{/* Footer */}
<footer className="border-t px-4 py-3">
<div className="mx-auto flex max-w-6xl items-center justify-between text-xs text-muted-foreground">
<span>Hey Search</span>
<div className="flex items-center gap-3">
<button
onClick={handleShowBookmarks}
className="flex items-center gap-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors"
>
<BookmarkIcon className="h-3 w-3" aria-hidden="true" />
Bookmarks
</button>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded transition-colors"
>
<ExternalLink className="h-3 w-3" aria-hidden="true" />
API Docs
</a>
</div>
</div>
<footer className="border-t px-4 py-3 text-xs text-muted-foreground">
<div className="mx-auto max-w-6xl text-center">Hey Search</div>
</footer>
{/* Error toasts */}
+134
View File
@@ -0,0 +1,134 @@
import { useState, useEffect, useRef } from "react";
import { Menu, X, Settings, BookmarkIcon, Images, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
interface AppHeaderProps {
onGoHome: () => void;
onShowSettings: () => void;
onShowBookmarks: () => void;
onShowGallery: () => void;
/** Makes header transparent + white text (for home page with background image) */
transparent?: boolean;
/** Optional center content (e.g. search bar) */
children?: React.ReactNode;
}
export function AppHeader({
onGoHome,
onShowSettings,
onShowBookmarks,
onShowGallery,
transparent = false,
children,
}: AppHeaderProps) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuOpen]);
// Close on Escape
useEffect(() => {
if (!menuOpen) return;
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); };
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [menuOpen]);
const base = transparent
? "border-transparent bg-transparent"
: "border-b bg-background/95 backdrop-blur";
const iconCls = transparent
? "text-white/80 hover:text-white hover:bg-white/10"
: "text-muted-foreground hover:bg-accent";
const logoCls = transparent
? "text-white font-bold"
: "bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent font-bold";
const dropdownCls = "absolute right-0 top-full mt-2 w-48 rounded-xl border bg-popover shadow-lg z-50 overflow-hidden";
const menuItem = (
icon: React.ReactNode,
label: string,
action: () => void,
isLink?: boolean,
href?: string,
) => {
const cls = "flex w-full items-center gap-3 px-4 py-3 text-sm text-foreground hover:bg-accent transition-colors focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none";
if (isLink && href) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={cls} onClick={() => setMenuOpen(false)}>
{icon}
{label}
</a>
);
}
return (
<button className={cls} onClick={() => { action(); setMenuOpen(false); }}>
{icon}
{label}
</button>
);
};
return (
<header className={cn("sticky top-0 z-40 border", base)}>
<div className="flex items-center gap-3 px-4 py-3">
{/* Logo */}
<button
onClick={onGoHome}
aria-label="Go to homepage"
className={cn("shrink-0 text-xl focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded", logoCls)}
>
HS
</button>
{/* Center slot */}
{children && <div className="flex-1 min-w-0">{children}</div>}
{/* Spacer when no center content */}
{!children && <div className="flex-1" />}
{/* Menu button */}
<div className="relative shrink-0" ref={menuRef}>
<button
onClick={() => setMenuOpen((v) => !v)}
aria-label={menuOpen ? "Close menu" : "Open menu"}
aria-expanded={menuOpen}
aria-haspopup="menu"
className={cn(
"rounded-full p-2 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
iconCls
)}
>
{menuOpen
? <X className="h-5 w-5" aria-hidden="true" />
: <Menu className="h-5 w-5" aria-hidden="true" />
}
</button>
{menuOpen && (
<div className={dropdownCls} role="menu" aria-label="Site navigation">
{menuItem(<Settings className="h-4 w-4" />, "Settings", onShowSettings)}
{menuItem(<BookmarkIcon className="h-4 w-4" />, "Bookmarks", onShowBookmarks)}
{menuItem(<Images className="h-4 w-4" />, "Backgrounds", onShowGallery)}
<div className="mx-3 border-t" />
{menuItem(<ExternalLink className="h-4 w-4" />, "API Docs", () => {}, true, "/docs")}
</div>
)}
</div>
</div>
</header>
);
}
+18 -17
View File
@@ -1,12 +1,15 @@
import { useState, useEffect, useCallback } from "react";
import { ArrowLeft, Loader2, ExternalLink } from "lucide-react";
import { Loader2, ExternalLink } from "lucide-react";
import { listBackgrounds, type BackgroundListItem } from "@/lib/api";
import { AppHeader } from "@/components/AppHeader";
interface BackgroundGalleryProps {
onBack: () => void;
onShowSettings: () => void;
onShowBookmarks: () => void;
}
export function BackgroundGallery({ onBack }: BackgroundGalleryProps) {
export function BackgroundGallery({ onBack, onShowSettings, onShowBookmarks }: BackgroundGalleryProps) {
const [images, setImages] = useState<BackgroundListItem[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
@@ -43,22 +46,19 @@ export function BackgroundGallery({ onBack }: BackgroundGalleryProps) {
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" : ""}
<AppHeader
onGoHome={onBack}
onShowSettings={onShowSettings}
onShowBookmarks={onShowBookmarks}
onShowGallery={() => {}}
>
<h1 className="text-base font-semibold">
Background Gallery
<span className="ml-2 text-sm font-normal text-muted-foreground">
({images.length})
</span>
</div>
</header>
</h1>
</AppHeader>
{/* Image grid */}
<main className="mx-auto max-w-6xl px-4 py-6">
@@ -141,3 +141,4 @@ export function BackgroundGallery({ onBack }: BackgroundGalleryProps) {
</div>
);
}
+23 -20
View File
@@ -1,11 +1,18 @@
import { useState, useEffect, useCallback } from "react";
import { ArrowLeft, Trash2, ExternalLink, Loader2 } from "lucide-react";
import { Trash2, ExternalLink, Loader2 } from "lucide-react";
import type { Bookmark } from "@/lib/api";
import { getBookmarks, removeBookmark } from "@/lib/api";
import { AppHeader } from "@/components/AppHeader";
type FilterType = "" | "web" | "image";
export function Bookmarks() {
interface BookmarksProps {
onGoHome: () => void;
onShowSettings: () => void;
onShowGallery: () => void;
}
export function Bookmarks({ onGoHome, onShowSettings, onShowGallery }: BookmarksProps) {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@@ -42,11 +49,6 @@ export function Bookmarks() {
load(next, filter);
};
const goHome = () => {
window.history.pushState({}, "", "/");
window.dispatchEvent(new PopStateEvent("popstate"));
};
const tabs: { label: string; value: FilterType }[] = [
{ label: "All", value: "" },
{ label: "Web", value: "web" },
@@ -56,20 +58,20 @@ export function Bookmarks() {
const hasMore = bookmarks.length < total;
return (
<div className="mx-auto min-h-screen max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<button
onClick={goHome}
aria-label="Go back home"
className="rounded-full p-2 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<h1 className="text-2xl font-bold">Bookmarks</h1>
<span className="text-sm text-muted-foreground">({total})</span>
</div>
<div className="min-h-screen bg-background">
<AppHeader
onGoHome={onGoHome}
onShowSettings={onShowSettings}
onShowBookmarks={() => {}}
onShowGallery={onShowGallery}
>
<h1 className="text-base font-semibold">
Bookmarks
<span className="ml-2 text-sm font-normal text-muted-foreground">({total})</span>
</h1>
</AppHeader>
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Filter tabs */}
<div className="mb-6 flex gap-2" role="tablist" aria-label="Bookmark type filter">
{tabs.map((tab) => (
@@ -209,6 +211,7 @@ export function Bookmarks() {
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
</div>
</div>
);
}