chore: import upstream snapshot with attribution
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { CLOUD_URL } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const cloudButtonVariants = {
|
||||
marketing:
|
||||
"border-border hover:bg-muted hidden rounded-md border px-3 py-1.5 text-sm font-medium transition-colors md:inline-flex",
|
||||
docs: "border-border/50 bg-muted/50 hover:bg-muted flex h-8 items-center rounded-lg border px-3 text-sm font-medium transition-colors",
|
||||
mobile:
|
||||
"border-border hover:bg-muted inline-flex w-fit rounded-md border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
};
|
||||
|
||||
export function CloudButton({
|
||||
variant,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
variant: keyof typeof cloudButtonVariants;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={CLOUD_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClick}
|
||||
className={cn(cloudButtonVariants[variant], className)}
|
||||
>
|
||||
Cloud
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
import type { ThemeColor } from "@/components/builder/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type { ThemeColor };
|
||||
|
||||
export function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setLocalValue(newValue);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onChange(newValue);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="relative cursor-pointer">
|
||||
<div
|
||||
className="size-5 rounded-md shadow-sm ring-1 ring-black/10 ring-inset"
|
||||
style={{ backgroundColor: localValue }}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={localValue}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className="absolute inset-0 size-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ThemeColor;
|
||||
onChange: (value: ThemeColor) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<ColorPicker
|
||||
value={value.light}
|
||||
onChange={(light) => onChange({ ...value, light })}
|
||||
/>
|
||||
<ColorPicker
|
||||
value={value.dark}
|
||||
onChange={(dark) => onChange({ ...value, dark })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OptionalThemeColorPicker({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}: {
|
||||
value: ThemeColor | undefined;
|
||||
defaultValue: ThemeColor;
|
||||
onChange: (value: ThemeColor | undefined) => void;
|
||||
}) {
|
||||
const displayValue = value ?? defaultValue;
|
||||
const isCustom = value !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<label className="relative cursor-pointer">
|
||||
<div
|
||||
className={cn(
|
||||
"size-5 rounded-md shadow-sm ring-1 ring-inset",
|
||||
isCustom ? "ring-black/10" : "opacity-50 ring-black/5",
|
||||
)}
|
||||
style={{ backgroundColor: displayValue.light }}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={displayValue.light}
|
||||
onChange={(e) => onChange({ ...displayValue, light: e.target.value })}
|
||||
className="absolute inset-0 size-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
<label className="relative cursor-pointer">
|
||||
<div
|
||||
className={cn(
|
||||
"size-5 rounded-md shadow-sm ring-1 ring-inset",
|
||||
isCustom ? "ring-black/10" : "opacity-50 ring-black/5",
|
||||
)}
|
||||
style={{ backgroundColor: displayValue.dark }}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={displayValue.dark}
|
||||
onChange={(e) => onChange({ ...displayValue, dark: e.target.value })}
|
||||
className="absolute inset-0 size-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { GitHubIcon } from "@/components/icons/github";
|
||||
import { DiscordIcon } from "@/components/icons/discord";
|
||||
import { ThemeToggle } from "@/components/shared/theme-toggle";
|
||||
import { CLOUD_URL, PRODUCTS } from "@/lib/constants";
|
||||
|
||||
type FooterLinkItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const FOOTER_LINKS: Record<string, FooterLinkItem[]> = {
|
||||
Products: [
|
||||
{
|
||||
label: "Cloud",
|
||||
href: CLOUD_URL,
|
||||
external: true,
|
||||
},
|
||||
{ label: "Playground", href: "/playground" },
|
||||
...PRODUCTS.map((p) => ({
|
||||
label: p.label,
|
||||
href: p.href,
|
||||
...(p.external && { external: true }),
|
||||
})),
|
||||
],
|
||||
Resources: [
|
||||
{ label: "Documentation", href: "/docs" },
|
||||
{ label: "Examples", href: "/examples" },
|
||||
{ label: "Showcase", href: "/showcase" },
|
||||
{ label: "Traction", href: "/traction" },
|
||||
{ label: "Packages", href: "/packages" },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
],
|
||||
Company: [
|
||||
{ label: "Careers", href: "/careers" },
|
||||
{
|
||||
label: "Contact Sales",
|
||||
href: "https://cal.com/simon-farshid/assistant-ui",
|
||||
external: true,
|
||||
},
|
||||
{ label: "Pricing", href: "/pricing" },
|
||||
{ label: "Brand", href: "/brand" },
|
||||
],
|
||||
Legal: [
|
||||
{
|
||||
label: "Terms of Service",
|
||||
href: "/terms-of-service",
|
||||
},
|
||||
{
|
||||
label: "Privacy Policy",
|
||||
href: "/privacy-policy",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function Footer(): React.ReactElement {
|
||||
return (
|
||||
<footer className="py-10 md:py-16">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-10 px-4 md:flex-row md:justify-between">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 sm:grid-cols-4 md:order-2 lg:gap-x-16">
|
||||
{Object.entries(FOOTER_LINKS).map(([category, links]) => (
|
||||
<div key={category} className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium">{category}</p>
|
||||
{links.map((link) => (
|
||||
<FooterLink
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
{...(link.external && { external: true })}
|
||||
>
|
||||
{link.label}
|
||||
</FooterLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 md:order-1">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/favicon/icon.svg"
|
||||
alt="logo"
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6 dark:hue-rotate-180 dark:invert"
|
||||
/>
|
||||
<span className="text-xl font-medium">assistant-ui</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://x.com/assistantui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/assistant-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<GitHubIcon className="size-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/S9dwgCNEFs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Discord"
|
||||
>
|
||||
<DiscordIcon className="size-5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
<div className="-ml-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<a
|
||||
href="https://agentbase.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
||||
>
|
||||
© {new Date().getFullYear()} AgentbaseAI Inc.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
const FooterLink: FC<{
|
||||
href: string;
|
||||
external?: boolean;
|
||||
children: ReactNode;
|
||||
}> = ({ href, external, children }) => {
|
||||
const isExternal = external ?? href.startsWith("http");
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm transition-colors"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
<ArrowUpRight className="size-3 opacity-40" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
||||
href={href}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { DownloadIcon, SquareDashedIcon, TypeIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BrandAssetsMenuProps = {
|
||||
children: ReactElement;
|
||||
};
|
||||
|
||||
type HeaderBrandLinkProps = {
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
};
|
||||
|
||||
export function HeaderBrandLink({
|
||||
className,
|
||||
labelClassName,
|
||||
}: HeaderBrandLinkProps) {
|
||||
return (
|
||||
<BrandAssetsMenu>
|
||||
<Link
|
||||
href="/"
|
||||
className={cn("flex shrink-0 items-center gap-2", className)}
|
||||
>
|
||||
<Image
|
||||
src="/favicon/icon.svg"
|
||||
alt="assistant-ui logo"
|
||||
width={18}
|
||||
height={18}
|
||||
className="dark:hue-rotate-180 dark:invert"
|
||||
/>
|
||||
<span className={cn("font-medium tracking-tight", labelClassName)}>
|
||||
assistant-ui
|
||||
</span>
|
||||
</Link>
|
||||
</BrandAssetsMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandAssetsMenu({ children }: BrandAssetsMenuProps) {
|
||||
return (
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="w-56">
|
||||
<ContextMenuItem
|
||||
onSelect={() => void copySvg("Logomark as SVG", "/favicon/icon.svg")}
|
||||
>
|
||||
<Image
|
||||
src="/favicon/icon.svg"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="size-4 dark:hue-rotate-180 dark:invert"
|
||||
/>
|
||||
Copy Logomark as SVG
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
void copySvg("Logotype as SVG", "/brand/logotype.svg")
|
||||
}
|
||||
>
|
||||
<TypeIcon className="size-4" />
|
||||
Copy Logotype as SVG
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem asChild>
|
||||
<Link href="/brand">
|
||||
<SquareDashedIcon className="size-4" />
|
||||
Brand Guidelines
|
||||
</Link>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuItem asChild>
|
||||
<a href="/assistant-ui-brand.zip" download>
|
||||
<DownloadIcon className="size-4" />
|
||||
Download Brand Assets
|
||||
</a>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ClipboardItem wraps the pending fetch so the clipboard write starts inside
|
||||
// the user activation window; Safari rejects a write that awaits the fetch.
|
||||
const copySvg = async (label: string, path: string) => {
|
||||
try {
|
||||
const svg = fetch(path).then(async (response) => {
|
||||
if (!response.ok) throw new Error("Failed to load SVG");
|
||||
return new Blob([await response.text()], { type: "text/plain" });
|
||||
});
|
||||
await navigator.clipboard.write([new ClipboardItem({ "text/plain": svg })]);
|
||||
toast.success(`${label} copied`);
|
||||
} catch {
|
||||
toast.error(`Failed to copy ${label.toLowerCase()}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ArrowUpRight, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
interface MoreDropdownItem {
|
||||
label: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export function MoreDropdown({ items }: { items: MoreDropdownItem[] }) {
|
||||
return (
|
||||
<HoverCard openDelay={100} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
More
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-40 rounded-xl p-1 shadow-xs" align="end">
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) =>
|
||||
item.external ? (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:bg-muted flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
<ArrowUpRight className="size-3 opacity-40" />
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="hover:bg-muted rounded-md px-2.5 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import type { DropdownItem, NavItem } from "@/lib/constants";
|
||||
|
||||
function DropdownLink({ link }: { link: DropdownItem }) {
|
||||
const className =
|
||||
"hover:bg-muted flex flex-col rounded-md px-2 py-1.5 transition-colors";
|
||||
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
{link.label}
|
||||
<ArrowUpRight className="size-3 opacity-40" />
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{link.description}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={link.href} className={className}>
|
||||
<span className="text-sm">{link.label}</span>
|
||||
<span className="text-muted-foreground text-xs">{link.description}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavItems({
|
||||
items,
|
||||
megaAlign = "start",
|
||||
}: {
|
||||
items: NavItem[];
|
||||
megaAlign?: "start" | "end";
|
||||
}) {
|
||||
return items.map((item) => {
|
||||
if (item.type === "link") {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-muted-foreground hover:text-foreground px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard key={item.label} openDelay={100} closeDelay={100}>
|
||||
{trigger}
|
||||
<HoverCardContent
|
||||
align={megaAlign}
|
||||
alignOffset={megaAlign === "end" ? -130 : -52}
|
||||
className="w-[44rem] rounded-xl p-3 shadow-xs"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{item.groups.map((group) => (
|
||||
<div key={group.label} className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground px-2 pb-1 text-xs font-medium">
|
||||
{group.label}
|
||||
</span>
|
||||
{group.items.map((link) => (
|
||||
<DropdownLink key={link.href} link={link} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Search,
|
||||
CornerDownLeft,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
FileText,
|
||||
Hash,
|
||||
Text,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useDocsSearch } from "fumadocs-core/search/client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { useGlobalAskAI } from "@/components/docs/assistant/context";
|
||||
|
||||
interface HighlightSegment {
|
||||
type: "text";
|
||||
content: string;
|
||||
styles?: {
|
||||
highlight?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
url: string;
|
||||
content: string;
|
||||
type: string;
|
||||
contentWithHighlights?: HighlightSegment[];
|
||||
}
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function HighlightedText({
|
||||
segments,
|
||||
fallback,
|
||||
}: {
|
||||
segments: HighlightSegment[] | undefined;
|
||||
fallback: string;
|
||||
}) {
|
||||
if (!segments || segments.length === 0) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
segment.styles?.highlight &&
|
||||
"bg-primary/15 text-primary rounded px-0.5",
|
||||
)}
|
||||
>
|
||||
{segment.content}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBreadcrumb(url: string): string {
|
||||
const path = url.split("#")[0] ?? "";
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
if (segments.length === 0) return "Home";
|
||||
|
||||
return segments
|
||||
.map((segment) =>
|
||||
segment
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" "),
|
||||
)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
function getResultTitle(item: SearchResult): string {
|
||||
if (item.type === "page") {
|
||||
const path = item.url.split("#")[0] ?? "";
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const lastSegment = segments[segments.length - 1] ?? "Home";
|
||||
return lastSegment
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
return item.content.replace(/<\/?mark>/g, "");
|
||||
}
|
||||
|
||||
function parseHighlightedContent(content: string): HighlightSegment[] {
|
||||
const segments: HighlightSegment[] = [];
|
||||
const regex = /<mark>([\s\S]*?)<\/mark>/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
type: "text",
|
||||
content: content.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
segments.push({
|
||||
type: "text",
|
||||
content: match[1] ?? "",
|
||||
styles: { highlight: true },
|
||||
});
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({ type: "text", content: content.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function getResultSegments(item: SearchResult): HighlightSegment[] | undefined {
|
||||
if (item.contentWithHighlights && item.contentWithHighlights.length > 0) {
|
||||
return item.contentWithHighlights;
|
||||
}
|
||||
if (item.type === "page") return undefined;
|
||||
return parseHighlightedContent(item.content);
|
||||
}
|
||||
|
||||
function ResultIcon({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case "page":
|
||||
return <FileText className="size-4" />;
|
||||
case "heading":
|
||||
return <Hash className="size-4" />;
|
||||
default:
|
||||
return <Text className="size-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getPageUrl(url: string): string {
|
||||
return url.split("#")[0] ?? "";
|
||||
}
|
||||
|
||||
interface GroupedResult {
|
||||
pageUrl: string;
|
||||
items: SearchResult[];
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const router = useRouter();
|
||||
const { setSearch, query } = useDocsSearch({ type: "fetch" });
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const askAIFn = useGlobalAskAI();
|
||||
|
||||
const latestResults = useMemo((): SearchResult[] => {
|
||||
if (!query.data || query.data === "empty") return [];
|
||||
return query.data as SearchResult[];
|
||||
}, [query.data]);
|
||||
|
||||
const staleResultsRef = useRef<SearchResult[]>([]);
|
||||
useEffect(() => {
|
||||
if (latestResults.length > 0) {
|
||||
staleResultsRef.current = latestResults;
|
||||
}
|
||||
}, [latestResults]);
|
||||
const results =
|
||||
latestResults.length > 0 ? latestResults : staleResultsRef.current;
|
||||
|
||||
const groupedResults = useMemo((): GroupedResult[] => {
|
||||
const groups: GroupedResult[] = [];
|
||||
let currentGroup: GroupedResult | null = null;
|
||||
|
||||
for (const item of results) {
|
||||
const pageUrl = getPageUrl(item.url);
|
||||
|
||||
if (!currentGroup || currentGroup.pageUrl !== pageUrl) {
|
||||
currentGroup = { pageUrl, items: [item] };
|
||||
groups.push(currentGroup);
|
||||
} else {
|
||||
currentGroup.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [results]);
|
||||
|
||||
const showAskAI = !!askAIFn && inputValue.length > 0;
|
||||
|
||||
const lastTrackedQuery = useRef("");
|
||||
const searchTrackingTimeout = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const resultsLengthRef = useRef(0);
|
||||
useEffect(() => {
|
||||
resultsLengthRef.current = results.length;
|
||||
}, [results.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInputValue("");
|
||||
setSelectedIndex(0);
|
||||
lastTrackedQuery.current = "";
|
||||
staleResultsRef.current = [];
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue.length === 0) {
|
||||
setSearch("");
|
||||
staleResultsRef.current = [];
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setSearch(inputValue);
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [inputValue, setSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
void latestResults;
|
||||
setSelectedIndex(0);
|
||||
}, [latestResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current && results.length > 0) {
|
||||
const selectedElement = listRef.current.querySelector(
|
||||
`[data-index="${selectedIndex}"]`,
|
||||
);
|
||||
selectedElement?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}, [selectedIndex, results.length]);
|
||||
|
||||
const handleAskAI = useCallback(() => {
|
||||
if (!askAIFn) return;
|
||||
analytics.search.askAITriggered(inputValue);
|
||||
onOpenChange(false);
|
||||
askAIFn(inputValue);
|
||||
}, [askAIFn, inputValue, onOpenChange]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(url: string) => {
|
||||
if (searchTrackingTimeout.current) {
|
||||
clearTimeout(searchTrackingTimeout.current);
|
||||
if (inputValue.length >= 2 && inputValue !== lastTrackedQuery.current) {
|
||||
lastTrackedQuery.current = inputValue;
|
||||
const resultCount = resultsLengthRef.current;
|
||||
if (resultCount === 0) analytics.search.noResults(inputValue);
|
||||
else analytics.search.querySubmitted(inputValue, resultCount);
|
||||
}
|
||||
}
|
||||
|
||||
const position = results.findIndex((r) => r.url === url);
|
||||
analytics.search.resultClicked(inputValue, url, position);
|
||||
onOpenChange(false);
|
||||
router.push(url);
|
||||
},
|
||||
[onOpenChange, router, results, inputValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTrackingTimeout.current) {
|
||||
clearTimeout(searchTrackingTimeout.current);
|
||||
}
|
||||
|
||||
if (
|
||||
!inputValue ||
|
||||
inputValue.length < 2 ||
|
||||
query.isLoading ||
|
||||
inputValue === lastTrackedQuery.current
|
||||
)
|
||||
return;
|
||||
|
||||
searchTrackingTimeout.current = setTimeout(() => {
|
||||
lastTrackedQuery.current = inputValue;
|
||||
const resultCount = resultsLengthRef.current;
|
||||
if (resultCount === 0) analytics.search.noResults(inputValue);
|
||||
else analytics.search.querySubmitted(inputValue, resultCount);
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (searchTrackingTimeout.current) {
|
||||
clearTimeout(searchTrackingTimeout.current);
|
||||
}
|
||||
};
|
||||
}, [inputValue, query.isLoading]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && results[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
handleSelect(results[selectedIndex].url);
|
||||
}
|
||||
},
|
||||
[results, selectedIndex, handleSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="gap-0 overflow-hidden rounded-3xl border-none p-2 sm:max-w-xl"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Search documentation</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-hidden rounded-2xl border">
|
||||
<div className="border-border/50 flex items-center gap-2.5 border-b px-3">
|
||||
<Search className="text-muted-foreground size-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documentation..."
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="placeholder:text-muted-foreground/60 h-11 flex-1 bg-transparent text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{showAskAI && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAskAI}
|
||||
className="hover:bg-accent hidden shrink-0 items-center gap-1.5 rounded-lg px-2 py-1 text-pink-500 transition-colors sm:flex"
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
<span className="text-xs font-medium">Ask AI</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
className="h-[min(400px,90vh)] overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
>
|
||||
{inputValue.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-4">
|
||||
<div className="text-muted-foreground/60 flex items-center gap-6">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<ArrowUp className="size-3" />
|
||||
<ArrowDown className="size-3" />
|
||||
<span>navigate</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<CornerDownLeft className="size-3" />
|
||||
<span>select</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : query.isLoading && results.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground/60 flex items-center gap-2">
|
||||
<div className="size-1 animate-pulse rounded-full bg-current" />
|
||||
<div className="size-1 animate-pulse rounded-full bg-current [animation-delay:150ms]" />
|
||||
<div className="size-1 animate-pulse rounded-full bg-current [animation-delay:300ms]" />
|
||||
</div>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 px-4">
|
||||
<p className="text-muted-foreground/60 text-sm">
|
||||
No results for “{inputValue}”
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{(() => {
|
||||
let flatIndex = 0;
|
||||
return groupedResults.map((group) => (
|
||||
<div key={group.pageUrl} className="mb-1 last:mb-0">
|
||||
{group.items.map((item, itemIndex) => {
|
||||
const currentIndex = flatIndex++;
|
||||
const isSelected = currentIndex === selectedIndex;
|
||||
const title = getResultTitle(item);
|
||||
const isNested = itemIndex > 0 || item.type !== "page";
|
||||
const showBreadcrumb = itemIndex === 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
data-index={currentIndex}
|
||||
onClick={() => handleSelect(item.url)}
|
||||
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
||||
className={cn(
|
||||
"group flex w-full cursor-pointer items-center gap-3 rounded-lg py-2 pr-3 text-left transition-colors",
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50",
|
||||
isNested ? "pl-9" : "pl-3",
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground shrink-0">
|
||||
<ResultIcon type={item.type} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-foreground truncate text-sm font-medium">
|
||||
<HighlightedText
|
||||
segments={getResultSegments(item)}
|
||||
fallback={title}
|
||||
/>
|
||||
</span>
|
||||
{showBreadcrumb && (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{formatBreadcrumb(item.url)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CornerDownLeft
|
||||
className={cn(
|
||||
"text-muted-foreground size-3.5 shrink-0 transition-opacity",
|
||||
isSelected ? "opacity-60" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GitHubIcon } from "@/components/icons/github";
|
||||
import { Select } from "@/components/assistant-ui/select";
|
||||
import { SUB_PROJECTS } from "@/lib/constants";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { HeaderBrandLink } from "./header-brand-link";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href: string;
|
||||
shimmer?: boolean;
|
||||
}
|
||||
|
||||
interface SubProjectLayoutProps {
|
||||
name: string;
|
||||
githubPath: string;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
children: ReactNode;
|
||||
hideFooter?: boolean;
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
export function SubProjectLayout({
|
||||
name,
|
||||
githubPath,
|
||||
breadcrumbs: breadcrumbsOverride,
|
||||
children,
|
||||
hideFooter = false,
|
||||
fullHeight = false,
|
||||
}: SubProjectLayoutProps): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (breadcrumbsOverride) {
|
||||
return breadcrumbsOverride;
|
||||
}
|
||||
|
||||
const basePath = `/${name}`;
|
||||
if (!pathname.startsWith(basePath) || pathname === basePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subPath = pathname.slice(basePath.length);
|
||||
const segments = subPath.split("/").filter(Boolean);
|
||||
|
||||
return segments.map((segment, index) => ({
|
||||
label: segment,
|
||||
href: `${basePath}/${segments.slice(0, index + 1).join("/")}`,
|
||||
shimmer: false,
|
||||
}));
|
||||
}, [pathname, name, breadcrumbsOverride]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
fullHeight ? "h-svh overflow-hidden" : "min-h-screen",
|
||||
)}
|
||||
>
|
||||
<header
|
||||
className={cn("z-50 w-full shrink-0", !fullHeight && "sticky top-0")}
|
||||
>
|
||||
{!fullHeight && (
|
||||
<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={cn(
|
||||
"relative flex h-12 w-full items-center justify-between px-4",
|
||||
!fullHeight && "mx-auto max-w-7xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center">
|
||||
<HeaderBrandLink labelClassName="hidden sm:inline" />
|
||||
<span className="text-muted-foreground/40 ml-3">/</span>
|
||||
<Select
|
||||
value={name}
|
||||
onValueChange={(value) => router.push(`/${value}`)}
|
||||
options={SUB_PROJECTS.toSorted((a, b) =>
|
||||
a.slug.localeCompare(b.slug),
|
||||
).map((p) => ({
|
||||
value: p.slug,
|
||||
label:
|
||||
p.slug === "tw-shimmer" ? (
|
||||
<span className="shimmer">{p.label}</span>
|
||||
) : (
|
||||
p.label
|
||||
),
|
||||
textValue: p.slug,
|
||||
}))}
|
||||
/>
|
||||
<span className="hidden sm:contents">
|
||||
{breadcrumbs?.map((item, index) => (
|
||||
<span key={item.href} className="contents">
|
||||
<span className="text-muted-foreground/40 mr-3 ml-1">/</span>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"hover:text-foreground mr-2 text-sm transition-colors",
|
||||
index === breadcrumbs.length - 1
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
item.shimmer && "shimmer",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
data-sub-project-header-portal
|
||||
className="flex items-center gap-1"
|
||||
/>
|
||||
<a
|
||||
href={githubPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center transition-colors"
|
||||
aria-label="View on GitHub"
|
||||
>
|
||||
<GitHubIcon className="size-4" />
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={cn("flex-1", fullHeight && "min-h-0 overflow-hidden")}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{!hideFooter && (
|
||||
<footer className="relative px-4 py-8">
|
||||
<div className="text-muted-foreground mx-auto flex max-w-7xl items-center justify-between text-sm">
|
||||
<p>
|
||||
By{" "}
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
assistant-ui
|
||||
</Link>
|
||||
</p>
|
||||
<a
|
||||
href="https://agentbase.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground/30 hover:text-foreground text-xs transition-colors"
|
||||
>
|
||||
© {new Date().getFullYear()} AgentbaseAI Inc.
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
value?: React.ReactNode; // If present, it's a content tab
|
||||
href?: string; // If present, it's a navigation tab
|
||||
icon?: React.ReactNode;
|
||||
isActive?: (pathname: string) => boolean;
|
||||
}
|
||||
|
||||
const tabSwitcherVariants = cva("flex flex-col", {
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: "h-full overflow-hidden",
|
||||
navigation: "h-auto",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
});
|
||||
|
||||
const tabListVariants = cva("flex-none", {
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: "",
|
||||
navigation: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
});
|
||||
|
||||
const tabIndicatorVariants = cva(
|
||||
"absolute transition-all duration-300 ease-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost: "bg-foreground bottom-0 h-[2px]",
|
||||
default: "bg-primary bottom-0 h-[2px]",
|
||||
outline: "bg-foreground bottom-0 h-[2px]",
|
||||
secondary: "bg-secondary-foreground bottom-0 h-[2px]",
|
||||
link: "bg-primary bottom-0 h-[2px]",
|
||||
},
|
||||
orientation: {
|
||||
horizontal: "bottom-0 h-[2px]",
|
||||
navigation: "bottom-0 h-0.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "ghost",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const tabItemVariants = cva(
|
||||
"focus-visible:ring-ring/50 relative flex h-[30px] cursor-pointer items-center justify-center gap-2 rounded-md px-3 py-2 text-sm leading-5 whitespace-nowrap transition-all duration-300 outline-none focus-visible:ring-2 focus-visible:ring-inset data-[active=true]:font-medium",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
"data-[active=true]:bg-foreground/5 data-[active=true]:text-foreground border-transparent bg-transparent",
|
||||
default:
|
||||
"border-border/50 bg-background/50 text-muted-foreground hover:border-border hover:bg-background hover:text-foreground data-[active=true]:border-border data-[active=true]:bg-background data-[active=true]:text-foreground border data-[active=true]:shadow-sm",
|
||||
outline:
|
||||
"border-border/30 text-muted-foreground hover:border-border hover:bg-accent/50 hover:text-foreground data-[active=true]:border-border data-[active=true]:bg-accent/30 data-[active=true]:text-foreground border bg-transparent",
|
||||
secondary:
|
||||
"bg-secondary/30 text-secondary-foreground/80 hover:bg-secondary/50 hover:text-secondary-foreground data-[active=true]:bg-secondary/70 data-[active=true]:text-secondary-foreground border-transparent",
|
||||
link: "text-muted-foreground hover:text-primary data-[active=true]:text-primary border-transparent bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "ghost",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Tab({
|
||||
tabs,
|
||||
className,
|
||||
defaultActiveIndex = 0,
|
||||
variant = "ghost",
|
||||
orientation,
|
||||
onTabChange,
|
||||
actions,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
tabs: TabItem[];
|
||||
defaultActiveIndex?: number;
|
||||
variant?: "ghost" | "default" | "outline" | "secondary" | "link";
|
||||
onTabChange?: (label: string, index: number) => void;
|
||||
actions?: React.ReactNode;
|
||||
} & VariantProps<typeof tabSwitcherVariants>) {
|
||||
const pathname = usePathname();
|
||||
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null);
|
||||
const [contentActiveIndex, setContentActiveIndex] =
|
||||
React.useState(defaultActiveIndex);
|
||||
const [hoverStyle, setHoverStyle] = React.useState({});
|
||||
const [activeStyle, setActiveStyle] = React.useState({
|
||||
left: "0px",
|
||||
width: "0px",
|
||||
});
|
||||
const tabRefs = React.useRef<(HTMLElement | null)[]>([]);
|
||||
|
||||
// Check if we have content tabs (tabs with value)
|
||||
const hasContentTabs = tabs.some((tab) => tab.value !== undefined);
|
||||
// Pure navigation mode: ALL tabs are navigation-only (href without value)
|
||||
const isPureNavigationMode =
|
||||
tabs.every((tab) => tab.href && !tab.value) && tabs.some((tab) => tab.href);
|
||||
|
||||
const resolvedOrientation =
|
||||
orientation || (isPureNavigationMode ? "navigation" : "horizontal");
|
||||
|
||||
// Find active tab index for pure navigation mode
|
||||
const navigationActiveIndex = isPureNavigationMode
|
||||
? tabs.findIndex((tab) => {
|
||||
if (tab.href) {
|
||||
return tab.isActive ? tab.isActive(pathname) : pathname === tab.href;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
: -1;
|
||||
|
||||
const activeIndex = isPureNavigationMode
|
||||
? navigationActiveIndex
|
||||
: contentActiveIndex;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hoveredIndex !== null) {
|
||||
const hoveredElement = tabRefs.current[hoveredIndex];
|
||||
if (hoveredElement) {
|
||||
const { offsetLeft, offsetWidth } = hoveredElement;
|
||||
setHoverStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [hoveredIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeIndex !== -1) {
|
||||
const activeElement = tabRefs.current[activeIndex];
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement;
|
||||
setActiveStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const initialActiveIndex = isPureNavigationMode
|
||||
? navigationActiveIndex
|
||||
: contentActiveIndex;
|
||||
if (initialActiveIndex !== -1) {
|
||||
const activeElement = tabRefs.current[initialActiveIndex];
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement;
|
||||
setActiveStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [isPureNavigationMode, navigationActiveIndex, contentActiveIndex]);
|
||||
|
||||
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
||||
const tab = tabs[index];
|
||||
// Only handle key events for content tabs (tabs with value)
|
||||
if (tab?.value !== undefined && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
if (index !== contentActiveIndex) {
|
||||
onTabChange?.(tab.label, index);
|
||||
}
|
||||
setContentActiveIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentTabClick = (index: number) => {
|
||||
const tab = tabs[index];
|
||||
// Only set active index for content tabs (tabs with value)
|
||||
if (tab?.value !== undefined) {
|
||||
if (index !== contentActiveIndex) {
|
||||
onTabChange?.(tab.label, index);
|
||||
}
|
||||
setContentActiveIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
// Show indicator only for ghost, outline, and link variants
|
||||
const showIndicator =
|
||||
variant === "ghost" || variant === "outline" || variant === "link";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
tabSwitcherVariants({ orientation: resolvedOrientation }),
|
||||
className,
|
||||
)}
|
||||
data-orientation={resolvedOrientation}
|
||||
data-slot="unified-tab-switcher"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
>
|
||||
<TabList orientation={resolvedOrientation}>
|
||||
<div className="flex items-center gap-2">
|
||||
<TabContainer className="min-w-0 flex-1">
|
||||
{showIndicator && (
|
||||
<TabIndicator
|
||||
activeStyle={activeStyle}
|
||||
hoveredIndex={hoveredIndex}
|
||||
hoverStyle={hoverStyle}
|
||||
orientation={resolvedOrientation}
|
||||
variant={variant}
|
||||
/>
|
||||
)}
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
index={index}
|
||||
isActive={
|
||||
isPureNavigationMode
|
||||
? Boolean(
|
||||
tab.href &&
|
||||
(tab.isActive
|
||||
? tab.isActive(pathname)
|
||||
: pathname === tab.href),
|
||||
)
|
||||
: tab.value !== undefined && index === contentActiveIndex
|
||||
}
|
||||
key={tab.label}
|
||||
onClick={() => handleContentTabClick(index)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
tab={tab}
|
||||
tabRefs={tabRefs}
|
||||
variant={variant}
|
||||
/>
|
||||
))}
|
||||
</TabContainer>
|
||||
{actions && (
|
||||
<div
|
||||
className="flex flex-none items-center gap-1 pb-2"
|
||||
data-slot="tab-actions"
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabList>
|
||||
|
||||
{/* Content panel - show when there are content tabs */}
|
||||
{hasContentTabs && (
|
||||
<TabContent
|
||||
activeIndex={contentActiveIndex}
|
||||
activeTab={tabs[contentActiveIndex]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabList({
|
||||
orientation,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
orientation: "horizontal" | "navigation";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(tabListVariants({ orientation }), className)}
|
||||
data-orientation={orientation}
|
||||
data-slot="tab-list"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabIndicator({
|
||||
variant,
|
||||
orientation,
|
||||
hoverStyle,
|
||||
activeStyle,
|
||||
hoveredIndex,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
variant: "ghost" | "default" | "outline" | "secondary" | "link";
|
||||
orientation: "horizontal" | "navigation";
|
||||
hoverStyle: React.CSSProperties;
|
||||
activeStyle: React.CSSProperties;
|
||||
hoveredIndex: number | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{variant === "ghost" &&
|
||||
hoveredIndex !== null &&
|
||||
hoverStyle.width &&
|
||||
Number.parseFloat(String(hoverStyle.width)) > 0 && (
|
||||
<div
|
||||
className="bg-foreground/8 absolute flex h-[30px] items-center rounded-md transition-all duration-300 ease-out"
|
||||
data-slot="tab-hover-indicator"
|
||||
style={hoverStyle}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
tabIndicatorVariants({ variant, orientation }),
|
||||
className,
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
data-slot="tab-active-indicator"
|
||||
data-variant={variant}
|
||||
style={activeStyle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContainer({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex scrollbar-none items-center space-x-[6px] overflow-x-auto pb-2",
|
||||
className,
|
||||
)}
|
||||
data-slot="tab-container"
|
||||
role="tablist"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabItem({
|
||||
tab,
|
||||
index,
|
||||
isActive,
|
||||
variant,
|
||||
tabRefs,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
className,
|
||||
}: {
|
||||
tab: TabItem;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
variant: "ghost" | "default" | "outline" | "secondary" | "link";
|
||||
tabRefs: React.RefObject<(HTMLElement | null)[]>;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const tabContent = (
|
||||
<>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClasses = cn(tabItemVariants({ variant }), className);
|
||||
|
||||
// If tab has a path, render as navigation link
|
||||
if (tab.href) {
|
||||
return (
|
||||
<Link
|
||||
aria-controls={`panel-${tab.label}`}
|
||||
aria-selected={isActive}
|
||||
className={baseClasses}
|
||||
data-active={isActive}
|
||||
data-slot="tab-item"
|
||||
data-type="navigation"
|
||||
data-variant={variant}
|
||||
href={tab.href}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={(el) => {
|
||||
tabRefs.current[index] = el;
|
||||
}}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
>
|
||||
{tabContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise render as content tab
|
||||
return (
|
||||
<div
|
||||
aria-controls={`panel-${tab.label}`}
|
||||
className={baseClasses}
|
||||
data-active={isActive}
|
||||
data-slot="tab-item"
|
||||
data-type="content"
|
||||
data-variant={variant}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
ref={(el) => {
|
||||
tabRefs.current[index] = el;
|
||||
}}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
>
|
||||
{tabContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContent({
|
||||
activeTab,
|
||||
activeIndex,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
activeTab: TabItem | undefined;
|
||||
activeIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-labelledby={activeTab?.label}
|
||||
className={cn("relative mt-4 flex-1 overflow-hidden", className)}
|
||||
data-slot="tab-content-panel"
|
||||
id={`panel-${activeTab?.label}`}
|
||||
role="tabpanel"
|
||||
{...props}
|
||||
>
|
||||
{activeTab?.value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tab, TabList, TabIndicator, TabContainer, TabItem, TabContent };
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{mounted ? (
|
||||
resolvedTheme === "dark" ? (
|
||||
<Moon className="size-4" />
|
||||
) : (
|
||||
<Sun className="size-4" />
|
||||
)
|
||||
) : (
|
||||
<div className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user