unsloth/studio/frontend/src/components/navbar.tsx
2026-03-09 20:17:45 +00:00

288 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only - See /studio/LICENSE.AGPL-3.0
// Copyright © 2025 Unsloth AI
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import {
ArrowRight01Icon,
Book03Icon,
BubbleChatIcon,
ChefHatIcon,
CursorInfo02Icon,
PackageIcon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useTrainingRuntimeStore } from "@/features/training";
import { Link, useRouterState } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useState } from "react";
import { TOUR_OPEN_EVENT } from "@/features/tour";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
{ label: "Recipes", href: "/data-recipes", icon: ChefHatIcon, enabled: true },
{ label: "Export", href: "/export", icon: PackageIcon, enabled: true },
{ label: "Chat", href: "/chat", icon: BubbleChatIcon, enabled: true },
];
function getTourId(pathname: string): "studio" | "chat" | "export" | null {
if (pathname === "/studio") return "studio";
if (pathname === "/chat") return "chat";
if (pathname === "/export") return "export";
return null;
}
export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const [mobileOpen, setMobileOpen] = useState(false);
const tourId = getTourId(pathname);
const openTour = () => {
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
);
};
return (
<header className="relative top-0 z-40 h-16 w-full">
<div className="mx-auto grid h-full max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
{/* Left: logo */}
<Link to="/studio" className="flex items-center justify-self-start select-none">
<img
src="/blacklogo.png"
alt="Unsloth"
className="h-9 w-auto dark:hidden"
/>
<img
src="/whitelogo.png"
alt="Unsloth"
className="hidden h-9 w-auto dark:block"
/>
</Link>
{/* Center: pill nav */}
<nav
data-tour="navbar"
className="hidden items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5 md:flex"
>
{NAV_ITEMS.map((item) => {
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
const disabledByTraining =
isTrainingRunning && item.href !== "/studio";
if (!item.enabled || disabledByTraining) {
return (
<span
key={item.href}
className="relative rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
>
{item.label}
</span>
);
}
return (
<Link
key={item.href}
to={item.href}
className={cn(
"relative rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
active
? "text-background"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 rounded-full bg-foreground"
transition={{
type: "spring",
stiffness: 500,
damping: 35,
mass: 0.5,
}}
/>
)}
<span className="relative z-10 flex items-center">
<motion.span
initial={false}
animate={{
width: active ? 14 : 0,
marginLeft: active ? -4 : 0,
marginRight: active ? 4 : 0,
opacity: active ? 1 : 0,
}}
transition={{ duration: 0.2, ease: [0.165, 0.84, 0.44, 1] }}
className="inline-flex shrink-0 items-center justify-center overflow-hidden"
>
<HugeiconsIcon
icon={item.icon}
className="size-3.5 -mt-px shrink-0"
/>
</motion.span>
{item.label}
</span>
</Link>
);
})}
</nav>
{/* Right: docs/tour desktop */}
<div className="hidden items-center justify-self-end gap-2 md:flex">
<AnimatedThemeToggler
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
title="Toggle theme"
aria-label="Toggle theme"
/>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more
</a>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 p-0">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="group/card flex flex-col gap-1 p-4 no-underline"
>
<p className="text-sm font-semibold font-heading">
Unsloth Documentation
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Guides on fine-tuning LLMs 2x faster with 70% less memory.
Covers LoRA, QLoRA, data formatting, and deployment.
</p>
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
Visit docs
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</span>
</a>
</HoverCardContent>
</HoverCard>
<button
type="button"
onClick={tourId ? openTour : undefined}
className={cn(
"flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
!tourId && "invisible pointer-events-none",
)}
title="Tour"
aria-hidden={!tourId}
tabIndex={tourId ? 0 : -1}
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<span className="text-sm font-medium">Tour</span>
</button>
</div>
{/* Right: mobile */}
<div className="flex items-center gap-2 md:hidden">
{tourId ? (
<button
type="button"
onClick={openTour}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
</button>
) : null}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger asChild={true}>
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground"
aria-label="Open navigation menu"
>
Menu
</button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px] p-4">
<SheetHeader>
<SheetTitle>Navigate</SheetTitle>
</SheetHeader>
<div className="mt-6 flex flex-col gap-2">
{NAV_ITEMS.filter((item) => item.enabled).map((item) => {
const active = pathname === item.href;
const disabledByTraining =
isTrainingRunning && item.href !== "/studio";
if (disabledByTraining) {
return (
<span
key={item.href}
className="rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground/40 cursor-not-allowed"
>
{item.label}
</span>
);
}
return (
<Link
key={item.href}
to={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"rounded-md border px-3 py-2 text-sm font-medium",
active
? "border-foreground bg-foreground text-background"
: "border-border text-foreground hover:bg-accent",
)}
>
{item.label}
</Link>
);
})}
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="mt-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
onClick={() => setMobileOpen(false)}
>
Learn more (Docs)
</a>
{tourId ? (
<button
type="button"
className="rounded-md border border-border px-3 py-2 text-left text-sm font-medium text-foreground hover:bg-accent"
onClick={() => {
openTour();
setMobileOpen(false);
}}
>
Start tour
</button>
) : null}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}