mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add TopNav and AvatarDropdown components
This commit is contained in:
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-semibold hover:opacity-90 transition-opacity"
|
||||||
|
aria-label={username}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full mt-1 end-0 w-56 rounded-lg border shadow-lg z-50",
|
||||||
|
variant === "dark"
|
||||||
|
? "bg-[#2a2a2a] border-[#444] text-[#e0e0e0]"
|
||||||
|
: "bg-card border-border text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* User info */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2.5 text-sm font-medium truncate",
|
||||||
|
variant === "dark" ? "text-[#e0e0e0]" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("border-t", variant === "dark" ? "border-[#444]" : "border-border")} />
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onSettingsClick();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-start px-3 py-2 text-sm flex items-center gap-2 transition-colors",
|
||||||
|
variant === "dark"
|
||||||
|
? "hover:bg-[#333] text-[#e0e0e0]"
|
||||||
|
: "hover:bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
{t.common.settings}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Docs */}
|
||||||
|
<a
|
||||||
|
href="https://docs.snapotter.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-start px-3 py-2 text-sm flex items-center gap-2 transition-colors",
|
||||||
|
variant === "dark"
|
||||||
|
? "hover:bg-[#333] text-[#e0e0e0]"
|
||||||
|
: "hover:bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{t.settings.about.docsLink}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* API Reference */}
|
||||||
|
<a
|
||||||
|
href="/api/docs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-start px-3 py-2 text-sm flex items-center gap-2 transition-colors",
|
||||||
|
variant === "dark"
|
||||||
|
? "hover:bg-[#333] text-[#e0e0e0]"
|
||||||
|
: "hover:bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{t.settings.about.apiRefLink}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{authEnabled && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn("border-t", variant === "dark" ? "border-[#444]" : "border-border")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
handleLogout();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-start px-3 py-2 text-sm flex items-center gap-2 transition-colors",
|
||||||
|
variant === "dark"
|
||||||
|
? "hover:bg-[#333] text-[#e0e0e0]"
|
||||||
|
: "hover:bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{t.auth.logout}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"flex items-center h-12 px-4 border-b shrink-0",
|
||||||
|
isDark ? "bg-[#222] border-[#333]" : "bg-white border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{breadcrumb ? (
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
isDark
|
||||||
|
? "text-[#aaa] hover:text-[#e0e0e0]"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<span className={cn("text-sm truncate", isDark ? "text-[#e0e0e0]" : "text-foreground")}>
|
||||||
|
{breadcrumb.modality && (
|
||||||
|
<span className={isDark ? "text-[#aaa]" : "text-muted-foreground"}>
|
||||||
|
{breadcrumb.modality}
|
||||||
|
{" / "}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-medium">{breadcrumb.toolName}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link to="/" className="shrink-0">
|
||||||
|
<OtterLogo className="h-7 w-7" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onHelpClick}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors",
|
||||||
|
isDark
|
||||||
|
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
aria-label={t.sidebar.help}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop layout
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"flex items-center h-12 px-4 border-b shrink-0",
|
||||||
|
isDark ? "bg-[#222] border-[#333]" : "bg-white border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Left: Logo */}
|
||||||
|
<Link to="/" className="shrink-0 me-4">
|
||||||
|
<OtterLogo className="h-7 w-7" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Center-left: Breadcrumb or nav links */}
|
||||||
|
{breadcrumb ? (
|
||||||
|
<nav className="flex items-center gap-1 text-sm min-w-0" aria-label={t.a11y.navigationMenu}>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className={cn(
|
||||||
|
"hover:underline shrink-0",
|
||||||
|
isDark
|
||||||
|
? "text-[#aaa] hover:text-[#e0e0e0]"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.sidebar.tools}
|
||||||
|
</Link>
|
||||||
|
{breadcrumb.modality && (
|
||||||
|
<>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0",
|
||||||
|
isDark ? "text-[#666]" : "text-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className={cn("shrink-0", isDark ? "text-[#aaa]" : "text-muted-foreground")}>
|
||||||
|
{breadcrumb.modality}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0",
|
||||||
|
isDark ? "text-[#666]" : "text-muted-foreground/50",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn("font-medium truncate", isDark ? "text-[#e0e0e0]" : "text-foreground")}
|
||||||
|
>
|
||||||
|
{breadcrumb.toolName}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
) : (
|
||||||
|
<nav className="flex items-center gap-1" aria-label={t.a11y.navigationMenu}>
|
||||||
|
{navLinks.map((link) => {
|
||||||
|
const active = isLinkActive(location.pathname, link.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
to={link.href}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? isDark
|
||||||
|
? "bg-[#333] text-[#e0e0e0]"
|
||||||
|
: "bg-muted text-foreground"
|
||||||
|
: isDark
|
||||||
|
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Right: Contextual nav links (when breadcrumb) + help + avatar */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{breadcrumb && (
|
||||||
|
<>
|
||||||
|
{navLinks
|
||||||
|
.filter((link) => link.href !== "/")
|
||||||
|
.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
to={link.href}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||||
|
isDark
|
||||||
|
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className={cn("w-px h-5 mx-1", isDark ? "bg-[#444]" : "bg-border")} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onHelpClick}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors",
|
||||||
|
isDark
|
||||||
|
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
aria-label={t.sidebar.help}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isMobile && authEnabled && (
|
||||||
|
<AvatarDropdown onSettingsClick={onSettingsClick} variant={variant} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user