Files
wehub-resource-sync e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

292 lines
10 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Menu, X, ArrowUpRight, ArrowRight, Search } from "lucide-react";
import { usePersistentBoolean } from "@/hooks/use-persistent-boolean";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { formatCompact } from "@/lib/format";
import { SearchDialog } from "./search-dialog";
import { GitHubIcon } from "@/components/icons/github";
import { DiscordIcon } from "@/components/icons/discord";
import { NAV_ITEMS } from "@/lib/constants";
import {
CloudButton,
cloudButtonVariants,
} from "@/components/shared/cloud-button";
import { useAssistantPanel } from "@/components/docs/assistant/context";
import { NavItems } from "@/components/shared/nav-items";
import { HeaderBrandLink } from "@/components/shared/header-brand-link";
function SearchButton({ onToggle }: { onToggle: () => void }) {
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
onToggle();
}
};
document.addEventListener("keydown", down, true);
return () => document.removeEventListener("keydown", down, true);
}, [onToggle]);
return (
<button
type="button"
onClick={onToggle}
className="text-muted-foreground hover:text-foreground flex size-8 cursor-pointer items-center justify-center transition-colors"
aria-label="Search (⌘K)"
>
<Search className="size-4" />
</button>
);
}
function HiringBanner({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="relative flex justify-center">
<div className="border-border/50 bg-background/60 relative flex items-center gap-3 rounded-full border px-4 py-1.5 backdrop-blur-md">
<Link
href="/careers"
className="group inline-flex items-center gap-1.5 text-xs"
>
<span className="shimmer text-muted-foreground group-hover:text-foreground transition-colors">
We&apos;re hiring. Build the future of agentic UI.
</span>
<ArrowRight className="text-muted-foreground group-hover:text-foreground size-3 transition-all group-hover:translate-x-0.5" />
</Link>
<button
type="button"
aria-label="Dismiss"
onClick={onDismiss}
className="text-muted-foreground hover:bg-muted hover:text-foreground flex size-5 items-center justify-center rounded-full transition-colors"
>
<X className="size-3" />
</button>
</div>
</div>
);
}
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [stars, setStars] = useState<number | null>(null);
const pathname = usePathname();
const { toggle } = useAssistantPanel();
const [dismissed, setDismissed] = usePersistentBoolean(
"homepage-hiring-banner-dismissed",
);
const [visited, setVisited] = usePersistentBoolean("homepage-visited");
const [returningVisitor, setReturningVisitor] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (pathname !== "/") return;
// Show the banner only from the second homepage visit onward.
setReturningVisitor(visited);
if (!visited) setVisited(true);
// oxlint-disable-next-line react/exhaustive-deps
}, [pathname]);
useEffect(() => {
fetch("/api/github/repo")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data && typeof data.stars === "number") {
setStars(data.stars);
}
})
.catch(console.error);
}, []);
const isHome = pathname === "/";
const showBanner = mounted && isHome && returningVisitor && !dismissed;
return (
<header className="sticky top-0 z-50 w-full">
<div className="from-background pointer-events-none absolute inset-x-0 top-0 h-14 bg-linear-to-b to-transparent mask-[linear-gradient(to_bottom,black_75%,transparent)] backdrop-blur-xl" />
<div className="relative mx-auto flex h-12 w-full max-w-7xl items-center justify-between px-4">
<div className="flex items-center gap-4">
<HeaderBrandLink />
<nav className="hidden items-center md:flex">
<NavItems items={NAV_ITEMS} />
</nav>
</div>
<div className="flex items-center gap-1">
{!isHome && (
<>
<SearchButton onToggle={() => setSearchOpen((prev) => !prev)} />
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
</>
)}
{isHome && (
<button
type="button"
onClick={toggle}
className={cloudButtonVariants.marketing}
aria-label="Ask AI (⌘I)"
>
Ask AI
</button>
)}
<CloudButton variant="marketing" />
<a
href="https://github.com/assistant-ui/assistant-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground ml-2 hidden items-center gap-2.5 transition-colors sm:flex"
aria-label="GitHub"
>
<GitHubIcon className="size-4" />
{stars !== null && (
<span className="text-sm tabular-nums">
{formatCompact(stars)}
</span>
)}
</a>
{!isHome && (
<a
href="https://discord.gg/S9dwgCNEFs"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground hidden size-8 items-center justify-center transition-colors sm:flex"
aria-label="Discord"
>
<DiscordIcon className="size-4" />
</a>
)}
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center transition-colors md:hidden"
aria-label="Toggle menu"
>
{mobileMenuOpen ? (
<X className="size-5" />
) : (
<Menu className="size-5" />
)}
</button>
</div>
</div>
<div
className={cn(
"bg-background fixed inset-x-0 top-12 bottom-0 z-40 transition-opacity duration-200 md:hidden",
mobileMenuOpen ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<nav className="flex h-full flex-col gap-1 overflow-y-auto px-4 pt-4">
{NAV_ITEMS.map((item) => {
if (item.type === "link") {
return item.href.startsWith("http") ? (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileMenuOpen(false)}
className="text-foreground py-3 text-lg transition-colors"
>
{item.label}
</a>
) : (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className="text-foreground py-3 text-lg transition-colors"
>
{item.label}
</Link>
);
}
const groups = item.groups;
return (
<div key={item.label} className="flex flex-col">
{groups.map((group) => (
<div key={group.label} className="flex flex-col">
<span className="text-muted-foreground py-3 text-sm">
{group.label}
</span>
{group.items.map((link) =>
link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileMenuOpen(false)}
className="text-foreground flex items-center gap-1.5 py-2 pl-4 text-lg transition-colors"
>
{link.label}
<ArrowUpRight className="size-3.5 opacity-40" />
</a>
) : (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileMenuOpen(false)}
className="text-foreground py-2 pl-4 text-lg transition-colors"
>
{link.label}
</Link>
),
)}
</div>
))}
</div>
);
})}
<div className="mt-auto flex flex-col gap-4 border-t py-6">
<CloudButton
variant="mobile"
onClick={() => setMobileMenuOpen(false)}
/>
<div className="flex gap-4">
<a
href="https://github.com/assistant-ui/assistant-ui"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors"
>
<GitHubIcon className="size-5" />
</a>
<a
href="https://discord.gg/S9dwgCNEFs"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors"
>
<DiscordIcon className="size-5" />
</a>
</div>
</div>
</nav>
</div>
{showBanner && (
<div className="absolute top-full right-0 left-0">
<HiringBanner onDismiss={() => setDismissed(true)} />
</div>
)}
</header>
);
}