diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b264643..b354d4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -226,14 +226,25 @@ function App() { const imageResults = response?.results.filter((r): r is ImageResult => isImageResult(r)) ?? []; const hasResults = (response?.results.length ?? 0) > 0; + // SettingsModal is always rendered here so it works on every page + const settingsModal = ( + setShowSettings(false)} /> + ); + // Gallery page if (showGallery) { - return setShowSettings(true)} onShowBookmarks={handleShowBookmarks} />; + return <> + setShowSettings(true)} onShowBookmarks={handleShowBookmarks} /> + {settingsModal} + ; } // Bookmarks page if (showBookmarks) { - return setShowSettings(true)} onShowGallery={handleShowGallery} />; + return <> + setShowSettings(true)} onShowGallery={handleShowGallery} /> + {settingsModal} + ; } const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null; @@ -259,6 +270,7 @@ function App() { onShowBookmarks={handleShowBookmarks} onShowGallery={handleShowGallery} transparent={!!bgUrl} + hideLogo /> {/* Center content */} @@ -336,7 +348,7 @@ function App() { )} - setShowSettings(false)} /> + {settingsModal} ); } @@ -512,7 +524,7 @@ function App() { {response?.errors && } {/* Settings modal */} - setShowSettings(false)} /> + {settingsModal} ); } diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx index d456e22..d38ac94 100644 --- a/frontend/src/components/AppHeader.tsx +++ b/frontend/src/components/AppHeader.tsx @@ -7,30 +7,51 @@ interface AppHeaderProps { onShowSettings: () => void; onShowBookmarks: () => void; onShowGallery: () => void; - /** Makes header transparent + white text (for home page with background image) */ + /** Hides the logo button (used on the home page) */ + hideLogo?: boolean; + /** Transparent header overlay (home page with background image) */ transparent?: boolean; /** Optional center content (e.g. search bar) */ children?: React.ReactNode; } +// Items ordered left→right. Rightmost item (Settings) gets the shortest delay, +// so it appears first creating the right-to-left unfold effect. +const NAV_ITEMS = [ + { id: "docs", icon: ExternalLink, label: "API Docs", href: "/docs" }, + { id: "gallery", icon: Images, label: "Backgrounds", href: null }, + { id: "bookmarks", icon: BookmarkIcon, label: "Bookmarks", href: null }, + { id: "settings", icon: Settings, label: "Settings", href: null }, +] as const; + +const STAGGER_MS = 60; + export function AppHeader({ onGoHome, onShowSettings, onShowBookmarks, onShowGallery, + hideLogo = false, transparent = false, children, }: AppHeaderProps) { const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); - // Close menu on outside click + const close = () => setMenuOpen(false); + + const actions: Record void> = { + settings: () => { onShowSettings(); close(); }, + bookmarks: () => { onShowBookmarks(); close(); }, + gallery: () => { onShowGallery(); close(); }, + docs: close, + }; + + // Close on outside click useEffect(() => { if (!menuOpen) return; const handler = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setMenuOpen(false); - } + if (menuRef.current && !menuRef.current.contains(e.target as Node)) close(); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); @@ -39,94 +60,123 @@ export function AppHeader({ // Close on Escape useEffect(() => { if (!menuOpen) return; - const handler = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); }; + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [menuOpen]); - const base = transparent + const headerBase = 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 btnBase = cn( + "rounded-full p-2 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", + 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 ( - setMenuOpen(false)}> - {icon} - {label} - - ); - } - return ( - - ); - }; + const itemBase = cn( + "flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium whitespace-nowrap", + "transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none", + transparent + ? "text-white/80 hover:text-white hover:bg-white/10" + : "text-muted-foreground hover:bg-accent" + ); return ( -
-
- {/* Logo */} - +
+
+ {/* Logo — hidden on home page */} + {!hideLogo && ( + + )} {/* Center slot */} - {children &&
{children}
} + {children + ?
{children}
+ :
+ } - {/* Spacer when no center content */} - {!children &&
} + {/* Right side: animated nav items + toggle button */} +
+ {/* Nav items — animate right→left on open */} + {NAV_ITEMS.map((item, i) => { + // Rightmost item (Settings, index 3) gets 0ms delay → appears first + const delay = menuOpen + ? (NAV_ITEMS.length - 1 - i) * STAGGER_MS + : 0; - {/* Menu button */} -
+ const sharedStyle: React.CSSProperties = { + transitionProperty: "opacity, transform", + transitionDuration: "180ms", + transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)", + transitionDelay: `${delay}ms`, + opacity: menuOpen ? 1 : 0, + transform: menuOpen ? "translateX(0)" : "translateX(16px)", + pointerEvents: menuOpen ? "auto" : "none", + }; + + const Icon = item.icon; + + if (item.href) { + return ( + + + ); + } + + return ( + + ); + })} + + {/* Toggle button */} - - {menuOpen && ( -
- {menuItem(, "Settings", onShowSettings)} - {menuItem(, "Bookmarks", onShowBookmarks)} - {menuItem(, "Backgrounds", onShowGallery)} -
- {menuItem(, "API Docs", () => {}, true, "/docs")} -
- )}