diff --git a/apps/web/src/components/layout/avatar-dropdown.tsx b/apps/web/src/components/layout/avatar-dropdown.tsx new file mode 100644 index 00000000..39a2bb89 --- /dev/null +++ b/apps/web/src/components/layout/avatar-dropdown.tsx @@ -0,0 +1,158 @@ +import { ExternalLink, LogOut, Settings } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useAuth } from "@/hooks/use-auth"; +import { clearToken } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +interface AvatarDropdownProps { + onSettingsClick: () => void; + variant?: "light" | "dark"; +} + +export function AvatarDropdown({ onSettingsClick, variant = "light" }: AvatarDropdownProps) { + const { t } = useTranslation(); + const { authEnabled } = useAuth(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const username = localStorage.getItem("snapotter-username") || "admin"; + const initial = username.charAt(0).toUpperCase(); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + const handleLogout = async () => { + try { + const res = await fetch("/api/auth/logout", { method: "POST" }); + const data = await res.json().catch(() => ({})); + clearToken(); + localStorage.removeItem("snapotter-username"); + if (data.logoutUrl) { + window.location.href = data.logoutUrl; + } else { + window.location.href = "/login"; + } + } catch { + clearToken(); + localStorage.removeItem("snapotter-username"); + window.location.href = "/login"; + } + }; + + return ( +
+ + {open && ( +
+ {/* User info */} +
+ {username} +
+ +
+ + {/* Settings */} + + + {/* Docs */} + + + {t.settings.about.docsLink} + + + {/* API Reference */} + + + {t.settings.about.apiRefLink} + + + {authEnabled && ( + <> +
+ + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/top-nav.tsx b/apps/web/src/components/layout/top-nav.tsx new file mode 100644 index 00000000..7388fda5 --- /dev/null +++ b/apps/web/src/components/layout/top-nav.tsx @@ -0,0 +1,242 @@ +import { + ChevronLeft, + ChevronRight, + FolderOpen, + HelpCircle, + LayoutGrid, + Workflow, +} from "lucide-react"; +import { Link, useLocation } from "react-router-dom"; +import { useTranslation } from "@/contexts/i18n-context"; +import { useAuth } from "@/hooks/use-auth"; +import { useMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { ImageEditIcon } from "../common/image-edit-icon"; +import { OtterLogo } from "../common/otter-logo"; +import { AvatarDropdown } from "./avatar-dropdown.js"; + +interface TopNavProps { + variant?: "light" | "dark"; + breadcrumb?: { modality?: string; toolName?: string }; + onHelpClick: () => void; + onSettingsClick: () => void; +} + +interface NavLinkItem { + label: string; + href: string; + icon: React.ComponentType<{ className?: string }>; +} + +function useNavLinks(): NavLinkItem[] { + const { t } = useTranslation(); + return [ + { label: t.sidebar.tools, href: "/", icon: LayoutGrid }, + { label: t.sidebar.automate, href: "/automate", icon: Workflow }, + { label: t.sidebar.editor, href: "/editor", icon: ImageEditIcon }, + { label: t.sidebar.files, href: "/files", icon: FolderOpen }, + ]; +} + +function isLinkActive(pathname: string, href: string): boolean { + if (href === "/") return pathname === "/"; + return pathname.startsWith(href); +} + +export function TopNav({ + variant = "light", + breadcrumb, + onHelpClick, + onSettingsClick, +}: TopNavProps) { + const location = useLocation(); + const isMobile = useMobile(); + const { authEnabled } = useAuth(); + const { t } = useTranslation(); + const navLinks = useNavLinks(); + + const isDark = variant === "dark"; + + // Mobile layout + if (isMobile) { + return ( +
+ {breadcrumb ? ( +
+ + + + + {breadcrumb.modality && ( + + {breadcrumb.modality} + {" / "} + + )} + {breadcrumb.toolName} + +
+ ) : ( + + + + )} + +
+ + +
+ ); + } + + // Desktop layout + return ( +
+ {/* Left: Logo */} + + + + + {/* Center-left: Breadcrumb or nav links */} + {breadcrumb ? ( + + ) : ( + + )} + + {/* Spacer */} +
+ + {/* Right: Contextual nav links (when breadcrumb) + help + avatar */} +
+ {breadcrumb && ( + <> + {navLinks + .filter((link) => link.href !== "/") + .map((link) => ( + + {link.label} + + ))} +
+ + )} + + + + {!isMobile && authEnabled && ( + + )} +
+
+ ); +}