snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
"use client"
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react"
const DEFAULT_THEME = "default"
type ThemeContextType = {
activeTheme: string
setActiveTheme: (theme: string) => void
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ActiveThemeProvider({
children,
initialTheme,
}: {
children: ReactNode
initialTheme?: string
}) {
const [activeTheme, setActiveTheme] = useState<string>(
() => initialTheme || DEFAULT_THEME
)
useEffect(() => {
Array.from(document.body.classList)
.filter((className) => className.startsWith("theme-"))
.forEach((className) => {
document.body.classList.remove(className)
})
document.body.classList.add(`theme-${activeTheme}`)
if (activeTheme.endsWith("-scaled")) {
document.body.classList.add("theme-scaled")
}
}, [activeTheme])
return (
<ThemeContext.Provider value={{ activeTheme, setActiveTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useThemeConfig() {
const context = useContext(ThemeContext)
if (context === undefined) {
throw new Error("useThemeConfig must be used within an ActiveThemeProvider")
}
return context
}

View File

@@ -0,0 +1,7 @@
"use client"
import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
export function Analytics() {
return <VercelAnalytics />
}

View File

@@ -0,0 +1,14 @@
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { Badge } from "@/registry/new-york-v4/ui/badge"
export function Announcement() {
return (
<Badge asChild variant="secondary" className="bg-muted">
<Link href="/docs/changelog">
New Questionnaire component <ArrowRightIcon />
</Link>
</Badge>
)
}

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "cn"
import { type registryItemFileSchema } from "shadcn/schema"
import { type z } from "zod"
import { highlightCode } from "@/lib/highlight-code"
import {
createFileTreeForRegistryItemFiles,
getRegistryItem,
} from "@/lib/registry"
import { BlockViewer } from "@/components/block-viewer"
import { ComponentPreview } from "@/components/component-preview"
import { type Style } from "@/registry/_legacy-styles"
export async function BlockDisplay({
name,
styleName,
}: {
name: string
styleName: Style["name"]
}) {
const item = await getCachedRegistryItem(name, styleName)
if (!item?.files) {
return null
}
const [tree, highlightedFiles] = await Promise.all([
getCachedFileTree(item.files),
getCachedHighlightedFiles(item.files),
])
return (
<BlockViewer
item={item}
tree={tree}
highlightedFiles={highlightedFiles}
styleName={styleName}
>
<ComponentPreview
name={item.name}
styleName={styleName}
hideCode
className={cn(
"my-0 **:[.preview]:h-auto **:[.preview]:p-4 **:[.preview>.p-6]:p-0",
item.meta?.containerClassName
)}
/>
</BlockViewer>
)
}
const getCachedRegistryItem = React.cache(
async (name: string, styleName: Style["name"]) => {
return await getRegistryItem(name, styleName)
}
)
const getCachedFileTree = React.cache(
async (files: Array<{ path: string; target?: string }>) => {
if (!files) {
return null
}
return await createFileTreeForRegistryItemFiles(files)
}
)
const getCachedHighlightedFiles = React.cache(
async (files: z.infer<typeof registryItemFileSchema>[]) => {
return await Promise.all(
files.map(async (file) => ({
...file,
highlightedContent: await highlightCode(file.content ?? ""),
}))
)
}
)

View File

@@ -0,0 +1,35 @@
import Image from "next/image"
import { cn } from "cn"
export function BlockImage({
name,
width = 1440,
height = 900,
className,
}: Omit<React.ComponentProps<typeof Image>, "src" | "alt"> & { name: string }) {
return (
<div
className={cn(
"relative aspect-[1440/900] w-full overflow-hidden rounded-lg",
className
)}
>
<Image
src={`/r/styles/new-york/${name}-light.png`}
alt={name}
width={width}
height={height}
className="object-cover dark:hidden"
data-image="light"
/>
<Image
src={`/r/styles/new-york/${name}-dark.png`}
alt={name}
width={width}
height={height}
className="hidden object-cover dark:block"
data-image="dark"
/>
</div>
)
}

View File

@@ -0,0 +1,513 @@
"use client"
import * as React from "react"
import Image from "next/image"
import Link from "next/link"
import { cn } from "cn"
import {
Check,
ChevronRight,
Clipboard,
File,
Folder,
Fullscreen,
Monitor,
RotateCw,
Smartphone,
Tablet,
Terminal,
} from "lucide-react"
import { type PanelImperativeHandle } from "react-resizable-panels"
import {
type registryItemFileSchema,
type registryItemSchema,
} from "shadcn/schema"
import { type z } from "zod"
import { trackEvent } from "@/lib/events"
import {
type createFileTreeForRegistryItemFiles,
type FileTree,
} from "@/lib/registry"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { getIconForLanguageExtension } from "@/components/icons"
import { OpenInV0Button } from "@/components/open-in-v0-button"
import { type Style } from "@/registry/_legacy-styles"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/new-york-v4/ui/collapsible"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/registry/new-york-v4/ui/resizable"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import {
Sidebar,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarProvider,
} from "@/registry/new-york-v4/ui/sidebar"
import { Tabs, TabsList, TabsTrigger } from "@/registry/new-york-v4/ui/tabs"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/new-york-v4/ui/toggle-group"
type BlockViewerContext = {
item: z.infer<typeof registryItemSchema>
view: "code" | "preview"
setView: (view: "code" | "preview") => void
activeFile: string | null
setActiveFile: (file: string) => void
resizablePanelRef: React.RefObject<PanelImperativeHandle | null> | null
tree: ReturnType<typeof createFileTreeForRegistryItemFiles> | null
highlightedFiles:
| (z.infer<typeof registryItemFileSchema> & {
highlightedContent: string
})[]
| null
iframeKey?: number
setIframeKey?: React.Dispatch<React.SetStateAction<number>>
}
const BlockViewerContext = React.createContext<BlockViewerContext | null>(null)
function useBlockViewer() {
const context = React.useContext(BlockViewerContext)
if (!context) {
throw new Error("useBlockViewer must be used within a BlockViewerProvider.")
}
return context
}
function BlockViewerProvider({
item,
tree,
highlightedFiles,
children,
}: Pick<BlockViewerContext, "item" | "tree" | "highlightedFiles"> & {
children: React.ReactNode
}) {
const [view, setView] = React.useState<BlockViewerContext["view"]>("preview")
const [activeFile, setActiveFile] = React.useState<
BlockViewerContext["activeFile"]
>(highlightedFiles?.[0].target ?? null)
const resizablePanelRef = React.useRef<PanelImperativeHandle>(null)
const [iframeKey, setIframeKey] = React.useState(0)
return (
<BlockViewerContext.Provider
value={{
item,
view,
setView,
resizablePanelRef,
activeFile,
setActiveFile,
tree,
highlightedFiles,
iframeKey,
setIframeKey,
}}
>
<div
id={item.name}
data-view={view}
className="group/block-view-wrapper flex min-w-0 scroll-mt-24 flex-col-reverse items-stretch gap-4 overflow-hidden md:flex-col"
style={
{
"--height": item.meta?.iframeHeight ?? "930px",
} as React.CSSProperties
}
>
{children}
</div>
</BlockViewerContext.Provider>
)
}
type BlockViewerProps = Pick<
BlockViewerContext,
"item" | "tree" | "highlightedFiles"
> & {
children: React.ReactNode
styleName: Style["name"]
}
function BlockViewerToolbar({ styleName }: { styleName: Style["name"] }) {
const { setView, view, item, resizablePanelRef, setIframeKey } =
useBlockViewer()
const { copyToClipboard, isCopied } = useCopyToClipboard()
return (
<div className="hidden w-full items-center gap-2 pl-2 md:pr-6 lg:flex">
<Tabs
value={view}
onValueChange={(value) => setView(value as "preview" | "code")}
>
<TabsList className="grid h-8! grid-cols-2 items-center rounded-lg p-1 *:data-[slot=tabs-trigger]:h-6 *:data-[slot=tabs-trigger]:rounded-sm *:data-[slot=tabs-trigger]:px-2 *:data-[slot=tabs-trigger]:text-xs">
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="code">Code</TabsTrigger>
</TabsList>
</Tabs>
<Separator orientation="vertical" className="mx-2 h-4!" />
<a
href={`#${item.name}`}
className="flex-1 text-center text-sm font-medium underline-offset-2 hover:underline md:flex-auto md:text-left"
>
{item.description?.replace(/\.$/, "")}
</a>
<div className="ml-auto flex items-center gap-2">
<div className="h-8 items-center gap-1.5 rounded-md border p-[3px] shadow-none">
<ToggleGroup
type="single"
defaultValue="100%"
onValueChange={(value) => {
setView("preview")
if (resizablePanelRef?.current) {
resizablePanelRef.current.resize(value)
}
}}
className="gap-1 *:data-[slot=toggle-group-item]:size-6! *:data-[slot=toggle-group-item]:rounded-sm!"
>
<ToggleGroupItem value="100%" title="Desktop">
<Monitor />
</ToggleGroupItem>
<ToggleGroupItem value="60%" title="Tablet">
<Tablet />
</ToggleGroupItem>
<ToggleGroupItem value="30%" title="Mobile">
<Smartphone />
</ToggleGroupItem>
<Separator orientation="vertical" className="h-4!" />
<Button
size="icon"
variant="ghost"
className="size-6 rounded-sm p-0"
asChild
title="Open in New Tab"
>
<Link href={`/view/${styleName}/${item.name}`} target="_blank">
<span className="sr-only">Open in New Tab</span>
<Fullscreen />
</Link>
</Button>
<Separator orientation="vertical" className="h-4!" />
<Button
size="icon"
variant="ghost"
className="size-6 rounded-sm p-0"
title="Refresh Preview"
onClick={() => {
if (setIframeKey) {
setIframeKey((k) => k + 1)
}
}}
>
<RotateCw />
<span className="sr-only">Refresh Preview</span>
</Button>
</ToggleGroup>
</div>
<Separator orientation="vertical" className="mx-1 h-4!" />
<Button
variant="outline"
className="w-fit gap-1 px-2 shadow-none"
size="sm"
onClick={() => {
copyToClipboard(`npx shadcn@latest add ${item.name}`)
}}
>
{isCopied ? <Check /> : <Terminal />}
<span>npx shadcn add {item.name}</span>
</Button>
<Separator orientation="vertical" className="mx-1 h-4!" />
<OpenInV0Button name={item.name} />
</div>
</div>
)
}
function BlockViewerIframe({
className,
styleName,
}: {
className?: string
styleName: Style["name"]
}) {
const { item, iframeKey } = useBlockViewer()
return (
<iframe
key={iframeKey}
src={`/view/${styleName}/${item.name}`}
height={item.meta?.iframeHeight ?? 930}
loading="lazy"
className={cn(
"relative z-20 no-scrollbar w-full bg-background",
className
)}
/>
)
}
function BlockViewerView({ styleName }: { styleName: Style["name"] }) {
const { resizablePanelRef } = useBlockViewer()
return (
<div className="hidden group-data-[view=code]/block-view-wrapper:hidden md:h-(--height) lg:flex">
<div className="relative grid w-full gap-4">
<div className="absolute inset-0 right-4 [background-image:radial-gradient(#d4d4d4_1px,transparent_1px)] [background-size:20px_20px] dark:[background-image:radial-gradient(#404040_1px,transparent_1px)]"></div>
<ResizablePanelGroup
orientation="horizontal"
className="relative z-10 after:absolute after:inset-0 after:right-3 after:z-0 after:rounded-xl after:bg-surface/50"
>
<ResizablePanel
panelRef={resizablePanelRef}
className="relative aspect-[4/2.5] overflow-hidden rounded-lg border bg-background md:aspect-auto md:rounded-xl"
defaultSize="100%"
minSize="30%"
>
<BlockViewerIframe styleName={styleName} />
</ResizablePanel>
<ResizableHandle className="relative hidden w-3 bg-transparent p-0 after:absolute after:top-1/2 after:right-0 after:h-8 after:w-[6px] after:translate-x-[-1px] after:-translate-y-1/2 after:rounded-full after:bg-border after:transition-all after:hover:h-10 md:block" />
<ResizablePanel defaultSize="0%" minSize="0%" />
</ResizablePanelGroup>
</div>
</div>
)
}
function BlockViewerMobile({ children }: { children: React.ReactNode }) {
const { item } = useBlockViewer()
return (
<div className="flex flex-col gap-2 lg:hidden">
<div className="flex items-center gap-2 px-2">
<div className="line-clamp-1 text-sm font-medium">
{item.description}
</div>
<div className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">
{item.name}
</div>
</div>
{item.meta?.mobile === "component" ? (
children
) : (
<div className="overflow-hidden rounded-xl border">
<Image
src={`/r/styles/new-york/${item.name}-light.png`}
alt={item.name}
data-block={item.name}
width={1440}
height={900}
className="object-cover dark:hidden"
/>
<Image
src={`/r/styles/new-york/${item.name}-dark.png`}
alt={item.name}
data-block={item.name}
width={1440}
height={900}
className="hidden object-cover dark:block"
/>
</div>
)}
</div>
)
}
function BlockViewerCode() {
const { activeFile, highlightedFiles } = useBlockViewer()
const file = React.useMemo(() => {
return highlightedFiles?.find((file) => file.target === activeFile)
}, [highlightedFiles, activeFile])
if (!file) {
return null
}
const language = file.path.split(".").pop() ?? "tsx"
return (
<div className="mr-[14px] flex overflow-hidden rounded-xl border bg-code text-code-foreground group-data-[view=preview]/block-view-wrapper:hidden md:h-(--height)">
<div className="w-72">
<BlockViewerFileTree />
</div>
<figure
data-rehype-pretty-code-figure=""
className="mx-0! mt-0 flex min-w-0 flex-1 flex-col rounded-xl border-none"
>
<figcaption
className="flex h-12 shrink-0 items-center gap-2 border-b px-4 py-2 text-code-foreground [&_svg]:size-4 [&_svg]:text-code-foreground [&_svg]:opacity-70"
data-language={language}
>
{getIconForLanguageExtension(language)}
{file.target}
<div className="ml-auto flex items-center gap-2">
<BlockCopyCodeButton />
</div>
</figcaption>
<div
key={file?.path}
dangerouslySetInnerHTML={{ __html: file?.highlightedContent ?? "" }}
className="no-scrollbar overflow-y-auto"
/>
</figure>
</div>
)
}
export function BlockViewerFileTree() {
const { tree } = useBlockViewer()
if (!tree) {
return null
}
return (
<SidebarProvider className="flex min-h-full! flex-col border-r">
<Sidebar collapsible="none" className="w-full flex-1">
<SidebarGroupLabel className="h-12 rounded-none border-b px-4 text-sm">
Files
</SidebarGroupLabel>
<SidebarGroup className="p-0">
<SidebarGroupContent>
<SidebarMenu className="translate-x-0 gap-1.5">
{tree.map((file, index) => (
<Tree key={index} item={file} index={1} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</Sidebar>
</SidebarProvider>
)
}
function Tree({ item, index }: { item: FileTree; index: number }) {
const { activeFile, setActiveFile } = useBlockViewer()
if (!item.children) {
return (
<SidebarMenuItem>
<SidebarMenuButton
isActive={item.path === activeFile}
onClick={() => item.path && setActiveFile(item.path)}
className="rounded-none pl-(--index) whitespace-nowrap hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15"
data-index={index}
style={
{
"--index": `${index * (index === 2 ? 1.2 : 1.3)}rem`,
} as React.CSSProperties
}
>
<ChevronRight className="invisible" />
<File className="h-4 w-4" />
{item.name}
</SidebarMenuButton>
</SidebarMenuItem>
)
}
return (
<SidebarMenuItem>
<Collapsible
className="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
defaultOpen
>
<CollapsibleTrigger asChild>
<SidebarMenuButton
className="rounded-none pl-(--index) whitespace-nowrap hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15"
style={
{
"--index": `${index * (index === 1 ? 1 : 1.2)}rem`,
} as React.CSSProperties
}
>
<ChevronRight className="transition-transform" />
<Folder />
{item.name}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="m-0 w-full translate-x-0 border-none p-0">
{item.children.map((subItem, key) => (
<Tree key={key} item={subItem} index={index + 1} />
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
)
}
function BlockCopyCodeButton() {
const { activeFile, item } = useBlockViewer()
const { copyToClipboard, isCopied } = useCopyToClipboard()
const file = React.useMemo(() => {
return item.files?.find((file) => file.target === activeFile)
}, [activeFile, item.files])
const content = file?.content
if (!content) {
return null
}
return (
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => {
copyToClipboard(content)
trackEvent({
name: "copy_block_code",
properties: {
name: item.name,
file: file.path,
},
})
}}
>
{isCopied ? <Check /> : <Clipboard />}
</Button>
)
}
function BlockViewer({
item,
tree,
highlightedFiles,
children,
styleName,
...props
}: BlockViewerProps) {
return (
<BlockViewerProvider
item={item}
tree={tree}
highlightedFiles={highlightedFiles}
{...props}
>
<BlockViewerToolbar styleName={styleName} />
<BlockViewerView styleName={styleName} />
<BlockViewerCode />
<BlockViewerMobile>{children}</BlockViewerMobile>
</BlockViewerProvider>
)
}
export { BlockViewer }

View File

@@ -0,0 +1,55 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { registryCategories } from "@/lib/categories"
import { ScrollArea, ScrollBar } from "@/registry/new-york-v4/ui/scroll-area"
export function BlocksNav() {
const pathname = usePathname()
return (
<div className="relative overflow-hidden">
<ScrollArea className="max-w-none">
<div className="flex items-center">
<BlocksNavLink
category={{ name: "Featured", slug: "", hidden: false }}
isActive={pathname === "/blocks"}
/>
{registryCategories.map((category) => (
<BlocksNavLink
key={category.slug}
category={category}
isActive={pathname === `/blocks/${category.slug}`}
/>
))}
</div>
<ScrollBar orientation="horizontal" className="invisible" />
</ScrollArea>
</div>
)
}
function BlocksNavLink({
category,
isActive,
}: {
category: (typeof registryCategories)[number]
isActive: boolean
}) {
if (category.hidden) {
return null
}
return (
<Link
href={`/blocks/${category.slug}`}
key={category.slug}
className="flex h-7 items-center justify-center px-4 text-center text-base font-medium text-muted-foreground transition-colors hover:text-primary data-[active=true]:text-primary"
data-active={isActive}
>
{category.name}
</Link>
)
}

View File

@@ -0,0 +1,36 @@
import { cn } from "cn"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/registry/new-york-v4/ui/alert"
export function Callout({
title,
children,
icon,
className,
variant = "default",
...props
}: React.ComponentProps<typeof Alert> & {
icon?: React.ReactNode
variant?: "default" | "info" | "warning"
}) {
return (
<Alert
data-variant={variant}
className={cn(
"not-typeset mt-6 w-auto rounded-2xl border-surface bg-surface text-surface-foreground md:-mx-1 **:[code]:border",
className
)}
{...props}
>
{icon}
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription className="text-card-foreground/80">
{children}
</AlertDescription>
</Alert>
)
}

View File

@@ -0,0 +1,132 @@
"use client"
import * as React from "react"
import { MinusIcon, PlusIcon } from "lucide-react"
import { Bar, BarChart } from "recharts"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import {
ChartContainer,
type ChartConfig,
} from "@/registry/new-york-v4/ui/chart"
const data = [
{
goal: 400,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 239,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 349,
},
]
const chartConfig = {
goal: {
label: "Goal",
color: "var(--primary)",
},
} satisfies ChartConfig
export function CardsActivityGoal() {
const [goal, setGoal] = React.useState(350)
function onClick(adjustment: number) {
setGoal(Math.max(200, Math.min(400, goal + adjustment)))
}
return (
<Card className="h-full gap-5">
<CardHeader>
<CardTitle>Move Goal</CardTitle>
<CardDescription>Set your daily activity goal.</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 flex-col">
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="icon"
className="size-7 rounded-full"
onClick={() => onClick(-10)}
disabled={goal <= 200}
>
<MinusIcon />
<span className="sr-only">Decrease</span>
</Button>
<div className="text-center">
<div className="text-4xl font-bold tracking-tighter tabular-nums">
{goal}
</div>
<div className="text-xs text-muted-foreground uppercase">
Calories/day
</div>
</div>
<Button
variant="outline"
size="icon"
className="size-7 rounded-full"
onClick={() => onClick(10)}
disabled={goal >= 400}
>
<PlusIcon />
<span className="sr-only">Increase</span>
</Button>
</div>
<div className="flex-1">
<ChartContainer
config={chartConfig}
className="aspect-auto h-full w-full"
>
<BarChart data={data}>
<Bar dataKey="goal" radius={4} fill="var(--color-goal)" />
</BarChart>
</ChartContainer>
</div>
</CardContent>
<CardFooter>
<Button className="w-full" variant="secondary">
Set Goal
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
"use client"
import { addDays } from "date-fns"
import { Calendar } from "@/registry/new-york-v4/ui/calendar"
import { Card, CardContent } from "@/registry/new-york-v4/ui/card"
const start = new Date(2025, 5, 5)
export function CardsCalendar() {
return (
<Card className="hidden max-w-[260px] p-0 sm:flex">
<CardContent className="p-0">
<Calendar
numberOfMonths={1}
mode="range"
defaultMonth={start}
selected={{
from: start,
to: addDays(start, 8),
}}
/>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,273 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { ArrowUpIcon, CheckIcon, PlusIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/registry/new-york-v4/ui/avatar"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/registry/new-york-v4/ui/card"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/new-york-v4/ui/command"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/registry/new-york-v4/ui/dialog"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/registry/new-york-v4/ui/input-group"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
const users = [
{
name: "Olivia Martin",
email: "m@example.com",
avatar: "/avatars/01.png",
},
{
name: "Isabella Nguyen",
email: "isabella.nguyen@email.com",
avatar: "/avatars/03.png",
},
{
name: "Emma Wilson",
email: "emma@example.com",
avatar: "/avatars/05.png",
},
{
name: "Jackson Lee",
email: "lee@example.com",
avatar: "/avatars/02.png",
},
{
name: "William Kim",
email: "will@email.com",
avatar: "/avatars/04.png",
},
] as const
type User = (typeof users)[number]
export function CardsChat() {
const [open, setOpen] = React.useState(false)
const [selectedUsers, setSelectedUsers] = React.useState<User[]>([])
const [messages, setMessages] = React.useState([
{
role: "agent",
content: "Hi, how can I help you today?",
},
{
role: "user",
content: "Hey, I'm having trouble with my account.",
},
{
role: "agent",
content: "What seems to be the problem?",
},
{
role: "user",
content: "I can't log in.",
},
])
const [input, setInput] = React.useState("")
const inputLength = input.trim().length
return (
<>
<Card>
<CardHeader className="flex flex-row items-center">
<div className="flex items-center gap-4">
<Avatar className="border">
<AvatarImage src="/avatars/01.png" alt="Image" />
<AvatarFallback>S</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<p className="text-sm leading-none font-medium">Sofia Davis</p>
<p className="text-xs text-muted-foreground">m@example.com</p>
</div>
</div>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="secondary"
className="ml-auto size-8 rounded-full"
onClick={() => setOpen(true)}
>
<PlusIcon />
<span className="sr-only">New message</span>
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={10}>New message</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex w-max max-w-[75%] flex-col gap-2 rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{message.content}
</div>
))}
</div>
</CardContent>
<CardFooter>
<form
onSubmit={(event) => {
event.preventDefault()
if (inputLength === 0) return
setMessages([
...messages,
{
role: "user",
content: input,
},
])
setInput("")
}}
className="relative w-full"
>
<InputGroup>
<InputGroupInput
id="message"
placeholder="Type your message..."
autoComplete="off"
value={input}
onChange={(event) => setInput(event.target.value)}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-xs"
className="rounded-full"
>
<ArrowUpIcon />
<span className="sr-only">Send</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</CardFooter>
</Card>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="gap-0 p-0 outline-none">
<DialogHeader className="px-4 pt-5 pb-4">
<DialogTitle>New message</DialogTitle>
<DialogDescription>
Invite a user to this thread. This will create a new group
message.
</DialogDescription>
</DialogHeader>
<Command className="overflow-hidden rounded-t-none border-t bg-transparent">
<CommandInput placeholder="Search user..." />
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.email}
data-active={selectedUsers.includes(user)}
className="data-[active=true]:opacity-50"
onSelect={() => {
if (selectedUsers.includes(user)) {
return setSelectedUsers(
selectedUsers.filter(
(selectedUser) => selectedUser !== user
)
)
}
return setSelectedUsers(
[...users].filter((u) =>
[...selectedUsers, user].includes(u)
)
)
}}
>
<Avatar className="border">
<AvatarImage src={user.avatar} alt="Image" />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
<div className="ml-2">
<p className="text-sm leading-none font-medium">
{user.name}
</p>
<p className="text-sm text-muted-foreground">
{user.email}
</p>
</div>
{selectedUsers.includes(user) ? (
<CheckIcon className="ml-auto flex size-4 text-primary" />
) : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<DialogFooter className="flex items-center border-t p-4 sm:justify-between">
{selectedUsers.length > 0 ? (
<div className="flex -space-x-2 overflow-hidden">
{selectedUsers.map((user) => (
<Avatar key={user.email} className="inline-block border">
<AvatarImage src={user.avatar} />
<AvatarFallback>{user.name[0]}</AvatarFallback>
</Avatar>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Select users to add to this thread.
</p>
)}
<Button
disabled={selectedUsers.length < 2}
size="sm"
onClick={() => {
setOpen(false)
}}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@/registry/new-york-v4/ui/field"
import { Switch } from "@/registry/new-york-v4/ui/switch"
export function CardsCookieSettings() {
return (
<Card>
<CardHeader>
<CardTitle>Cookie Settings</CardTitle>
<CardDescription>Manage your cookie settings here.</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="necessary">Strictly Necessary</FieldLabel>
<FieldDescription>
These cookies are essential in order to use the website and use
its features.
</FieldDescription>
</FieldContent>
<Switch id="necessary" defaultChecked aria-label="Necessary" />
</Field>
<Field>
<Button variant="outline">Save preferences</Button>
</Field>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import {
Field,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/registry/new-york-v4/ui/field"
import { Input } from "@/registry/new-york-v4/ui/input"
export function CardsCreateAccount() {
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>
Enter your email below to create your account
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field className="grid grid-cols-2 gap-6">
<Button variant="outline">
<svg viewBox="0 0 438.549 438.549">
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
></path>
</svg>
GitHub
</Button>
<Button variant="outline">
<svg role="img" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
Google
</Button>
</Field>
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
Or continue with
</FieldSeparator>
<Field>
<FieldLabel htmlFor="email-create-account">Email</FieldLabel>
<Input
id="email-create-account"
type="email"
placeholder="m@example.com"
/>
</Field>
<Field>
<FieldLabel htmlFor="password-create-account">Password</FieldLabel>
<Input id="password-create-account" type="password" />
</Field>
<Field>
<Button>Create Account</Button>
</Field>
</FieldGroup>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,129 @@
"use client"
import { CartesianGrid, Line, LineChart, XAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/registry/new-york-v4/ui/chart"
const data = [
{
average: 400,
today: 240,
day: "Monday",
},
{
average: 300,
today: 139,
day: "Tuesday",
},
{
average: 200,
today: 980,
day: "Wednesday",
},
{
average: 278,
today: 390,
day: "Thursday",
},
{
average: 189,
today: 480,
day: "Friday",
},
{
average: 239,
today: 380,
day: "Saturday",
},
{
average: 349,
today: 430,
day: "Sunday",
},
]
const chartConfig = {
today: {
label: "Today",
color: "var(--primary)",
},
average: {
label: "Average",
color: "var(--primary)",
},
} satisfies ChartConfig
export function CardsExerciseMinutes() {
return (
<Card>
<CardHeader>
<CardTitle>Exercise Minutes</CardTitle>
<CardDescription>
Your exercise minutes are ahead of where you normally are.
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="w-full md:h-[200px]">
<LineChart
accessibilityLayer
data={data}
margin={{
top: 5,
right: 10,
left: 16,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="day"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
/>
<Line
type="monotone"
dataKey="today"
strokeWidth={2}
stroke="var(--color-today)"
dot={{
fill: "var(--color-today)",
}}
activeDot={{
r: 5,
}}
/>
<Line
type="monotone"
strokeWidth={2}
dataKey="average"
stroke="var(--color-average)"
strokeOpacity={0.5}
dot={{
fill: "var(--color-average)",
opacity: 0.5,
}}
activeDot={{
r: 5,
}}
/>
<ChartTooltip content={<ChartTooltipContent />} />
</LineChart>
</ChartContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,140 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import { Checkbox } from "@/registry/new-york-v4/ui/checkbox"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from "@/registry/new-york-v4/ui/field"
import { Input } from "@/registry/new-york-v4/ui/input"
import {
RadioGroup,
RadioGroupItem,
} from "@/registry/new-york-v4/ui/radio-group"
import { Textarea } from "@/registry/new-york-v4/ui/textarea"
const plans = [
{
id: "starter",
name: "Starter Plan",
description: "For small businesses.",
price: "$10",
},
{
id: "pro",
name: "Pro Plan",
description: "More features and storage.",
price: "$20",
},
] as const
export function CardsForms() {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Upgrade your Subscription</CardTitle>
<CardDescription className="text-balance">
You are currently on the free plan. Upgrade to the pro plan to get
access to all features.
</CardDescription>
</CardHeader>
<CardContent>
<form>
<FieldGroup>
<FieldGroup className="grid grid-cols-2">
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" placeholder="Max Leiter" />
</Field>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" placeholder="mail@acme.com" />
</Field>
</FieldGroup>
<FieldGroup className="grid grid-cols-2 gap-3 md:grid-cols-[1fr_80px_60px]">
<Field>
<FieldLabel htmlFor="card-number">Card Number</FieldLabel>
<Input
id="card-number"
placeholder="1234 1234 1234 1234"
className="col-span-2 md:col-span-1"
/>
</Field>
<Field>
<FieldLabel htmlFor="card-number-expiry">
Expiry Date
</FieldLabel>
<Input id="card-number-expiry" placeholder="MM/YY" />
</Field>
<Field>
<FieldLabel htmlFor="card-number-cvc">CVC</FieldLabel>
<Input id="card-number-cvc" placeholder="CVC" />
</Field>
</FieldGroup>
<FieldSet>
<FieldLegend>Plan</FieldLegend>
<FieldDescription>
Select the plan that best fits your needs.
</FieldDescription>
<RadioGroup
defaultValue="starter"
className="grid grid-cols-2 gap-2"
>
{plans.map((plan) => (
<FieldLabel key={plan.id}>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{plan.name}</FieldTitle>
<FieldDescription className="text-xs">
{plan.description}
</FieldDescription>
</FieldContent>
<RadioGroupItem value={plan.id} id={plan.name} />
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
<Field>
<FieldLabel htmlFor="notes">Notes</FieldLabel>
<Textarea id="notes" placeholder="Enter notes" />
</Field>
<Field>
<Field orientation="horizontal">
<Checkbox id="terms" />
<FieldLabel htmlFor="terms" className="font-normal">
I agree to the terms and conditions
</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="newsletter" defaultChecked />
<FieldLabel htmlFor="newsletter" className="font-normal">
Allow us to send you emails
</FieldLabel>
</Field>
</Field>
<Field orientation="horizontal">
<Button variant="outline" size="sm">
Cancel
</Button>
<Button size="sm">Upgrade Plan</Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,59 @@
import { CardsActivityGoal } from "@/components/cards/activity-goal"
import { CardsCalendar } from "@/components/cards/calendar"
import { CardsChat } from "@/components/cards/chat"
import { CardsCookieSettings } from "@/components/cards/cookie-settings"
import { CardsCreateAccount } from "@/components/cards/create-account"
import { CardsExerciseMinutes } from "@/components/cards/exercise-minutes"
import { CardsForms } from "@/components/cards/forms"
import { CardsReportIssue } from "@/components/cards/report-issue"
import { CardsShare } from "@/components/cards/share"
import { CardsStats } from "@/components/cards/stats"
import { CardsTeamMembers } from "@/components/cards/team-members"
export function CardsDemo() {
return (
<div className="md:grids-col-2 grid **:data-[slot=card]:shadow-none md:gap-4 lg:grid-cols-10 xl:grid-cols-11">
<div className="grid gap-4 lg:col-span-4 xl:col-span-6">
<CardsStats />
<div className="grid gap-1 sm:grid-cols-[auto_1fr] md:hidden">
<CardsCalendar />
<div className="pt-3 sm:pt-0 sm:pl-2 xl:pl-4">
<CardsActivityGoal />
</div>
<div className="pt-3 sm:col-span-2 xl:pt-4">
<CardsExerciseMinutes />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
<div className="flex flex-col gap-4">
<CardsForms />
<CardsTeamMembers />
<CardsCookieSettings />
</div>
<div className="flex flex-col gap-4">
<CardsCreateAccount />
<CardsChat />
<div className="hidden xl:block">
<CardsReportIssue />
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 lg:col-span-6 xl:col-span-5">
<div className="hidden gap-1 sm:grid-cols-[auto_1fr] md:grid">
<CardsCalendar />
<div className="pt-3 sm:pt-0 sm:pl-2 xl:pl-3">
<CardsActivityGoal />
</div>
<div className="pt-3 sm:col-span-2 xl:pt-3">
<CardsExerciseMinutes />
</div>
</div>
<CardsShare />
<div className="xl:hidden">
<CardsReportIssue />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
"use client"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import { Input } from "@/registry/new-york-v4/ui/input"
import { Label } from "@/registry/new-york-v4/ui/label"
import {
RadioGroup,
RadioGroupItem,
} from "@/registry/new-york-v4/ui/radio-group"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york-v4/ui/select"
const plans = [
{
id: "starter",
name: "Starter Plan",
description: "Perfect for small businesses.",
price: "$10",
},
{
id: "pro",
name: "Pro Plan",
description: "Advanced features with more storage.",
price: "$20",
},
] as const
export function CardsPaymentMethod() {
return (
<Card>
<CardHeader>
<CardTitle>Payment Method</CardTitle>
<CardDescription>
Add a new payment method to your account.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="First Last" />
</div>
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Plan</legend>
<p className="text-sm text-muted-foreground">
Select the plan that best fits your needs.
</p>
<RadioGroup defaultValue="starter" className="grid gap-3">
{plans.map((plan) => (
<Label
className="flex items-start gap-3 rounded-lg border p-3 has-[[data-state=checked]]:border-ring has-[[data-state=checked]]:bg-primary/5"
key={plan.id}
>
<RadioGroupItem
value={plan.id}
id={plan.name}
className="data-[state=checked]:border-primary"
/>
<div className="grid gap-1 font-normal">
<div className="font-medium">{plan.name}</div>
<div className="pr-2 text-xs leading-snug text-balance text-muted-foreground">
{plan.description}
</div>
</div>
</Label>
))}
</RadioGroup>
</fieldset>
<div className="flex flex-col gap-3">
<Label htmlFor="number">Card number</Label>
<Input id="number" placeholder="" />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="month">Expires</Label>
<Select>
<SelectTrigger id="month" aria-label="Month" className="w-full">
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">January</SelectItem>
<SelectItem value="2">February</SelectItem>
<SelectItem value="3">March</SelectItem>
<SelectItem value="4">April</SelectItem>
<SelectItem value="5">May</SelectItem>
<SelectItem value="6">June</SelectItem>
<SelectItem value="7">July</SelectItem>
<SelectItem value="8">August</SelectItem>
<SelectItem value="9">September</SelectItem>
<SelectItem value="10">October</SelectItem>
<SelectItem value="11">November</SelectItem>
<SelectItem value="12">December</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="year">Year</Label>
<Select>
<SelectTrigger id="year" aria-label="Year" className="w-full">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 10 }, (_, i) => (
<SelectItem key={i} value={`${new Date().getFullYear() + i}`}>
{new Date().getFullYear() + i}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="cvc">CVC</Label>
<Input id="cvc" placeholder="CVC" />
</div>
</div>
</CardContent>
<CardFooter>
<Button className="w-full">Continue</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,100 @@
"use client"
import * as React from "react"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import { Field, FieldGroup, FieldLabel } from "@/registry/new-york-v4/ui/field"
import { Input } from "@/registry/new-york-v4/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york-v4/ui/select"
import { Textarea } from "@/registry/new-york-v4/ui/textarea"
export function CardsReportIssue() {
const id = React.useId()
return (
<Card>
<CardHeader>
<CardTitle>Report an issue</CardTitle>
<CardDescription>
What area are you having problems with?
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<FieldGroup className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor={`area-${id}`}>Area</FieldLabel>
<Select defaultValue="billing">
<SelectTrigger
id={`area-${id}`}
aria-label="Area"
className="w-full"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="billing">Billing</SelectItem>
<SelectItem value="account">Account</SelectItem>
<SelectItem value="deployments">Deployments</SelectItem>
<SelectItem value="support">Support</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor={`security-level-${id}`}>
Security Level
</FieldLabel>
<Select defaultValue="2">
<SelectTrigger
id={`security-level-${id}`}
className="w-full [&_span]:block! [&_span]:truncate"
aria-label="Security Level"
>
<SelectValue placeholder="Select level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Severity 1 (Highest)</SelectItem>
<SelectItem value="2">Severity 2</SelectItem>
<SelectItem value="3">Severity 3</SelectItem>
<SelectItem value="4">Severity 4 (Lowest)</SelectItem>
</SelectContent>
</Select>
</Field>
</FieldGroup>
<Field>
<FieldLabel htmlFor={`subject-${id}`}>Subject</FieldLabel>
<Input id={`subject-${id}`} placeholder="I need help with..." />
</Field>
<Field>
<FieldLabel htmlFor={`description-${id}`}>Description</FieldLabel>
<Textarea
id={`description-${id}`}
placeholder="Please include all information relevant to your issue."
className="min-h-24"
/>
</Field>
<Field orientation="horizontal" className="justify-end">
<Button variant="ghost" size="sm">
Cancel
</Button>
<Button size="sm">Submit</Button>
</Field>
</FieldGroup>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,117 @@
"use client"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/registry/new-york-v4/ui/avatar"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import { Input } from "@/registry/new-york-v4/ui/input"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from "@/registry/new-york-v4/ui/item"
import { Label } from "@/registry/new-york-v4/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york-v4/ui/select"
import { Separator } from "@/registry/new-york-v4/ui/separator"
const people = [
{
name: "Olivia Martin",
email: "m@example.com",
avatar: "/avatars/03.png",
},
{
name: "Isabella Nguyen",
email: "b@example.com",
avatar: "/avatars/04.png",
},
{
name: "Sofia Davis",
email: "p@example.com",
avatar: "/avatars/05.png",
},
{
name: "Ethan Thompson",
email: "e@example.com",
avatar: "/avatars/01.png",
},
]
export function CardsShare() {
return (
<Card>
<CardHeader>
<CardTitle>Share this document</CardTitle>
<CardDescription>
Anyone with the link can view this document.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Label htmlFor="link" className="sr-only">
Link
</Label>
<Input
id="link"
value="http://example.com/link/to/document"
className="h-8"
readOnly
/>
<Button size="sm" variant="outline" className="shadow-none">
Copy Link
</Button>
</div>
<Separator className="my-4" />
<div className="flex flex-col gap-4">
<div className="text-sm font-medium">People with access</div>
<ItemGroup>
{people.map((person) => (
<Item key={person.email} className="px-0 py-2">
<Avatar>
<AvatarImage src={person.avatar} alt="Image" />
<AvatarFallback>{person.name.charAt(0)}</AvatarFallback>
</Avatar>
<ItemContent>
<ItemTitle>{person.name}</ItemTitle>
<ItemDescription>{person.email}</ItemDescription>
</ItemContent>
<ItemActions>
<Select defaultValue="edit">
<SelectTrigger
className="ml-auto pr-2"
aria-label="Edit"
size="sm"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="edit">Can edit</SelectItem>
<SelectItem value="view">Can view</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
))}
</ItemGroup>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,132 @@
"use client"
import { Area, AreaChart, Line, LineChart } from "recharts"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import {
ChartContainer,
type ChartConfig,
} from "@/registry/new-york-v4/ui/chart"
const data = [
{
revenue: 10400,
subscription: 40,
},
{
revenue: 14405,
subscription: 90,
},
{
revenue: 9400,
subscription: 200,
},
{
revenue: 8200,
subscription: 278,
},
{
revenue: 7000,
subscription: 89,
},
{
revenue: 9600,
subscription: 239,
},
{
revenue: 11244,
subscription: 78,
},
{
revenue: 26475,
subscription: 89,
},
]
const chartConfig = {
revenue: {
label: "Revenue",
color: "var(--primary)",
},
subscription: {
label: "Subscriptions",
color: "var(--primary)",
},
} satisfies ChartConfig
export function CardsStats() {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-3xl">$15,231.89</CardTitle>
<CardDescription>+20.1% from last month</CardDescription>
</CardHeader>
<CardContent className="pb-0">
<ChartContainer config={chartConfig} className="h-[80px] w-full">
<LineChart
data={data}
margin={{
top: 5,
right: 10,
left: 10,
bottom: 0,
}}
>
<Line
type="monotone"
strokeWidth={2}
dataKey="revenue"
stroke="var(--color-revenue)"
activeDot={{
r: 6,
}}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="pb-0 lg:hidden xl:flex">
<CardHeader>
<CardDescription>Subscriptions</CardDescription>
<CardTitle className="text-3xl">+2,350</CardTitle>
<CardDescription>+180.1% from last month</CardDescription>
<CardAction>
<Button variant="ghost" size="sm">
View More
</Button>
</CardAction>
</CardHeader>
<CardContent className="mt-auto max-h-[124px] flex-1 p-0">
<ChartContainer config={chartConfig} className="size-full">
<AreaChart
data={data}
margin={{
left: 0,
right: 0,
}}
>
<Area
dataKey="subscription"
fill="var(--color-subscription)"
fillOpacity={0.05}
stroke="var(--color-subscription)"
strokeWidth={2}
type="monotone"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,142 @@
"use client"
import { ChevronDownIcon } from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/registry/new-york-v4/ui/avatar"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york-v4/ui/card"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/new-york-v4/ui/command"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from "@/registry/new-york-v4/ui/item"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/new-york-v4/ui/popover"
const teamMembers = [
{
name: "Sofia Davis",
email: "m@example.com",
avatar: "/avatars/01.png",
role: "Owner",
},
{
name: "Jackson Lee",
email: "p@example.com",
avatar: "/avatars/02.png",
role: "Developer",
},
{
name: "Isabella Nguyen",
email: "i@example.com",
avatar: "/avatars/03.png",
role: "Billing",
},
]
const roles = [
{
name: "Viewer",
description: "Can view and comment.",
},
{
name: "Developer",
description: "Can view, comment and edit.",
},
{
name: "Billing",
description: "Can view, comment and manage billing.",
},
{
name: "Owner",
description: "Admin-level access to all resources.",
},
]
export function CardsTeamMembers() {
return (
<Card className="gap-4">
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>
Invite your team members to collaborate.
</CardDescription>
</CardHeader>
<CardContent>
<ItemGroup>
{teamMembers.map((member) => (
<Item key={member.name} className="px-0 py-2">
<Avatar className="border">
<AvatarImage src={member.avatar} alt="Image" />
<AvatarFallback>{member.name.charAt(0)}</AvatarFallback>
</Avatar>
<ItemContent>
<ItemTitle>{member.name}</ItemTitle>
<ItemDescription>{member.email}</ItemDescription>
</ItemContent>
<ItemActions>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto shadow-none"
>
{member.role} <ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="end">
<Command>
<CommandInput placeholder="Select role..." />
<CommandList>
<CommandEmpty>No roles found.</CommandEmpty>
<CommandGroup>
{roles.map((role) => (
<CommandItem key={role.name}>
<div className="flex flex-col">
<span className="text-sm font-medium">
{role.name}
</span>
<span className="text-sm text-muted-foreground">
{role.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</ItemActions>
</Item>
))}
</ItemGroup>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "cn"
import { useMediaQuery } from "@/hooks/use-media-query"
import { ChartCopyButton } from "@/components/chart-copy-button"
import { type Chart } from "@/components/chart-display"
import { getIconForLanguageExtension } from "@/components/icons"
import { OpenInV0Button } from "@/components/open-in-v0-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/registry/new-york-v4/ui/drawer"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/registry/new-york-v4/ui/sheet"
export function ChartCodeViewer({
chart,
className,
children,
}: {
chart: Chart
} & React.ComponentProps<"div">) {
const isDesktop = useMediaQuery("(min-width: 768px)")
const button = (
<Button
size="sm"
variant="outline"
className="h-6 rounded-[6px] border bg-transparent px-2 text-xs text-foreground shadow-none hover:bg-muted dark:text-foreground"
>
View Code
</Button>
)
const content = (
<div className="flex min-h-0 flex-1 flex-col gap-0">
<div className="chart-wrapper hidden theme-container sm:block [&_[data-chart]]:mx-auto [&_[data-chart]]:max-h-[35vh] [&>div]:rounded-none [&>div]:border-0 [&>div]:border-b [&>div]:shadow-none">
{children}
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden p-4">
<figure
data-rehype-pretty-code-figure=""
className="mt-0 flex h-auto min-w-0 flex-1 flex-col overflow-hidden"
>
<figcaption
className="flex h-12 shrink-0 items-center gap-2 border-b py-2 pr-2 pl-4 text-foreground [&>svg]:size-4 [&>svg]:text-foreground [&>svg]:opacity-70"
data-language="tsx"
>
{getIconForLanguageExtension("tsx")}
{chart.name}
<div className="ml-auto flex items-center gap-2">
<ChartCopyButton
event="copy_chart_code"
name={chart.name}
code={chart.files?.[0]?.content ?? ""}
/>
<OpenInV0Button name={chart.name} className="rounded-sm" />
</div>
</figcaption>
<div
dangerouslySetInnerHTML={{
__html: chart.highlightedCode,
}}
className="no-scrollbar overflow-y-auto"
/>
</figure>
</div>
</div>
)
if (!isDesktop) {
return (
<Drawer>
<DrawerTrigger asChild>{button}</DrawerTrigger>
<DrawerContent
className={cn(
"flex max-h-[80vh] flex-col sm:max-h-[90vh] [&>div.bg-muted]:shrink-0",
className
)}
>
<DrawerHeader className="sr-only">
<DrawerTitle>Code</DrawerTitle>
<DrawerDescription>View the code for the chart.</DrawerDescription>
</DrawerHeader>
<div className="flex h-full flex-col overflow-auto">{content}</div>
</DrawerContent>
</Drawer>
)
}
return (
<Sheet>
<SheetTrigger asChild>{button}</SheetTrigger>
<SheetContent
side="right"
className={cn(
"flex flex-col gap-0 border-l-0 p-0 sm:max-w-sm md:w-[700px] md:max-w-[700px] dark:border-l",
className
)}
>
<SheetHeader className="sr-only">
<SheetTitle>Code</SheetTitle>
<SheetDescription>View the code for the chart.</SheetDescription>
</SheetHeader>
{content}
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { cn } from "cn"
import { trackEvent, type Event } from "@/lib/events"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
export function ChartCopyButton({
event,
name,
code,
className,
...props
}: {
event: Event["name"]
name: string
code: string
} & React.ComponentProps<typeof Button>) {
const [hasCopied, setHasCopied] = React.useState(false)
React.useEffect(() => {
setTimeout(() => {
setHasCopied(false)
}, 2000)
}, [hasCopied])
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className={cn(
"[&_svg]-h-3.5 h-7 w-7 rounded-[6px] [&_svg]:w-3.5",
className
)}
onClick={() => {
navigator.clipboard.writeText(code)
trackEvent({
name: event,
properties: {
name,
},
})
setHasCopied(true)
}}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? <IconCheck /> : <IconCopy />}
</Button>
</TooltipTrigger>
<TooltipContent className="bg-black text-white">Copy code</TooltipContent>
</Tooltip>
)
}

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import { cn } from "cn"
import { type registryItemSchema } from "shadcn/schema"
import { type z } from "zod"
import { highlightCode } from "@/lib/highlight-code"
import { getRegistryItem } from "@/lib/registry"
import { ChartIframe } from "@/components/chart-iframe"
import { ChartToolbar } from "@/components/chart-toolbar"
import { type Style } from "@/registry/_legacy-styles"
export type Chart = z.infer<typeof registryItemSchema> & {
highlightedCode: string
}
export function ChartDisplay({
chart,
styleName,
className,
}: {
chart: Chart
styleName: string
} & React.ComponentProps<"div">) {
return (
<div
className={cn(
"themes-wrapper group relative flex flex-col overflow-hidden rounded-xl transition-all duration-200 ease-in-out hover:z-30",
className
)}
>
<ChartToolbar
chart={chart}
className="relative z-20 flex justify-end px-3 py-2.5"
/>
<div className="relative z-10 overflow-hidden rounded-xl bg-background">
<ChartIframe
src={`/view/${styleName}/${chart.name}`}
height={460}
title={chart.name}
/>
</div>
</div>
)
}
// Exported for parallel prefetching in page components.
export const getCachedRegistryItem = React.cache(
async (name: string, styleName: Style["name"]) => {
return await getRegistryItem(name, styleName)
}
)
export const getChartHighlightedCode = React.cache(async (content: string) => {
return await highlightCode(content)
})

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import { cn } from "cn"
export function ChartIframe({
src,
height,
title,
}: {
src: string
height: number
title: string
}) {
const [loaded, setLoaded] = React.useState(false)
return (
<iframe
src={src}
className={cn(
"w-full border-none transition-opacity duration-300",
loaded ? "opacity-100" : "opacity-0"
)}
height={height}
loading="lazy"
title={title}
onLoad={() => setLoaded(true)}
/>
)
}

View File

@@ -0,0 +1,107 @@
"use client"
import { cn } from "cn"
import {
AreaChartIcon,
BarChartBigIcon,
HexagonIcon,
LineChartIcon,
MousePointer2Icon,
PieChartIcon,
RadarIcon,
} from "lucide-react"
import { ChartCodeViewer } from "@/components/chart-code-viewer"
import { ChartCopyButton } from "@/components/chart-copy-button"
import { type Chart } from "@/components/chart-display"
import { Separator } from "@/registry/new-york-v4/ui/separator"
export function ChartToolbar({
chart,
className,
children,
}: {
chart: Chart
} & React.ComponentProps<"div">) {
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="flex items-center gap-1.5 pl-1 text-[13px] text-muted-foreground [&>svg]:h-[0.9rem] [&>svg]:w-[0.9rem]">
<ChartTitle chart={chart} />
</div>
<div className="ml-auto flex items-center gap-2 [&>form]:flex">
<ChartCopyButton
event="copy_chart_code"
name={chart.name}
code={chart.files?.[0]?.content ?? ""}
className="[&_svg]-h-3 h-6 w-6 rounded-[6px] bg-transparent text-foreground shadow-none hover:bg-muted dark:text-foreground [&_svg]:w-3"
/>
<Separator
orientation="vertical"
className="mx-0 hidden h-4! md:flex"
/>
<ChartCodeViewer chart={chart}>{children}</ChartCodeViewer>
</div>
</div>
)
}
function ChartTitle({ chart }: { chart: Chart }) {
if (chart.name.includes("chart-line")) {
return (
<>
<LineChartIcon /> Line Chart
</>
)
}
if (chart.name.includes("chart-bar")) {
return (
<>
<BarChartBigIcon /> Bar Chart
</>
)
}
if (chart.name.includes("chart-pie")) {
return (
<>
<PieChartIcon /> Pie Chart
</>
)
}
if (chart.name.includes("chart-area")) {
return (
<>
<AreaChartIcon /> Area Chart
</>
)
}
if (chart.name.includes("chart-radar")) {
return (
<>
<HexagonIcon /> Radar Chart
</>
)
}
if (chart.name.includes("chart-radial")) {
return (
<>
<RadarIcon /> Radial Chart
</>
)
}
if (chart.name.includes("chart-tooltip")) {
return (
<>
<MousePointer2Icon />
Tooltip
</>
)
}
return chart.name
}

View File

@@ -0,0 +1,67 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "cn"
import { ScrollArea, ScrollBar } from "@/registry/new-york-v4/ui/scroll-area"
const links = [
{
name: "Area Charts",
href: "/charts/area#charts",
},
{
name: "Bar Charts",
href: "/charts/bar#charts",
},
{
name: "Line Charts",
href: "/charts/line#charts",
},
{
name: "Pie Charts",
href: "/charts/pie#charts",
},
{
name: "Radar Charts",
href: "/charts/radar#charts",
},
{
name: "Radial Charts",
href: "/charts/radial#charts",
},
{
name: "Tooltips",
href: "/charts/tooltip#charts",
},
]
export function ChartsNav({
className,
...props
}: React.ComponentProps<"div">) {
const pathname = usePathname()
return (
<div className="relative overflow-hidden">
<ScrollArea className="max-w-[600px] lg:max-w-none">
<div className={cn("flex items-center", className)} {...props}>
{links.map((link) => (
<Link
href={link.href}
key={link.href}
data-active={link.href.startsWith(pathname)}
className={cn(
"flex h-7 shrink-0 items-center justify-center px-4 text-center text-base font-medium text-muted-foreground transition-colors hover:text-primary data-[active=true]:text-primary"
)}
>
{link.name}
</Link>
))}
</div>
<ScrollBar orientation="horizontal" className="invisible" />
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,128 @@
"use client"
import * as React from "react"
import { IconCheck, IconCopy, IconTerminal } from "@tabler/icons-react"
import { useConfig } from "@/hooks/use-config"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
export function CodeBlockCommand({
__npm__,
__yarn__,
__pnpm__,
__bun__,
}: React.ComponentProps<"pre"> & {
__npm__?: string
__yarn__?: string
__pnpm__?: string
__bun__?: string
}) {
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
const packageManager = config.packageManager || "pnpm"
const tabs = React.useMemo(() => {
return {
pnpm: __pnpm__,
npm: __npm__,
yarn: __yarn__,
bun: __bun__,
}
}, [__npm__, __pnpm__, __yarn__, __bun__])
const copyCommand = React.useCallback(() => {
const command = tabs[packageManager]
if (!command) {
return
}
copyToClipboardWithMeta(command, {
name: "copy_npm_command",
properties: {
command,
pm: packageManager,
},
})
setHasCopied(true)
}, [packageManager, tabs])
return (
<div className="overflow-x-auto">
<Tabs
value={packageManager}
className="gap-0"
onValueChange={(value) => {
setConfig({
...config,
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
})
}}
>
<div className="flex items-center gap-2 border-b border-border/50 px-3 py-1">
<div className="flex size-4 items-center justify-center rounded-[1px] bg-foreground opacity-70">
<IconTerminal className="size-3 text-code" />
</div>
<TabsList className="rounded-none bg-transparent p-0">
{Object.entries(tabs).map(([key]) => {
return (
<TabsTrigger
key={key}
value={key}
className="h-7 border border-transparent pt-0.5 shadow-none! data-[state=active]:border-input data-[state=active]:bg-background!"
>
{key}
</TabsTrigger>
)
})}
</TabsList>
</div>
<div className="no-scrollbar overflow-x-auto">
{Object.entries(tabs).map(([key, value]) => {
return (
<TabsContent key={key} value={key} className="mt-0 px-4 py-3.5">
<pre>
<code
className="relative font-mono text-sm leading-none"
data-language="bash"
>
{value}
</code>
</pre>
</TabsContent>
)
})}
</div>
</Tabs>
<Button
data-slot="copy-button"
size="icon"
variant="ghost"
className="absolute top-2 right-2 z-10 size-7 opacity-70 hover:opacity-100 focus-visible:opacity-100"
onClick={copyCommand}
>
<span className="sr-only">Copy</span>
{hasCopied ? <IconCheck /> : <IconCopy />}
</Button>
</div>
)
}

View File

@@ -0,0 +1,51 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/new-york-v4/ui/collapsible"
import { Separator } from "@/registry/new-york-v4/ui/separator"
export function CodeCollapsibleWrapper({
className,
children,
...props
}: React.ComponentProps<typeof Collapsible>) {
const [isOpened, setIsOpened] = React.useState(false)
return (
<Collapsible
open={isOpened}
onOpenChange={setIsOpened}
className={cn("group/collapsible relative md:-mx-1", className)}
{...props}
>
<CollapsibleTrigger asChild>
<div className="absolute top-1.5 right-9 z-10 flex items-center">
<Button
variant="ghost"
size="sm"
className="h-7 rounded-md px-2 text-muted-foreground"
>
{isOpened ? "Collapse" : "Expand"}
</Button>
<Separator orientation="vertical" className="mx-1.5 h-4!" />
</div>
</CollapsibleTrigger>
<CollapsibleContent
forceMount
className="relative mt-6 overflow-hidden data-[state=closed]:max-h-64 data-[state=closed]:[content-visibility:auto] [&>figure]:mt-0 [&>figure]:md:mx-0!"
>
{children}
</CollapsibleContent>
<CollapsibleTrigger className="absolute inset-x-0 -bottom-2 flex h-20 items-center justify-center rounded-b-lg bg-gradient-to-b from-code/70 to-code text-sm text-muted-foreground group-data-[state=open]/collapsible:hidden">
{isOpened ? "Collapse" : "Expand"}
</CollapsibleTrigger>
</Collapsible>
)
}

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import { useConfig } from "@/hooks/use-config"
import { Tabs } from "@/registry/new-york-v4/ui/tabs"
export function CodeTabs({ children }: React.ComponentProps<typeof Tabs>) {
const [config, setConfig] = useConfig()
const installationType = React.useMemo(() => {
return config.installationType || "cli"
}, [config])
return (
<Tabs
value={installationType}
onValueChange={(value) =>
setConfig({ ...config, installationType: value as "cli" | "manual" })
}
className="relative mt-6 w-full *:data-[slot=tabs-list]:gap-6"
>
{children}
</Tabs>
)
}

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { getColorFormat, type Color } from "@/lib/colors"
import { useColors } from "@/hooks/use-colors"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/registry/new-york-v4/ui/select"
import { Skeleton } from "@/registry/new-york-v4/ui/skeleton"
export function ColorFormatSelector({
color,
className,
...props
}: Omit<React.ComponentProps<typeof SelectTrigger>, "color"> & {
color: Color
}) {
const { format, setFormat, isLoading } = useColors()
const formats = React.useMemo(() => getColorFormat(color), [color])
if (isLoading) {
return <ColorFormatSelectorSkeleton />
}
return (
<Select value={format} onValueChange={setFormat}>
<SelectTrigger
size="sm"
className={cn(
"border-secondary bg-secondary text-secondary-foreground shadow-none",
className
)}
{...props}
>
<span className="font-medium">Format: </span>
<span className="font-mono text-muted-foreground">{format}</span>
</SelectTrigger>
<SelectContent align="end" position="popper" className="rounded-xl">
{Object.entries(formats).map(([format, value]) => (
<SelectItem
key={format}
value={format}
className="gap-2 rounded-lg [&>span]:flex [&>span]:items-center [&>span]:gap-2"
>
<span className="font-medium">{format}</span>
<span className="font-mono text-xs text-muted-foreground">
{value}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function ColorFormatSelectorSkeleton({
className,
...props
}: React.ComponentProps<typeof Skeleton>) {
return (
<Skeleton
className={cn("h-8 w-[132px] gap-1.5 rounded-md", className)}
{...props}
/>
)
}

View File

@@ -0,0 +1,24 @@
import { type ColorPalette } from "@/lib/colors"
import { Color } from "@/components/color"
import { ColorFormatSelector } from "@/components/color-format-selector"
export function ColorPalette({ colorPalette }: { colorPalette: ColorPalette }) {
return (
<div id={colorPalette.name} className="scroll-mt-20 rounded-lg">
<div className="flex items-center px-4">
<div className="flex-1 pl-1 text-sm font-medium">
<h2 className="capitalize">{colorPalette.name}</h2>
</div>
<ColorFormatSelector
color={colorPalette.colors[0]}
className="ml-auto"
/>
</div>
<div className="flex flex-col gap-4 py-4 sm:flex-row sm:gap-2">
{colorPalette.colors.map((color) => (
<Color key={color.hex} color={color} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import { Check, Clipboard } from "lucide-react"
import { toast } from "sonner"
import { type Color } from "@/lib/colors"
import { trackEvent } from "@/lib/events"
import { useColors } from "@/hooks/use-colors"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
export function Color({ color }: { color: Color }) {
const { format, setLastCopied, lastCopied } = useColors()
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 0 })
return (
<button
key={color.hex}
className="group relative flex aspect-[3/1] w-full flex-1 cursor-pointer flex-col gap-2 text-(--text) sm:aspect-[2/3] sm:h-auto sm:w-auto [&>svg]:absolute [&>svg]:top-4 [&>svg]:right-4 [&>svg]:z-10 [&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:opacity-0 [&>svg]:transition-opacity"
data-last-copied={lastCopied === color[format]}
style={
{
"--bg": `${color.oklch}`,
"--text": color.foreground,
} as React.CSSProperties
}
onClick={() => {
copyToClipboard(color[format])
trackEvent({
name: "copy_color",
properties: {
color: color.id,
value: color[format],
format,
},
})
setLastCopied(color[format])
toast.success(`Copied ${color[format]} to clipboard.`)
}}
>
{isCopied ? (
<Check className="group-hover:opacity-100 group-data-[last-copied=true]:opacity-100" />
) : (
<Clipboard className="group-hover:opacity-100" />
)}
<div className="border-ghost w-full flex-1 rounded-md bg-(--bg) after:rounded-lg after:border-input md:rounded-lg" />
<div className="flex w-full flex-col items-center justify-center gap-1">
<span className="font-mono text-xs text-muted-foreground tabular-nums transition-colors group-hover:text-foreground group-data-[last-copied=true]:text-primary sm:hidden xl:flex">
{color.className}
</span>
<span className="hidden font-mono text-xs text-muted-foreground tabular-nums transition-colors group-hover:text-foreground group-data-[last-copied=true]:text-primary sm:flex xl:hidden">
{color.scale}
</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "cn"
import { getColors } from "@/lib/colors"
import { ScrollArea, ScrollBar } from "@/registry/new-york-v4/ui/scroll-area"
export function ColorsNav({
className,
...props
}: React.ComponentProps<"div">) {
const pathname = usePathname()
const colors = getColors()
return (
<div className={cn("flex items-center", className)} {...props}>
<ScrollArea className="max-w-full">
<div className="flex items-center">
{colors.map((colorPalette, index) => (
<Link
href={`/colors#${colorPalette.name}`}
key={colorPalette.name}
data-active={
pathname?.startsWith(colorPalette.name) ||
(index === 0 && pathname === "/colors")
}
className={cn(
"flex h-7 items-center justify-center px-4 text-center text-base font-medium text-muted-foreground capitalize transition-colors hover:text-primary data-[active=true]:text-primary"
)}
>
{colorPalette.name}
</Link>
))}
</div>
<ScrollBar orientation="horizontal" className="invisible" />
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,642 @@
"use client"
import * as React from "react"
import { usePathname, useRouter } from "next/navigation"
import { IconArrowRight } from "@tabler/icons-react"
import { cn } from "cn"
import { useDocsSearch } from "fumadocs-core/search/client"
import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { encodePreset } from "shadcn/preset"
import { type Color, type ColorPalette } from "@/lib/colors"
import { trackEvent } from "@/lib/events"
import { showMcpDocs } from "@/lib/flags"
import { getCurrentBase, getPagesFromFolder } from "@/lib/page-tree"
import { type source } from "@/lib/source"
import { useConfig } from "@/hooks/use-config"
import { useMutationObserver } from "@/hooks/use-mutation-observer"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/new-york-v4/ui/command"
import {
Dialog,
DialogDescription,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
import { STYLES } from "@/registry/styles"
export function CommandMenu({
tree,
colors,
blocks,
navItems,
...props
}: React.ComponentProps<typeof Dialog> & {
tree: typeof source.pageTree
colors: ColorPalette[]
blocks?: { name: string; description: string; categories: string[] }[]
navItems?: { href: string; label: string }[]
}) {
const router = useRouter()
const pathname = usePathname()
const [config] = useConfig()
const currentBase = getCurrentBase(pathname)
const [open, setOpen] = React.useState(false)
const [renderDelayedGroups, setRenderDelayedGroups] = React.useState(false)
const [selectedType, setSelectedType] = React.useState<
"color" | "page" | "component" | "block" | "style" | null
>(null)
const [copyPayload, setCopyPayload] = React.useState("")
const { search, setSearch, query } = useDocsSearch({
type: "fetch",
})
const packageManager = config.packageManager || "pnpm"
// Track search queries with debouncing to avoid excessive tracking.
const searchTimeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined)
const lastTrackedQueryRef = React.useRef<string>("")
const trackSearchQuery = React.useCallback((query: string) => {
const trimmedQuery = query.trim()
// Only track if the query is different from the last tracked query and has content.
if (trimmedQuery && trimmedQuery !== lastTrackedQueryRef.current) {
lastTrackedQueryRef.current = trimmedQuery
trackEvent({
name: "search_query",
properties: {
query: trimmedQuery,
query_length: trimmedQuery.length,
},
})
}
}, [])
const handleSearchChange = React.useCallback(
(value: string) => {
// Clear existing timeout.
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Set new timeout to debounce both search and tracking.
searchTimeoutRef.current = setTimeout(() => {
React.startTransition(() => {
setSearch(value)
trackSearchQuery(value)
})
}, 500)
},
[setSearch, trackSearchQuery]
)
// Cleanup timeout on unmount.
React.useEffect(() => {
if (open) {
const frame = requestAnimationFrame(() => {
setRenderDelayedGroups(true)
})
return () => {
cancelAnimationFrame(frame)
}
}
setRenderDelayedGroups(false)
}, [open])
React.useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
}
}, [])
const commandFilter = React.useCallback(
(value: string, searchValue: string, keywords?: string[]) => {
const extendValue = value + " " + (keywords?.join(" ") || "")
if (extendValue.toLowerCase().includes(searchValue.toLowerCase())) {
return 1
}
return 0
},
[]
)
const handlePageHighlight = React.useCallback(
(isComponent: boolean, item: { url: string; name?: React.ReactNode }) => {
if (isComponent) {
const componentName = item.url.split("/").pop()
setSelectedType("component")
setCopyPayload(
`${packageManager} dlx shadcn@latest add ${componentName}`
)
} else {
setSelectedType("page")
setCopyPayload("")
}
},
[packageManager, setSelectedType, setCopyPayload]
)
const handleColorHighlight = React.useCallback(
(color: Color) => {
setSelectedType("color")
setCopyPayload(color.className)
},
[setSelectedType, setCopyPayload]
)
const handleBlockHighlight = React.useCallback(
(block: { name: string; description: string; categories: string[] }) => {
setSelectedType("block")
setCopyPayload(`${packageManager} dlx shadcn@latest add ${block.name}`)
},
[setSelectedType, setCopyPayload, packageManager]
)
const runCommand = React.useCallback(
(command: () => unknown) => {
setOpen(false)
command()
},
[setOpen]
)
const navItemsSection = React.useMemo(() => {
if (!navItems || navItems.length === 0) {
return null
}
return (
<CommandGroup
heading="Pages"
className="p-0! **:[[cmdk-group-heading]]:scroll-mt-16 **:[[cmdk-group-heading]]:p-3! **:[[cmdk-group-heading]]:pb-1!"
>
{navItems.map((item) => (
<CommandMenuItem
key={item.href}
value={`Navigation ${item.label}`}
keywords={["nav", "navigation", item.label.toLowerCase()]}
onHighlight={() => {
setSelectedType("page")
setCopyPayload("")
}}
onSelect={() => {
runCommand(() => router.push(item.href))
}}
>
<IconArrowRight />
{item.label}
</CommandMenuItem>
))}
</CommandGroup>
)
}, [navItems, runCommand, router])
const stylesSection = React.useMemo(() => {
return (
<CommandGroup
heading="Styles"
className="p-0! **:[[cmdk-group-heading]]:scroll-mt-16 **:[[cmdk-group-heading]]:p-3! **:[[cmdk-group-heading]]:pb-1!"
>
{STYLES.map((style) => (
<CommandMenuItem
key={style.name}
value={`Style ${style.title} ${style.description}`}
keywords={["style", "preset", style.name, style.title]}
onHighlight={() => {
setSelectedType("style")
setCopyPayload("")
}}
onSelect={() => {
runCommand(() =>
router.push(
`/create?preset=${encodePreset({ style: style.name })}`
)
)
}}
>
{style.icon}
{style.title}
<span className="ml-auto text-xs font-normal text-muted-foreground">
Open style in shadcn/create
</span>
</CommandMenuItem>
))}
</CommandGroup>
)
}, [runCommand, router])
const pageGroupsSection = React.useMemo(() => {
return tree.children.map((group) => {
if (group.type !== "folder") {
return null
}
const pages = getPagesFromFolder(group, currentBase).filter((item) => {
if (!showMcpDocs && item.url.includes("/mcp")) {
return false
}
return true
})
if (pages.length === 0) {
return null
}
return (
<CommandGroup
key={group.$id}
heading={group.name}
className="p-0! **:[[cmdk-group-heading]]:scroll-mt-16 **:[[cmdk-group-heading]]:p-3! **:[[cmdk-group-heading]]:pb-1!"
>
{pages.map((item) => {
const isComponent = item.url.includes("/components/")
return (
<CommandMenuItem
key={item.url}
value={
item.name?.toString() ? `${group.name} ${item.name}` : ""
}
keywords={isComponent ? ["component"] : undefined}
onHighlight={() => handlePageHighlight(isComponent, item)}
onSelect={() => {
runCommand(() => router.push(item.url))
}}
>
{isComponent ? (
<div className="aspect-square size-4 rounded-full border border-dashed border-muted-foreground" />
) : (
<IconArrowRight />
)}
{item.name}
</CommandMenuItem>
)
})}
</CommandGroup>
)
})
}, [tree.children, currentBase, handlePageHighlight, runCommand, router])
const colorGroupsSection = React.useMemo(() => {
return colors.map((colorPalette) => (
<CommandGroup
key={colorPalette.name}
heading={
colorPalette.name.charAt(0).toUpperCase() + colorPalette.name.slice(1)
}
className="p-0! **:[[cmdk-group-heading]]:p-3!"
>
{colorPalette.colors.map((color) => (
<CommandMenuItem
key={color.hex}
value={color.className}
keywords={["color", color.name, color.className]}
onHighlight={() => handleColorHighlight(color)}
onSelect={() => {
runCommand(() =>
copyToClipboardWithMeta(color.oklch, {
name: "copy_color",
properties: { color: color.oklch },
})
)
}}
>
<div
className="border-ghost aspect-square size-4 rounded-sm bg-(--color) after:rounded-sm"
style={{ "--color": color.oklch } as React.CSSProperties}
/>
{color.className}
<span className="ml-auto font-mono text-xs font-normal text-muted-foreground tabular-nums">
{color.oklch}
</span>
</CommandMenuItem>
))}
</CommandGroup>
))
}, [colors, handleColorHighlight, runCommand])
const blocksSection = React.useMemo(() => {
if (!blocks || blocks.length === 0) {
return null
}
return (
<CommandGroup
heading="Blocks"
className="p-0! **:[[cmdk-group-heading]]:p-3!"
>
{blocks.map((block) => (
<CommandMenuItem
key={block.name}
value={block.name}
onHighlight={() => {
handleBlockHighlight(block)
}}
keywords={[
"block",
block.name,
block.description,
...block.categories,
]}
onSelect={() => {
runCommand(() =>
router.push(`/blocks/${block.categories[0]}#${block.name}`)
)
}}
>
<SquareDashedIcon />
{block.description}
<span className="ml-auto font-mono text-xs font-normal text-muted-foreground tabular-nums">
{block.name}
</span>
</CommandMenuItem>
))}
</CommandGroup>
)
}, [blocks, handleBlockHighlight, runCommand, router])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
setOpen((open) => !open)
}
if (e.key === "c" && (e.metaKey || e.ctrlKey)) {
runCommand(() => {
if (selectedType === "color") {
copyToClipboardWithMeta(copyPayload, {
name: "copy_color",
properties: { color: copyPayload },
})
}
if (selectedType === "block") {
copyToClipboardWithMeta(copyPayload, {
name: "copy_npm_command",
properties: { command: copyPayload, pm: packageManager },
})
}
if (selectedType === "page" || selectedType === "component") {
copyToClipboardWithMeta(copyPayload, {
name: "copy_npm_command",
properties: { command: copyPayload, pm: packageManager },
})
}
})
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [copyPayload, runCommand, selectedType, packageManager])
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
className={cn(
"relative h-8 w-full justify-start rounded-lg border-none bg-muted pl-3 text-foreground shadow-none transition-colors hover:bg-muted/50 md:w-48 lg:w-40 xl:w-64 dark:bg-card"
)}
onClick={() => setOpen(true)}
{...props}
>
<span className="hidden xl:inline-flex">Search documentation...</span>
<span className="inline-flex xl:hidden">Search...</span>
</Button>
</DialogTrigger>
<DialogContent className="rounded-xl border-none bg-clip-padding p-2 pb-11 shadow-2xl ring-4 ring-neutral-200/80 dark:bg-neutral-900 dark:ring-neutral-800">
<DialogHeader className="sr-only">
<DialogTitle>Search documentation...</DialogTitle>
<DialogDescription>Search for a command to run...</DialogDescription>
</DialogHeader>
<Command
className="rounded-none bg-transparent **:data-[slot=command-input]:h-9! **:data-[slot=command-input]:py-0 **:data-[slot=command-input-wrapper]:mb-0 **:data-[slot=command-input-wrapper]:h-9! **:data-[slot=command-input-wrapper]:rounded-md **:data-[slot=command-input-wrapper]:border **:data-[slot=command-input-wrapper]:border-input **:data-[slot=command-input-wrapper]:bg-input/50"
filter={commandFilter}
>
<div className="relative">
<CommandInput
placeholder="Search documentation..."
onValueChange={handleSearchChange}
/>
{query.isLoading && (
<div className="pointer-events-none absolute top-1/2 right-3 z-10 flex -translate-y-1/2 items-center justify-center">
<Spinner className="size-4 text-muted-foreground" />
</div>
)}
</div>
<CommandList className="no-scrollbar min-h-80 scroll-pt-2 scroll-pb-1.5">
<CommandEmpty className="py-12 text-center text-sm text-muted-foreground">
{query.isLoading ? "Searching..." : "No results found."}
</CommandEmpty>
{navItemsSection}
{stylesSection}
{renderDelayedGroups ? (
<>
{pageGroupsSection}
{colorGroupsSection}
{blocksSection}
<SearchResults
setOpen={setOpen}
query={query}
search={search}
/>
</>
) : null}
</CommandList>
</Command>
<div className="absolute inset-x-0 bottom-0 z-20 flex h-10 items-center gap-2 rounded-b-xl border-t border-t-neutral-100 bg-neutral-50 px-4 text-xs font-medium text-muted-foreground dark:border-t-neutral-700 dark:bg-neutral-800">
<div className="flex items-center gap-2">
<CommandMenuKbd>
<CornerDownLeftIcon />
</CommandMenuKbd>{" "}
{selectedType === "page" || selectedType === "component"
? "Go to Page"
: null}
{selectedType === "color" ? "Copy OKLCH" : null}
{selectedType === "style" ? "Open in shadcn/create" : null}
</div>
{copyPayload && (
<>
<Separator orientation="vertical" className="h-4!" />
<div className="flex items-center gap-1">
<CommandMenuKbd>⌘</CommandMenuKbd>
<CommandMenuKbd>C</CommandMenuKbd>
{copyPayload}
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
)
}
function CommandMenuItem({
children,
className,
onHighlight,
...props
}: React.ComponentProps<typeof CommandItem> & {
onHighlight?: () => void
"data-selected"?: string
"aria-selected"?: string
}) {
const ref = React.useRef<HTMLDivElement>(null)
useMutationObserver(ref, (mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
mutation.attributeName === "aria-selected" &&
ref.current?.getAttribute("aria-selected") === "true"
) {
onHighlight?.()
}
})
})
return (
<CommandItem
ref={ref}
className={cn(
"h-9 rounded-md border border-transparent px-3! font-medium data-[selected=true]:border-input data-[selected=true]:bg-input/50",
className
)}
{...props}
>
{children}
</CommandItem>
)
}
function CommandMenuKbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
className={cn(
"pointer-events-none flex h-5 items-center justify-center gap-1 rounded border bg-background px-1 font-sans text-[0.7rem] font-medium text-muted-foreground select-none [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
type Query = Awaited<ReturnType<typeof useDocsSearch>>["query"]
function SearchResults({
setOpen,
query,
search,
}: {
setOpen: (open: boolean) => void
query: Query
search: string
}) {
const router = useRouter()
const uniqueResults = React.useMemo(() => {
if (!query.data || !Array.isArray(query.data)) {
return []
}
return query.data.filter(
(item, index, self) =>
!(
item.type === "text" && item.content.trim().split(/\s+/).length <= 1
) && index === self.findIndex((t) => t.content === item.content)
)
}, [query.data])
if (!search.trim()) {
return null
}
if (!query.data || query.data === "empty") {
return null
}
if (query.data && uniqueResults.length === 0) {
return null
}
return (
<CommandGroup
className="px-0! **:[[cmdk-group-heading]]:scroll-mt-16 **:[[cmdk-group-heading]]:p-3! **:[[cmdk-group-heading]]:pb-1!"
heading="Search Results"
>
{uniqueResults.map((item) => {
return (
<CommandItem
key={item.id}
data-type={item.type}
onSelect={() => {
router.push(item.url)
setOpen(false)
}}
className="h-9 rounded-md border border-transparent px-3! font-normal data-[selected=true]:border-input data-[selected=true]:bg-input/50"
keywords={[item.content]}
value={`${item.content} ${item.type}`}
>
<div className="line-clamp-1 text-sm">{item.content}</div>
</CommandItem>
)
})}
</CommandGroup>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
{/* <DialogOverlay /> */}
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[15%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
)
}

View File

@@ -0,0 +1,283 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { IconAlertCircle } from "@tabler/icons-react"
import { cn } from "cn"
import { I18nProvider } from "react-aria-components"
import {
LanguageProvider,
LanguageSelector,
useLanguageContext,
useTranslation,
type Translations,
} from "@/components/language-selector"
import { DirectionProvider as BaseDirectionProvider } from "@/registry/bases/base/ui/direction"
import { DirectionProvider as RadixDirectionProvider } from "@/registry/bases/radix/ui/direction"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/styles/base-nova/ui/popover"
export function ComponentPreviewTabs({
className,
previewClassName,
align = "center",
hideCode = false,
chromeLessOnMobile = false,
component,
source,
sourcePreview,
direction = "ltr",
styleName,
...props
}: React.ComponentProps<"div"> & {
previewClassName?: string
align?: "center" | "start" | "end"
hideCode?: boolean
chromeLessOnMobile?: boolean
component: React.ReactNode
source: React.ReactNode
sourcePreview?: React.ReactNode
direction?: "ltr" | "rtl"
styleName?: string
}) {
const [isMobileCodeVisible, setIsMobileCodeVisible] = React.useState(false)
const base = styleName?.match(/^(base|radix|aria)-/)?.[1] || "radix"
return (
<div
data-slot="component-preview"
data-not-typeset
className={cn(
"group relative mt-4 mb-12 flex flex-col overflow-hidden rounded-2xl border",
className
)}
{...props}
>
{direction === "rtl" ? (
<LanguageProvider defaultLanguage="ar">
<div className="flex h-16 items-center border-b px-4">
<RtlLanguageSelector />
<Popover>
<PopoverTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="ml-auto size-7"
>
<IconAlertCircle />
<span className="sr-only">Toggle</span>
</Button>
}
></PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
className="w-56 text-xs"
>
<div>
I used AI to translate the text for demonstration purposes.
It&apos;s not perfect and may contain errors.
</div>
<Separator className="-mx-2.5 w-auto!" />
<div data-lang="ar">
لقد استخدمت الذكاء الاصطناعي لترجمة النص للأغراض التجريبية
فقط. قد لا تكون الترجمة دقيقة وقد تحتوي على أخطاء.
</div>
<Separator className="-mx-2.5 w-auto!" />
<div data-lang="he">
השתמשתי בבינה מלאכותית כדי לתרגם את הטקסט למטרות הדגמה. זה לא
מושלם ויכול להכיל שגיאות.
</div>
</PopoverContent>
</Popover>
</div>
<PreviewWrapper
align={align}
chromeLessOnMobile={chromeLessOnMobile}
previewClassName={previewClassName}
>
<DirectionProviderWrapper base={base}>
{component}
</DirectionProviderWrapper>
</PreviewWrapper>
</LanguageProvider>
) : (
<DirectionProviderWrapper base={base} dir="ltr">
<PreviewWrapper
align={align}
chromeLessOnMobile={chromeLessOnMobile}
previewClassName={previewClassName}
dir="ltr"
>
{component}
</PreviewWrapper>
</DirectionProviderWrapper>
)}
{!hideCode && (
<div
data-slot="code"
data-mobile-code-visible={isMobileCodeVisible}
className="relative overflow-hidden **:data-[slot=copy-button]:right-4 **:data-[slot=copy-button]:hidden data-[mobile-code-visible=true]:**:data-[slot=copy-button]:flex [&_[data-rehype-pretty-code-figure]]:m-0! [&_[data-rehype-pretty-code-figure]]:rounded-t-none [&_[data-rehype-pretty-code-figure]]:border-t [&_pre]:max-h-72"
>
{isMobileCodeVisible ? (
<>
{direction === "rtl" && (
<div className="relative z-10 no-scrollbar overflow-x-auto border-t bg-code p-6 font-mono text-sm text-muted-foreground">
<pre>{`// You will notice this example uses dir and data-lang attributes.
// This is because this site is not RTL by default.
// In your application, you won't need these.`}</pre>
<span>
{"// See the "}
<Link
href="/docs/rtl"
className="underline underline-offset-4"
>
RTL guide
</Link>
{" for more information."}
</span>
</div>
)}
{source}
</>
) : (
<div className="relative">
{sourcePreview}
<div className="absolute inset-0 flex items-center justify-center pb-4">
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(to top, var(--color-code), color-mix(in oklab, var(--color-code) 60%, transparent), transparent)",
}}
/>
<Button
type="button"
size="sm"
variant="outline"
className="relative z-10 rounded-lg bg-background text-foreground shadow-none hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted"
onClick={() => {
setIsMobileCodeVisible(true)
}}
>
View Code
</Button>
</div>
</div>
)}
</div>
)}
</div>
)
}
const directionTranslations: Translations<Record<string, never>> = {
en: {
dir: "ltr",
values: {},
},
ar: {
dir: "rtl",
values: {},
},
he: {
dir: "rtl",
values: {},
},
}
function RtlLanguageSelector({ className }: { className?: string }) {
const context = useLanguageContext()
if (!context) {
return null
}
return (
<LanguageSelector
value={context.language}
onValueChange={context.setLanguage}
className={className}
/>
)
}
function PreviewWrapper({
align,
chromeLessOnMobile,
previewClassName,
dir: explicitDir,
children,
}: {
align: "center" | "start" | "end"
chromeLessOnMobile: boolean
previewClassName?: string
dir?: "ltr" | "rtl"
children: React.ReactNode
}) {
// useTranslation handles the case when there's no LanguageProvider context.
// It will fall back to local state with defaultLanguage.
const translation = useTranslation(directionTranslations, "ar")
const dir = explicitDir ?? translation.dir
return (
<div
data-slot="preview"
dir={dir}
data-lang={dir === "rtl" ? translation.language : undefined}
>
<div
data-align={align}
data-chromeless={chromeLessOnMobile}
className={cn(
"preview relative flex h-72 w-full justify-center p-10 data-[align=center]:items-center data-[align=end]:items-start data-[align=start]:items-start data-[chromeless=true]:h-auto data-[chromeless=true]:p-0 sm:data-[align=end]:items-end",
previewClassName
)}
>
{children}
</div>
</div>
)
}
function DirectionProviderWrapper({
base,
dir: explicitDir,
children,
}: {
base?: string
dir?: "ltr" | "rtl"
children: React.ReactNode
}) {
// useTranslation handles the case when there's no LanguageProvider context.
// It will fall back to local state with defaultLanguage.
const translation = useTranslation(directionTranslations, "ar")
const dir = explicitDir ?? translation.dir
if (base === "base") {
return (
<BaseDirectionProvider direction={dir}>{children}</BaseDirectionProvider>
)
}
if (base === "aria") {
return (
<I18nProvider
locale={
explicitDir === "ltr"
? "en"
: (translation.locale ?? translation.language)
}
>
{children}
</I18nProvider>
)
}
return <RadixDirectionProvider dir={dir}>{children}</RadixDirectionProvider>
}

View File

@@ -0,0 +1,130 @@
import * as React from "react"
import Image from "next/image"
import { getRegistryComponent } from "@/lib/registry"
import { ComponentPreviewTabs } from "@/components/component-preview-tabs"
import { ComponentSource } from "@/components/component-source"
export function ComponentPreview({
name,
type,
className,
previewClassName,
align = "center",
hideCode = false,
chromeLessOnMobile = false,
styleName = "base-nova",
direction = "ltr",
caption,
...props
}: React.ComponentProps<"div"> & {
name: string
styleName?: string
align?: "center" | "start" | "end"
description?: string
hideCode?: boolean
type?: "block" | "component" | "example"
chromeLessOnMobile?: boolean
previewClassName?: string
direction?: "ltr" | "rtl"
caption?: string
}) {
if (type === "block") {
const content = (
<div
data-not-typeset
className="relative mt-6 aspect-[4/2.5] w-full overflow-hidden rounded-2xl border md:-mx-1"
>
<Image
src={`/r/styles/new-york/${name}-light.png`}
alt={name}
width={1440}
height={900}
className="absolute top-0 left-0 z-20 h-full w-[1600px] max-w-none bg-background object-cover object-left-top md:hidden dark:hidden md:dark:hidden"
/>
<Image
src={`/r/styles/new-york/${name}-dark.png`}
alt={name}
width={1440}
height={900}
className="absolute top-0 left-0 z-20 hidden h-full w-[1600px] max-w-none bg-background object-cover object-left-top md:hidden dark:block md:dark:hidden"
/>
<div className="absolute inset-0 hidden w-[1600px] bg-background md:block">
<iframe src={`/view/${styleName}/${name}`} className="size-full" />
</div>
</div>
)
if (caption) {
return (
<figure className="flex flex-col gap-4">
{content}
<figcaption className="text-center text-sm text-muted-foreground">
{caption}
</figcaption>
</figure>
)
}
return content
}
const Component = getRegistryComponent(name, styleName)
if (!Component) {
return (
<p className="mt-6 text-sm text-muted-foreground">
Component{" "}
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">
{name}
</code>{" "}
not found in registry.
</p>
)
}
const content = (
<ComponentPreviewTabs
className={className}
previewClassName={previewClassName}
align={align}
hideCode={hideCode}
component={React.createElement(Component)}
source={
<ComponentSource
name={name}
collapsible={false}
styleName={styleName}
/>
}
sourcePreview={
<ComponentSource
name={name}
collapsible={false}
styleName={styleName}
maxLines={3}
/>
}
chromeLessOnMobile={chromeLessOnMobile}
direction={direction}
styleName={styleName}
{...props}
/>
)
if (caption) {
return (
<figure
data-hide-code={hideCode}
className="flex flex-col data-[hide-code=true]:gap-4"
>
{content}
<figcaption className="-mt-8 text-center text-sm text-muted-foreground data-[hide-code=true]:mt-0">
{caption}
</figcaption>
</figure>
)
}
return content
}

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "cn"
import { formatCode } from "@/lib/format-code"
import { highlightCode } from "@/lib/highlight-code"
import { readFileFromRoot } from "@/lib/read-file"
import { getDemoItem, getRegistryItem } from "@/lib/registry"
import { CodeCollapsibleWrapper } from "@/components/code-collapsible-wrapper"
import { CopyButton } from "@/components/copy-button"
import { getIconForLanguageExtension } from "@/components/icons"
export async function ComponentSource({
name,
src,
title,
language,
collapsible = true,
className,
styleName = "base-nova",
maxLines,
}: React.ComponentProps<"div"> & {
name?: string
src?: string
title?: string
language?: string
collapsible?: boolean
styleName?: string
maxLines?: number
}) {
if (!name && !src) {
return null
}
let code: string | undefined
if (name) {
const item =
(await getDemoItem(name, styleName)) ??
(await getRegistryItem(name, styleName))
code = item?.files?.[0]?.content
}
if (src) {
code = await readFileFromRoot(src)
}
if (!code) {
return null
}
code = await formatCode(code, styleName)
code = code.replaceAll("/* eslint-disable react/no-children-prop */\n", "")
// Truncate code if maxLines is set.
if (maxLines) {
code = code.split("\n").slice(0, maxLines).join("\n")
}
const lang = language ?? title?.split(".").pop() ?? "tsx"
const highlightedCode = await highlightCode(code, lang)
if (!collapsible) {
return (
<div className={cn("relative", className)}>
<ComponentCode
code={code}
highlightedCode={highlightedCode}
language={lang}
title={title}
/>
</div>
)
}
return (
<CodeCollapsibleWrapper className={className}>
<ComponentCode
code={code}
highlightedCode={highlightedCode}
language={lang}
title={title}
/>
</CodeCollapsibleWrapper>
)
}
function ComponentCode({
code,
highlightedCode,
language,
title,
}: {
code: string
highlightedCode: string
language: string
title: string | undefined
}) {
return (
<figure data-rehype-pretty-code-figure="" className="[&>pre]:max-h-96">
{title && (
<figcaption
data-rehype-pretty-code-title=""
className="flex items-center gap-2 text-code-foreground [&_svg]:size-4 [&_svg]:text-code-foreground [&_svg]:opacity-70"
data-language={language}
>
{getIconForLanguageExtension(language)}
{title}
</figcaption>
)}
<CopyButton value={code} />
<div
data-not-typeset
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</figure>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import * as React from "react"
import { cn } from "cn"
export function ComponentWrapper({
className,
name,
children,
...props
}: React.ComponentPropsWithoutRef<"div"> & { name: string }) {
return (
<ComponentErrorBoundary name={name}>
<div
id={name}
data-name={name.toLowerCase()}
className={cn(
"flex w-full scroll-mt-16 flex-col rounded-lg border",
className
)}
{...props}
>
<div className="border-b px-4 py-3">
<div className="text-sm font-medium">{getComponentName(name)}</div>
</div>
<div className="flex flex-1 items-center gap-2 p-4">{children}</div>
</div>
</ComponentErrorBoundary>
)
}
class ComponentErrorBoundary extends React.Component<
{ children: React.ReactNode; name: string },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; name: string }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(`Error in component ${this.props.name}:`, error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 text-red-500">
Something went wrong in component: {this.props.name}
</div>
)
}
return this.props.children
}
}
function getComponentName(name: string) {
// convert kebab-case to title case
return name.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase())
}

View File

@@ -0,0 +1,69 @@
import Link from "next/link"
import { PAGES_NEW } from "@/lib/docs"
import {
getPagesFromFolder,
type PageTreeFolder,
type PageTreePage,
} from "@/lib/page-tree"
function ComponentLink({
component,
showNewIndicator,
}: {
component: PageTreePage
showNewIndicator: boolean
}) {
const isNew = showNewIndicator && PAGES_NEW.includes(component.url)
return (
<Link
href={component.url}
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
>
{component.name}
{isNew && (
<>
<span className="sr-only">New</span>
<span
aria-hidden="true"
className="flex size-2 rounded-full bg-blue-500"
/>
</>
)}
</Link>
)
}
export function ComponentsList({
componentsFolder,
currentBase,
variant = "all",
}: {
componentsFolder: PageTreeFolder
currentBase: string
variant?: "all" | "new"
}) {
const list = getPagesFromFolder(componentsFolder, currentBase).filter(
(component) => variant === "all" || PAGES_NEW.includes(component.url)
)
if (!list.length) {
return null
}
return (
<div
data-not-typeset
className="mt-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-x-8 lg:gap-x-16 lg:gap-y-6 xl:gap-x-20"
>
{list.map((component) => (
<ComponentLink
key={component.$id}
component={component}
showNewIndicator={variant === "all"}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { cn } from "cn"
import { trackEvent, type Event } from "@/lib/events"
import { Button } from "@/registry/new-york-v4/ui/button"
function legacyCopyToClipboard(value: string) {
const textArea = document.createElement("textarea")
textArea.value = value
textArea.setAttribute("readonly", "")
textArea.style.position = "fixed"
textArea.style.opacity = "0"
textArea.style.pointerEvents = "none"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
textArea.setSelectionRange(0, value.length)
let hasCopied = false
try {
hasCopied = document.execCommand("copy")
} catch {
hasCopied = false
}
document.body.removeChild(textArea)
return hasCopied
}
export async function copyToClipboardWithMeta(value: string, event?: Event) {
if (typeof window === "undefined") {
return false
}
if (!value) {
return false
}
let hasCopied = false
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value)
hasCopied = true
} catch {
hasCopied = legacyCopyToClipboard(value)
}
} else {
hasCopied = legacyCopyToClipboard(value)
}
if (!hasCopied) {
return false
}
if (event) {
trackEvent(event)
}
return true
}
export function CopyButton({
value,
className,
variant = "ghost",
event,
...props
}: React.ComponentProps<typeof Button> & {
value: string
src?: string
event?: Event["name"]
tooltip?: string
}) {
const [hasCopied, setHasCopied] = React.useState(false)
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
return (
<Button
data-slot="copy-button"
data-copied={hasCopied}
size="icon"
variant={variant}
className={cn(
"absolute top-3 right-2 z-10 size-7 bg-code hover:opacity-100 focus-visible:opacity-100",
className
)}
onClick={async () => {
const hasCopied = await copyToClipboardWithMeta(
value,
event
? {
name: event,
properties: {
code: value,
},
}
: undefined
)
if (hasCopied) {
setHasCopied(true)
}
}}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? <IconCheck /> : <IconCopy />}
</Button>
)
}

View File

@@ -0,0 +1,39 @@
"use client"
import { Suspense } from "react"
import { usePathname } from "next/navigation"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import { ProjectForm } from "@/app/(app)/(create)/components/project-form"
import { V0Button } from "@/app/(app)/(create)/components/v0-button"
export function DesignerActions() {
const pathname = usePathname()
// Typeset carries its own docs panel; the header stays clean. A route gate
// on purpose: two designer pages don't justify a slot protocol. Revisit if
// a third appears.
if (pathname === "/typeset") {
return null
}
return (
<>
<div className="hidden items-center gap-2 group-has-data-[slot=designer]/layout:md:flex">
<Separator orientation="vertical" />
<Suspense fallback={null}>
<V0Button />
</Suspense>
<Suspense fallback={null}>
<ProjectForm />
</Suspense>
</div>
<div className="hidden items-center gap-2 group-has-data-[slot=designer]/layout:flex group-has-data-[slot=designer]/layout:md:hidden">
<Separator orientation="vertical" />
<Suspense fallback={null}>
<V0Button />
</Suspense>
</div>
</>
)
}

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import { IconCheck, IconCopy, IconPlus } from "@tabler/icons-react"
import { useConfig } from "@/hooks/use-config"
import { useIsMobile } from "@/hooks/use-mobile"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/styles/base-nova/ui/drawer"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/styles/base-nova/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui/tooltip"
const DirectoryAddContext = React.createContext<{
open: (registry: { name: string }) => void
}>({
open: () => {},
})
export function useDirectoryAdd() {
return React.useContext(DirectoryAddContext)
}
export function DirectoryAddButton({
registry,
}: {
registry: { name: string }
}) {
const { open } = useDirectoryAdd()
return (
<Button
size="sm"
variant="outline"
className="relative z-10"
onClick={() => open(registry)}
>
Add <IconPlus />
</Button>
)
}
export function DirectoryAddProvider({
children,
}: {
children: React.ReactNode
}) {
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const [selectedRegistry, setSelectedRegistry] = React.useState<{
name: string
} | null>(null)
const isMobile = useIsMobile()
const packageManager = config.packageManager || "pnpm"
const commands = React.useMemo(() => {
if (!selectedRegistry) return null
return {
pnpm: `pnpm dlx shadcn@latest registry add ${selectedRegistry.name}`,
npm: `npx shadcn@latest registry add ${selectedRegistry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${selectedRegistry.name}`,
bun: `bunx --bun shadcn@latest registry add ${selectedRegistry.name}`,
}
}, [selectedRegistry])
const command = commands?.[packageManager] ?? ""
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 2000)
return () => clearTimeout(timer)
}
}, [hasCopied])
const handleCopy = React.useCallback(() => {
copyToClipboardWithMeta(command, {
name: "copy_registry_add_command",
properties: {
command,
registry: selectedRegistry?.name ?? "",
},
})
setHasCopied(true)
}, [command, selectedRegistry?.name])
const contextValue = React.useMemo(
() => ({
open: (registry: { name: string }) => {
setSelectedRegistry(registry)
setIsOpen(true)
},
}),
[]
)
const Content = commands ? (
<Tabs
value={packageManager}
onValueChange={(value) => {
setConfig({
...config,
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
})
}}
className="gap-0 overflow-hidden rounded-xl border"
>
<div className="flex items-center gap-2 border-b p-1.5">
<TabsList className="h-auto *:data-[slot=tabs-trigger]:pt-0">
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
<TabsTrigger value="npm">npm</TabsTrigger>
<TabsTrigger value="yarn">yarn</TabsTrigger>
<TabsTrigger value="bun">bun</TabsTrigger>
</TabsList>
<Tooltip>
<TooltipTrigger
render={
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
/>
}
>
{hasCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy command</span>
</TooltipTrigger>
<TooltipContent>
{hasCopied ? "Copied!" : "Copy command"}
</TooltipContent>
</Tooltip>
</div>
{Object.entries(commands).map(([key, cmd]) => (
<TabsContent key={key} value={key} className="mt-0">
<div className="bg-surface px-3 py-3 text-surface-foreground">
<div className="no-scrollbar overflow-x-auto">
<code className="font-mono text-sm whitespace-nowrap">{cmd}</code>
</div>
</div>
</TabsContent>
))}
</Tabs>
) : null
return (
<DirectoryAddContext value={contextValue}>
{children}
{isMobile ? (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose render={<Button size="sm" />}>Done</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
) : (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="animate-none! sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose render={<Button size="sm" variant="outline" />}>
Done
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DirectoryAddContext>
)
}

View File

@@ -0,0 +1,359 @@
"use client"
import * as React from "react"
import { usePathname } from "next/navigation"
import {
IconArrowUpRight,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { cn } from "cn"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import {
DirectoryAddButton,
DirectoryAddProvider,
} from "@/components/directory-add-button"
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/styles/base-nova/ui/item"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/styles/base-nova/ui/pagination"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
import { SearchDirectory } from "./directory-search"
function getHomepageUrl(homepage: string) {
const url = new URL(homepage)
url.searchParams.set("utm_source", "ui.shadcn.com")
url.searchParams.set("utm_medium", "referral")
url.searchParams.set("utm_campaign", "directory")
return url.toString()
}
function getPageHref(pathname: string, query: string, page: number) {
const searchParams = new URLSearchParams()
if (query) {
searchParams.set("q", query)
}
if (page > 1) {
searchParams.set("page", page.toString())
}
const search = searchParams.toString()
return search ? `${pathname}?${search}` : pathname
}
function getPageNumbers(current: number, total: number) {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1) as (
| number
| "ellipsis"
)[]
}
const pages: (number | "ellipsis")[] = [1]
// Show ellipsis or page 2 directly if only one number would be hidden.
if (current > 4) {
pages.push("ellipsis")
} else if (current >= 4) {
pages.push(2)
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
// Show ellipsis or second-to-last page directly if only one number would be hidden.
if (current < total - 3) {
pages.push("ellipsis")
} else if (current <= total - 3) {
pages.push(total - 1)
}
pages.push(total)
return pages
}
type DirectoryPaginationLinkProps = React.ComponentProps<"a"> & {
isActive?: boolean
size?: React.ComponentProps<typeof Button>["size"]
}
function DirectoryPaginationLink({
className,
isActive,
size = "icon",
...props
}: DirectoryPaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function DirectoryPaginationPrevious({
className,
text = "Previous",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<IconChevronLeft className="cn-rtl-flip size-4" />
<span className="hidden sm:block">{text}</span>
</DirectoryPaginationLink>
)
}
function DirectoryPaginationNext({
className,
text = "Next",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<IconChevronRight className="cn-rtl-flip size-4" />
</DirectoryPaginationLink>
)
}
export function DirectoryList() {
return (
<DirectoryAddProvider>
<div data-not-typeset className="mt-6">
<React.Suspense fallback={<DirectoryListSkeleton />}>
<DirectoryListContent />
</React.Suspense>
</div>
</DirectoryAddProvider>
)
}
function DirectoryListContent() {
const pathname = usePathname()
const {
isLoading,
paginatedRegistries,
page,
query,
registries,
totalPages,
setPage,
setQuery,
} = useSearchRegistry()
const previousHref =
page > 1 ? getPageHref(pathname, query, page - 1) : undefined
const nextHref =
page < totalPages ? getPageHref(pathname, query, page + 1) : undefined
const handlePageChange = React.useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
targetPage: number,
disabled = false
) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
if (disabled || targetPage === page) {
event.preventDefault()
return
}
event.preventDefault()
void setPage(targetPage)
},
[page, setPage]
)
if (isLoading) {
return <DirectoryListSkeleton />
}
return (
<>
<SearchDirectory
query={query}
registriesCount={registries.length}
setQuery={setQuery}
/>
<ItemGroup className="my-8">
{paginatedRegistries.map((registry, index) => (
<React.Fragment key={registry.name}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<ItemContent>
<ItemTitle>
<a
href={getHomepageUrl(registry.homepage)}
target="_blank"
rel="noopener noreferrer external"
className="group flex items-center gap-1"
>
{registry.name}{" "}
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
</a>
</ItemTitle>
{registry.description && (
<ItemDescription className="text-pretty">
{registry.description}
</ItemDescription>
)}
</ItemContent>
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
</ItemActions>
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
</ItemFooter>
</Item>
{index < paginatedRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
</React.Fragment>
))}
</ItemGroup>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<DirectoryPaginationPrevious
href={previousHref}
aria-disabled={page <= 1 || undefined}
tabIndex={page <= 1 ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page - 1, page <= 1)
}
className={cn(
page <= 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
{getPageNumbers(page, totalPages).map((p, i) =>
p === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<DirectoryPaginationLink
href={getPageHref(pathname, query, p)}
isActive={p === page}
onClick={(event) => handlePageChange(event, p)}
className="cursor-pointer"
>
{p}
</DirectoryPaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<DirectoryPaginationNext
href={nextHref}
aria-disabled={page >= totalPages || undefined}
tabIndex={page >= totalPages ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page + 1, page >= totalPages)
}
className={cn(
page >= totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)
}
function DirectoryListSkeleton() {
return (
<>
<Skeleton className="h-8 w-full rounded-lg" />
<ItemGroup className="my-8">
{Array.from({ length: 10 }, (_, index) => (
<React.Fragment key={index}>
<Item className="relative items-start gap-6 px-0">
<Skeleton className="size-8 rounded-lg" />
<ItemContent>
<Skeleton className="h-4 w-32 sm:w-40" />
<Skeleton className="mt-1.5 h-4 w-full max-w-md" />
<Skeleton className="mt-1 h-4 w-3/4 max-w-sm" />
</ItemContent>
<ItemActions className="hidden self-start sm:flex">
<Skeleton className="h-7 w-16 rounded-lg" />
</ItemActions>
<ItemFooter className="justify-start gap-2 pl-16 sm:hidden">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
</ItemFooter>
</Item>
{index < 9 && <ItemSeparator className="my-1" />}
</React.Fragment>
))}
</ItemGroup>
</>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import { Search, X } from "lucide-react"
import { Field } from "@/styles/base-nova/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/base-nova/ui/input-group"
export function SearchDirectory({
query,
registriesCount,
setQuery,
}: {
query: string
registriesCount: number
setQuery: (value: string | null) => void
}) {
return (
<Field>
<InputGroup>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
className="h-full"
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<InputGroupAddon align="inline-end">
<span className="text-muted-foreground tabular-nums sm:text-xs">
{registriesCount}{" "}
{registriesCount === 1 ? "registry" : "registries"}
</span>
</InputGroupAddon>
<InputGroupAddon
align="inline-end"
data-disabled={!query.length}
className="data-[disabled=true]:hidden"
>
<InputGroupButton
aria-label="Clear"
title="Clear"
size="icon-xs"
onClick={() => setQuery(null)}
>
<X />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
)
}

View File

@@ -0,0 +1,49 @@
import Link from "next/link"
import { cn } from "cn"
import { source } from "@/lib/source"
import { BASES } from "@/registry/bases"
export function DocsBaseSwitcher({
base,
component,
hrefPrefix = "/docs/components",
className,
}: {
base: string
component: string
hrefPrefix?: string
className?: string
}) {
const activeBase = BASES.find((baseItem) => base === baseItem.name)
return (
<div
className={cn(
"not-typeset inline-flex w-full items-center gap-6",
className
)}
>
{BASES.filter((baseItem) =>
source.getPage([`components/${baseItem.name}/${component}`])
).map((baseItem) => (
<Link
key={baseItem.name}
href={`${hrefPrefix}/${baseItem.name}/${component}`}
data-active={base === baseItem.name}
className="relative inline-flex items-center justify-center gap-1 pt-1 pb-0.5 text-base font-medium text-muted-foreground transition-colors after:absolute after:inset-x-0 after:bottom-[-4px] after:h-0.5 after:bg-foreground after:opacity-0 after:transition-opacity hover:text-foreground data-[active=true]:text-foreground data-[active=true]:after:opacity-100"
>
{baseItem.title}
</Link>
))}
{activeBase?.meta?.logo && (
<div
className="ml-auto size-4 shrink-0 text-muted-foreground opacity-80 [&_svg]:size-4"
dangerouslySetInnerHTML={{
__html: activeBase.meta.logo,
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import { Fragment } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useBreadcrumb } from "fumadocs-core/breadcrumb"
import type { Root } from "fumadocs-core/page-tree"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/registry/new-york-v4/ui/breadcrumb"
export function DocsBreadcrumb({
tree,
className,
}: {
tree: Root
className?: string
}) {
const pathname = usePathname()
const items = useBreadcrumb(pathname, tree)
if (items.length === 0) return null
return (
<Breadcrumb className={className}>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/docs" className="hover:text-accent-foreground">
Docs
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
{items.map((item, i) => (
<Fragment key={i}>
{i !== 0 && <BreadcrumbSeparator />}
{item.url ? (
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href={item.url}
className="truncate hover:text-accent-foreground"
>
{item.name}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
) : (
<BreadcrumbItem>
<BreadcrumbPage>{item.name}</BreadcrumbPage>
</BreadcrumbItem>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)
}

View File

@@ -0,0 +1,223 @@
"use client"
import { IconCheck, IconChevronDown, IconCopy } from "@tabler/icons-react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/registry/new-york-v4/ui/dropdown-menu"
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "@/registry/new-york-v4/ui/popover"
import { Separator } from "@/registry/new-york-v4/ui/separator"
function getPromptUrl(baseURL: string, url: string) {
return `${baseURL}?q=${encodeURIComponent(
`I’m looking at this shadcn/ui documentation: ${url}.
Help me understand how to use it. Be ready to explain concepts, give examples, or help debug based on it.
`
)}`
}
const menuItems = {
markdown: (url: string) => (
<a href={`${url}.md`} target="_blank" rel="noopener noreferrer">
<svg strokeLinejoin="round" viewBox="0 0 22 16">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.5 2.25H2.5C1.80964 2.25 1.25 2.80964 1.25 3.5V12.5C1.25 13.1904 1.80964 13.75 2.5 13.75H19.5C20.1904 13.75 20.75 13.1904 20.75 12.5V3.5C20.75 2.80964 20.1904 2.25 19.5 2.25ZM2.5 1C1.11929 1 0 2.11929 0 3.5V12.5C0 13.8807 1.11929 15 2.5 15H19.5C20.8807 15 22 13.8807 22 12.5V3.5C22 2.11929 20.8807 1 19.5 1H2.5ZM3 4.5H4H4.25H4.6899L4.98715 4.82428L7 7.02011L9.01285 4.82428L9.3101 4.5H9.75H10H11V5.5V11.5H9V7.79807L7.73715 9.17572L7 9.97989L6.26285 9.17572L5 7.79807V11.5H3V5.5V4.5ZM15 8V4.5H17V8H19.5L17 10.5L16 11.5L15 10.5L12.5 8H15Z"
fill="currentColor"
/>
</svg>
View as Markdown
</a>
),
v0: (url: string) => (
<a
href={getPromptUrl("https://v0.dev", url)}
target="_blank"
rel="noopener noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 147 70"
className="size-4.5 -translate-x-px"
>
<path d="M56 50.203V14h14v46.156C70 65.593 65.593 70 60.156 70c-2.596 0-5.158-1-7-2.843L0 14h19.797L56 50.203ZM147 56h-14V23.953L100.953 56H133v14H96.687C85.814 70 77 61.186 77 50.312V14h14v32.156L123.156 14H91V0h36.312C138.186 0 147 8.814 147 19.688V56Z" />
</svg>
<span className="-translate-x-[2px]">Open in v0</span>
</a>
),
chatgpt: (url: string) => (
<a
href={getPromptUrl("https://chatgpt.com", url)}
target="_blank"
rel="noopener noreferrer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5Z"
fill="currentColor"
/>
</svg>
Open in ChatGPT
</a>
),
claude: (url: string) => (
<a
href={getPromptUrl("https://claude.ai/new", url)}
target="_blank"
rel="noopener noreferrer"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="m4.714 15.956 4.718-2.648.079-.23-.08-.128h-.23l-.79-.048-2.695-.073-2.337-.097-2.265-.122-.57-.121-.535-.704.055-.353.48-.321.685.06 1.518.104 2.277.157 1.651.098 2.447.255h.389l.054-.158-.133-.097-.103-.098-2.356-1.596-2.55-1.688-1.336-.972-.722-.491L2 6.223l-.158-1.008.655-.722.88.06.225.061.893.686 1.906 1.476 2.49 1.833.364.304.146-.104.018-.072-.164-.274-1.354-2.446-1.445-2.49-.644-1.032-.17-.619a2.972 2.972 0 0 1-.103-.729L6.287.133 6.7 0l.995.134.42.364.619 1.415L9.735 4.14l1.555 3.03.455.898.243.832.09.255h.159V9.01l.127-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.583.28.48.685-.067.444-.286 1.851-.558 2.903-.365 1.942h.213l.243-.242.983-1.306 1.652-2.064.728-.82.85-.904.547-.431h1.032l.759 1.129-.34 1.166-1.063 1.347-.88 1.142-1.263 1.7-.79 1.36.074.11.188-.02 2.853-.606 1.542-.28 1.84-.315.832.388.09.395-.327.807-1.967.486-2.307.462-3.436.813-.043.03.049.061 1.548.146.662.036h1.62l3.018.225.79.522.473.638-.08.485-1.213.62-1.64-.389-3.825-.91-1.31-.329h-.183v.11l1.093 1.068 2.003 1.81 2.508 2.33.127.578-.321.455-.34-.049-2.204-1.657-.85-.747-1.925-1.62h-.127v.17l.443.649 2.343 3.521.122 1.08-.17.353-.607.213-.668-.122-1.372-1.924-1.415-2.168-1.141-1.943-.14.08-.674 7.254-.316.37-.728.28-.607-.461-.322-.747.322-1.476.388-1.924.316-1.53.285-1.9.17-.632-.012-.042-.14.018-1.432 1.967-2.18 2.945-1.724 1.845-.413.164-.716-.37.066-.662.401-.589 2.386-3.036 1.439-1.882.929-1.086-.006-.158h-.055L4.138 18.56l-1.13.146-.485-.456.06-.746.231-.243 1.907-1.312Z"
fill="currentColor"
/>
</svg>
Open in Claude
</a>
),
scira: (url: string) => (
<a
href={getPromptUrl("https://scira.ai/", url)}
target="_blank"
className="m-0 p-0"
>
<svg
width="910"
height="934"
viewBox="0 0 910 934"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeWidth="30"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Open in Scira
</a>
),
}
export function DocsCopyPage({ page, url }: { page: string; url: string }) {
const { copyToClipboard, isCopied } = useCopyToClipboard()
const trigger = (
<Button
variant="secondary"
size="sm"
className="peer -ml-0.5 size-8 shadow-none md:size-7 md:text-[0.8rem]"
>
<IconChevronDown className="rotate-180 sm:rotate-0" />
</Button>
)
return (
<Popover>
<div className="group/buttons relative flex rounded-lg bg-secondary *:[[data-slot=button]]:focus-visible:relative *:[[data-slot=button]]:focus-visible:z-10">
<PopoverAnchor />
<Button
variant="secondary"
size="sm"
className="h-8 shadow-none md:h-7 md:text-[0.8rem]"
onClick={() => copyToClipboard(page)}
>
{isCopied ? <IconCheck /> : <IconCopy />}
Copy Page
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild className="hidden sm:flex">
{trigger}
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="animate-none! rounded-lg shadow-none"
>
{Object.entries(menuItems).map(([key, value]) => (
<DropdownMenuItem key={key} asChild>
{value(url)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator
orientation="vertical"
className="absolute top-1 right-8 z-0 h-6! bg-foreground/5! peer-focus-visible:opacity-0 sm:right-7 sm:h-5!"
/>
<PopoverTrigger asChild className="flex sm:hidden">
{trigger}
</PopoverTrigger>
<PopoverContent
className="w-52 origin-center! rounded-lg bg-background/70 p-1 shadow-none backdrop-blur-sm dark:bg-background/60"
align="start"
>
{Object.entries(menuItems).map(([key, value]) => (
<Button
variant="ghost"
size="lg"
asChild
key={key}
className="w-full justify-start text-base font-normal *:[svg]:text-muted-foreground"
>
{value(url)}
</Button>
))}
</PopoverContent>
</div>
</Popover>
)
}

View File

@@ -0,0 +1,179 @@
"use client"
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
function getPromptUrl(baseURL: string, url: string) {
return `${baseURL}?q=${encodeURIComponent(
`I'm looking at this shadcn/ui documentation: ${url}.
Help me understand how to use it. Be ready to explain concepts, give examples, or help debug based on it.
`
)}`
}
export function DocsPageLinks({ page, url }: { page: string; url: string }) {
const { copyToClipboard, isCopied } = useCopyToClipboard()
return (
<div className="flex flex-col gap-3 px-6">
<ul className="flex flex-col gap-2 text-[0.8rem] text-muted-foreground">
<li>
<button
onClick={() => copyToClipboard(page)}
className="inline-flex items-center gap-2 transition-colors hover:text-foreground"
>
{isCopied ? (
<IconCheck className="size-4" />
) : (
<IconCopy className="size-4" />
)}
Copy page
</button>
</li>
<li>
<a
href={`${url}.md`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 transition-colors hover:text-foreground"
>
<svg strokeLinejoin="round" viewBox="0 0 22 16" className="size-4">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.5 2.25H2.5C1.80964 2.25 1.25 2.80964 1.25 3.5V12.5C1.25 13.1904 1.80964 13.75 2.5 13.75H19.5C20.1904 13.75 20.75 13.1904 20.75 12.5V3.5C20.75 2.80964 20.1904 2.25 19.5 2.25ZM2.5 1C1.11929 1 0 2.11929 0 3.5V12.5C0 13.8807 1.11929 15 2.5 15H19.5C20.8807 15 22 13.8807 22 12.5V3.5C22 2.11929 20.8807 1 19.5 1H2.5ZM3 4.5H4H4.25H4.6899L4.98715 4.82428L7 7.02011L9.01285 4.82428L9.3101 4.5H9.75H10H11V5.5V11.5H9V7.79807L7.73715 9.17572L7 9.97989L6.26285 9.17572L5 7.79807V11.5H3V5.5V4.5ZM15 8V4.5H17V8H19.5L17 10.5L16 11.5L15 10.5L12.5 8H15Z"
fill="currentColor"
/>
</svg>
View as Markdown
</a>
</li>
<li>
<a
href={getPromptUrl("https://v0.dev", url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 transition-colors hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 147 70"
className="size-4"
>
<path d="M56 50.203V14h14v46.156C70 65.593 65.593 70 60.156 70c-2.596 0-5.158-1-7-2.843L0 14h19.797L56 50.203ZM147 56h-14V23.953L100.953 56H133v14H96.687C85.814 70 77 61.186 77 50.312V14h14v32.156L123.156 14H91V0h36.312C138.186 0 147 8.814 147 19.688V56Z" />
</svg>
Open in v0
</a>
</li>
<li>
<a
href={getPromptUrl("https://chatgpt.com", url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 transition-colors hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="size-4"
>
<path
d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5Z"
fill="currentColor"
/>
</svg>
Open in ChatGPT
</a>
</li>
<li>
<a
href={getPromptUrl("https://claude.ai/new", url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 transition-colors hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="size-4"
>
<path
d="m4.714 15.956 4.718-2.648.079-.23-.08-.128h-.23l-.79-.048-2.695-.073-2.337-.097-2.265-.122-.57-.121-.535-.704.055-.353.48-.321.685.06 1.518.104 2.277.157 1.651.098 2.447.255h.389l.054-.158-.133-.097-.103-.098-2.356-1.596-2.55-1.688-1.336-.972-.722-.491L2 6.223l-.158-1.008.655-.722.88.06.225.061.893.686 1.906 1.476 2.49 1.833.364.304.146-.104.018-.072-.164-.274-1.354-2.446-1.445-2.49-.644-1.032-.17-.619a2.972 2.972 0 0 1-.103-.729L6.287.133 6.7 0l.995.134.42.364.619 1.415L9.735 4.14l1.555 3.03.455.898.243.832.09.255h.159V9.01l.127-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.583.28.48.685-.067.444-.286 1.851-.558 2.903-.365 1.942h.213l.243-.242.983-1.306 1.652-2.064.728-.82.85-.904.547-.431h1.032l.759 1.129-.34 1.166-1.063 1.347-.88 1.142-1.263 1.7-.79 1.36.074.11.188-.02 2.853-.606 1.542-.28 1.84-.315.832.388.09.395-.327.807-1.967.486-2.307.462-3.436.813-.043.03.049.061 1.548.146.662.036h1.62l3.018.225.79.522.473.638-.08.485-1.213.62-1.64-.389-3.825-.91-1.31-.329h-.183v.11l1.093 1.068 2.003 1.81 2.508 2.33.127.578-.321.455-.34-.049-2.204-1.657-.85-.747-1.925-1.62h-.127v.17l.443.649 2.343 3.521.122 1.08-.17.353-.607.213-.668-.122-1.372-1.924-1.415-2.168-1.141-1.943-.14.08-.674 7.254-.316.37-.728.28-.607-.461-.322-.747.322-1.476.388-1.924.316-1.53.285-1.9.17-.632-.012-.042-.14.018-1.432 1.967-2.18 2.945-1.724 1.845-.413.164-.716-.37.066-.662.401-.589 2.386-3.036 1.439-1.882.929-1.086-.006-.158h-.055L4.138 18.56l-1.13.146-.485-.456.06-.746.231-.243 1.907-1.312Z"
fill="currentColor"
/>
</svg>
Open in Claude
</a>
</li>
<li>
<a
href={getPromptUrl("https://scira.ai/", url)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 transition-colors hover:text-foreground"
>
<svg
width="910"
height="934"
viewBox="0 0 910 934"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-4"
>
<path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
stroke="currentColor"
strokeWidth="20"
strokeLinejoin="round"
/>
<path
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="8"
strokeLinejoin="round"
/>
<path
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
stroke="currentColor"
strokeWidth="30"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Open in Scira
</a>
</li>
</ul>
</div>
)
}

View File

@@ -0,0 +1,273 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { PAGES_NEW } from "@/lib/docs"
import { DOCS_SIDEBAR_SCROLL_STORAGE_KEY } from "@/lib/docs-sidebar-scroll"
import { showMcpDocs } from "@/lib/flags"
import { getCurrentBase, getPagesFromFolder } from "@/lib/page-tree"
import type { source } from "@/lib/source"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/registry/new-york-v4/ui/sidebar"
const TOP_LEVEL_SECTIONS = [
{ name: "Introduction", href: "/docs" },
{
name: "Components",
href: "/docs/components",
},
{
name: "Installation",
href: "/docs/installation",
},
{
name: "Theming",
href: "/docs/theming",
},
{
name: "CLI",
href: "/docs/cli",
},
{
name: "Typeset",
href: "/docs/typeset",
},
{
name: "Skills",
href: "/docs/skills",
},
{
name: "Registry",
href: "/docs/registry",
},
{
name: "Changelog",
href: "/docs/changelog",
},
]
const EXCLUDED_SECTIONS = ["installation", "dark-mode", "changelog", "rtl"]
const EXCLUDED_PAGES = ["/docs", "/docs/changelog", "/docs/rtl", "/docs/new"]
function readScrollState() {
try {
return JSON.parse(
sessionStorage.getItem(DOCS_SIDEBAR_SCROLL_STORAGE_KEY) ?? ""
) as {
pathname: string
scrollTop: number
}
} catch {
return null
}
}
function saveScrollState(container: HTMLElement) {
try {
sessionStorage.setItem(
DOCS_SIDEBAR_SCROLL_STORAGE_KEY,
JSON.stringify({
pathname: location.pathname,
scrollTop: container.scrollTop,
})
)
} catch {}
}
function getActiveItem(container: HTMLElement) {
const items = container.querySelectorAll<HTMLElement>('[data-active="true"]')
let active: HTMLElement | null = null
let activePathLength = -1
let activeDistance = Infinity
const containerRect = container.getBoundingClientRect()
const containerCenter = containerRect.top + container.clientHeight / 2
for (const item of items) {
const link = item.querySelector<HTMLAnchorElement>("a[href]")
const href = item.getAttribute("href") ?? link?.getAttribute("href")
const pathLength = href?.length ?? 0
const itemRect = item.getBoundingClientRect()
const distance = Math.abs(
itemRect.top + itemRect.height / 2 - containerCenter
)
if (
pathLength > activePathLength ||
(pathLength === activePathLength && distance < activeDistance)
) {
active = item
activePathLength = pathLength
activeDistance = distance
}
}
return active
}
export function DocsSidebar({
tree,
...props
}: React.ComponentProps<typeof Sidebar> & { tree: typeof source.pageTree }) {
const pathname = usePathname()
const currentBase = getCurrentBase(pathname)
const contentRef = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
const container = contentRef.current
if (!container) {
return
}
const scrollState = readScrollState()
if (scrollState?.pathname === pathname) {
container.scrollTop = scrollState.scrollTop
} else {
// Prefer the longest route because section links also match by prefix.
// Equal routes keep the item closest to the current viewport.
const active = getActiveItem(container)
if (active) {
const containerRect = container.getBoundingClientRect()
const activeRect = active.getBoundingClientRect()
if (
activeRect.top < containerRect.top ||
activeRect.bottom > containerRect.bottom
) {
container.scrollTop +=
activeRect.top -
containerRect.top -
(container.clientHeight - activeRect.height) / 2
}
}
}
saveScrollState(container)
}, [pathname])
React.useEffect(() => {
const container = contentRef.current
if (!container) {
return
}
const onScroll = () => saveScrollState(container)
container.addEventListener("scroll", onScroll, { passive: true })
return () => container.removeEventListener("scroll", onScroll)
}, [])
return (
<Sidebar
className="sticky top-[calc(var(--header-height)+0.6rem)] z-30 hidden h-[calc(100svh-10rem)] overflow-hidden overscroll-none bg-transparent [--sidebar-menu-width:--spacing(56)] lg:flex"
collapsible="none"
{...props}
>
<div className="absolute top-12 right-2 bottom-0 hidden h-full w-px bg-[linear-gradient(to_bottom,transparent_0%,var(--border)_10%,var(--border)_90%,transparent_100%)] lg:flex" />
<SidebarContent
ref={contentRef}
data-docs-sidebar-content=""
className="w-(--sidebar-menu-width) scroll-fade scrollbar-none overflow-x-hidden pl-2.5"
>
<SidebarGroup className="pt-12">
<SidebarGroupLabel className="font-medium text-muted-foreground">
Sections
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{TOP_LEVEL_SECTIONS.map(({ name, href }) => {
if (!showMcpDocs && href.includes("/mcp")) {
return null
}
return (
<SidebarMenuItem key={name}>
<SidebarMenuButton
asChild
isActive={
href === "/docs"
? pathname === href
: pathname.startsWith(href)
}
className="relative h-[30px] w-fit overflow-visible border border-transparent text-[0.8rem] font-medium after:absolute after:inset-x-0 after:-inset-y-1 after:z-0 after:rounded-md data-[active=true]:border-accent data-[active=true]:bg-accent 3xl:fixed:w-full 3xl:fixed:max-w-48"
>
<Link href={href}>
<span className="absolute inset-0 flex w-(--sidebar-menu-width) bg-transparent" />
{name}
{PAGES_NEW.includes(href) && (
<span
className="flex size-2 rounded-full bg-blue-500"
title="New"
/>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{tree.children.map((item) => {
if (EXCLUDED_SECTIONS.includes(item.$id ?? "")) {
return null
}
return (
<SidebarGroup key={item.$id}>
<SidebarGroupLabel className="font-medium text-muted-foreground">
{item.name}
</SidebarGroupLabel>
<SidebarGroupContent>
{item.type === "folder" && (
<SidebarMenu className="gap-0.5">
{getPagesFromFolder(item, currentBase).map((page) => {
if (!showMcpDocs && page.url.includes("/mcp")) {
return null
}
if (EXCLUDED_PAGES.includes(page.url)) {
return null
}
return (
<SidebarMenuItem key={page.url}>
<SidebarMenuButton
asChild
isActive={page.url === pathname}
className="relative h-[30px] w-fit overflow-visible border border-transparent text-[0.8rem] font-medium after:absolute after:inset-x-0 after:-inset-y-1 after:z-0 after:rounded-md data-[active=true]:border-accent data-[active=true]:bg-accent 3xl:fixed:w-full 3xl:fixed:max-w-48"
>
<Link href={page.url}>
<span className="absolute inset-0 flex w-(--sidebar-menu-width) bg-transparent" />
{page.name}
{PAGES_NEW.includes(page.url) && (
<span
className="flex size-2 rounded-full bg-blue-500"
title="New"
/>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
)}
</SidebarGroupContent>
</SidebarGroup>
)
})}
</SidebarContent>
</Sidebar>
)
}

View File

@@ -0,0 +1,126 @@
"use client"
import * as React from "react"
import { IconMenu3 } from "@tabler/icons-react"
import { cn } from "cn"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/registry/new-york-v4/ui/dropdown-menu"
function useActiveItem(itemIds: string[]) {
const [activeId, setActiveId] = React.useState<string | null>(null)
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id)
}
}
},
{ rootMargin: "0% 0% -80% 0%" }
)
for (const id of itemIds ?? []) {
const element = document.getElementById(id)
if (element) {
observer.observe(element)
}
}
return () => {
for (const id of itemIds ?? []) {
const element = document.getElementById(id)
if (element) {
observer.unobserve(element)
}
}
}
}, [itemIds])
return activeId
}
export function DocsTableOfContents({
toc,
variant = "list",
className,
}: {
toc: {
title?: React.ReactNode
url: string
depth: number
}[]
variant?: "dropdown" | "list"
className?: string
}) {
const [open, setOpen] = React.useState(false)
const itemIds = React.useMemo(
() => toc.map((item) => item.url.replace("#", "")),
[toc]
)
const activeHeading = useActiveItem(itemIds)
if (!toc?.length) {
return null
}
if (variant === "dropdown") {
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn("h-8 md:h-7", className)}
>
<IconMenu3 /> On This Page
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="no-scrollbar max-h-[70svh]"
>
{toc.map((item) => (
<DropdownMenuItem
key={item.url}
asChild
onClick={() => {
setOpen(false)
}}
data-depth={item.depth}
className="data-[depth=3]:pl-6 data-[depth=4]:pl-8"
>
<a href={item.url}>{item.title}</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<div className={cn("flex flex-col gap-2 p-4 pt-0 text-sm", className)}>
<p className="h-6 bg-background text-xs font-medium text-muted-foreground">
On This Page
</p>
{toc.map((item) => (
<a
key={item.url}
href={item.url}
className="text-[0.8rem] text-muted-foreground no-underline transition-colors hover:text-foreground data-[active=true]:font-medium data-[active=true]:text-foreground data-[depth=3]:pl-4 data-[depth=4]:pl-6"
data-active={item.url === `#${activeHeading}`}
data-depth={item.depth}
>
{item.title}
</a>
))}
</div>
)
}

View File

@@ -0,0 +1,94 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "cn"
import { ScrollArea, ScrollBar } from "@/registry/new-york-v4/ui/scroll-area"
const examples = [
{
name: "Dashboard",
href: "/examples/dashboard",
code: "https://github.com/shadcn/ui/tree/main/apps/v4/app/(app)/examples/dashboard",
hidden: false,
},
{
name: "Tasks",
href: "/examples/tasks",
code: "https://github.com/shadcn/ui/tree/main/apps/v4/app/(app)/examples/tasks",
hidden: false,
},
{
name: "Playground",
href: "/examples/playground",
code: "https://github.com/shadcn/ui/tree/main/apps/v4/app/(app)/examples/playground",
hidden: false,
},
{
name: "Authentication",
href: "/examples/authentication",
code: "https://github.com/shadcn/ui/tree/main/apps/v4/app/(app)/examples/authentication",
hidden: false,
},
{
name: "RTL",
href: "/examples/rtl",
code: "https://github.com/shadcn/ui/tree/main/apps/v4/app/(app)/examples/rtl",
hidden: false,
},
]
export function ExamplesNav({
className,
...props
}: React.ComponentProps<"div">) {
const pathname = usePathname()
return (
<div className={cn("flex items-center", className)} {...props}>
<ScrollArea className="max-w-[96%] md:max-w-[600px] lg:max-w-none">
<div className="flex items-center">
<ExampleLink
example={{ name: "Examples", href: "/", code: "", hidden: false }}
isActive={pathname === "/"}
/>
{examples.map((example) => (
<ExampleLink
key={example.href}
example={example}
isActive={pathname?.startsWith(example.href) ?? false}
/>
))}
</div>
<ScrollBar orientation="horizontal" className="invisible" />
</ScrollArea>
</div>
)
}
function ExampleLink({
example,
isActive,
}: {
example: (typeof examples)[number]
isActive: boolean
}) {
if (example.hidden) {
return null
}
return (
<Link
href={example.href}
key={example.href}
className="flex h-7 items-center justify-center gap-2 px-4 text-center text-base font-medium text-muted-foreground transition-colors hover:text-primary data-[active=true]:text-primary"
data-active={isActive}
>
{example.name}
{example.name === "RTL" && (
<span className="flex size-2 rounded-full bg-blue-500" title="New" />
)}
</Link>
)
}

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import Link from "next/link"
import { siteConfig } from "@/lib/config"
import { Icons } from "@/components/icons"
import { Button } from "@/registry/new-york-v4/ui/button"
import { Skeleton } from "@/registry/new-york-v4/ui/skeleton"
export function GitHubLink() {
return (
<Button asChild size="sm" variant="ghost" className="h-8 shadow-none">
<Link href={siteConfig.links.github} target="_blank" rel="noreferrer">
<Icons.gitHub />
<React.Suspense fallback={<Skeleton className="h-4 w-[42px]" />}>
<StarsCount />
</React.Suspense>
</Link>
</Button>
)
}
export async function StarsCount() {
const data = await fetch("https://api.github.com/repos/shadcn-ui/ui", {
next: { revalidate: 86400 },
})
const json = await data.json()
const formattedCount =
json.stargazers_count >= 1000
? `${Math.round(json.stargazers_count / 1000)}k`
: json.stargazers_count?.toLocaleString()
return (
<span className="w-fit text-xs text-muted-foreground tabular-nums">
{formattedCount}
</span>
)
}

View File

@@ -0,0 +1,207 @@
import { FileIcon } from "lucide-react"
type IconProps = React.HTMLAttributes<SVGElement>
export const Icons = {
logo: (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" {...props}>
<rect width="256" height="256" fill="none" />
<line
x1="208"
y1="128"
x2="128"
y2="208"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
/>
<line
x1="192"
y1="40"
x2="40"
y2="192"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="32"
/>
</svg>
),
twitter: (props: IconProps) => (
<svg
{...props}
height="23"
viewBox="0 0 1200 1227"
width="23"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z" />
</svg>
),
gitHub: (props: IconProps) => (
<svg viewBox="0 0 438.549 438.549" {...props}>
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
></path>
</svg>
),
radix: (props: IconProps) => (
<svg viewBox="0 0 25 25" fill="none" {...props}>
<path
d="M12 25C7.58173 25 4 21.4183 4 17C4 12.5817 7.58173 9 12 9V25Z"
fill="currentcolor"
></path>
<path d="M12 0H4V8H12V0Z" fill="currentcolor"></path>
<path
d="M17 8C19.2091 8 21 6.20914 21 4C21 1.79086 19.2091 0 17 0C14.7909 0 13 1.79086 13 4C13 6.20914 14.7909 8 17 8Z"
fill="currentcolor"
></path>
</svg>
),
aria: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M13.966 22.624l-1.69-4.281H8.122l3.892-9.144 5.662 13.425zM8.884 1.376H0v21.248zm15.116 0h-8.884L24 22.624Z" />
</svg>
),
npm: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"
fill="currentColor"
/>
</svg>
),
yarn: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M12 0C5.375 0 0 5.375 0 12s5.375 12 12 12 12-5.375 12-12S18.625 0 12 0zm.768 4.105c.183 0 .363.053.525.157.125.083.287.185.755 1.154.31-.088.468-.042.551-.019.204.056.366.19.463.375.477.917.542 2.553.334 3.605-.241 1.232-.755 2.029-1.131 2.576.324.329.778.899 1.117 1.825.278.774.31 1.478.273 2.015a5.51 5.51 0 0 0 .602-.329c.593-.366 1.487-.917 2.553-.931.714-.009 1.269.445 1.353 1.103a1.23 1.23 0 0 1-.945 1.362c-.649.158-.95.278-1.821.843-1.232.797-2.539 1.242-3.012 1.39a1.686 1.686 0 0 1-.704.343c-.737.181-3.266.315-3.466.315h-.046c-.783 0-1.214-.241-1.45-.491-.658.329-1.51.19-2.122-.134a1.078 1.078 0 0 1-.58-1.153 1.243 1.243 0 0 1-.153-.195c-.162-.25-.528-.936-.454-1.946.056-.723.556-1.367.88-1.71a5.522 5.522 0 0 1 .408-2.256c.306-.727.885-1.348 1.32-1.737-.32-.537-.644-1.367-.329-2.21.227-.602.412-.936.82-1.08h-.005c.199-.074.389-.153.486-.259a3.418 3.418 0 0 1 2.298-1.103c.037-.093.079-.185.125-.283.31-.658.639-1.029 1.024-1.168a.94.94 0 0 1 .328-.06zm.006.7c-.507.016-1.001 1.519-1.001 1.519s-1.27-.204-2.266.871c-.199.218-.468.334-.746.44-.079.028-.176.023-.417.672-.371.991.625 2.094.625 2.094s-1.186.839-1.626 1.881c-.486 1.144-.338 2.261-.338 2.261s-.843.732-.899 1.487c-.051.663.139 1.2.343 1.515.227.343.51.176.51.176s-.561.653-.037.931c.477.25 1.283.394 1.71-.037.31-.31.371-1.001.486-1.283.028-.065.12.111.209.199.097.093.264.195.264.195s-.755.324-.445 1.066c.102.246.468.403 1.066.398.222-.005 2.664-.139 3.313-.296.375-.088.505-.283.505-.283s1.566-.431 2.998-1.357c.917-.598 1.293-.76 2.034-.936.612-.148.57-1.098-.241-1.084-.839.009-1.575.44-2.196.825-1.163.718-1.742.672-1.742.672l-.018-.032c-.079-.13.371-1.293-.134-2.678-.547-1.515-1.413-1.881-1.344-1.997.297-.5 1.038-1.297 1.334-2.78.176-.899.13-2.377-.269-3.151-.074-.144-.732.241-.732.241s-.616-1.371-.788-1.483a.271.271 0 0 0-.157-.046z"
fill="currentColor"
/>
</svg>
),
pnpm: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M0 0v7.5h7.5V0zm8.25 0v7.5h7.498V0zm8.25 0v7.5H24V0zM8.25 8.25v7.5h7.498v-7.5zm8.25 0v7.5H24v-7.5zM0 16.5V24h7.5v-7.5zm8.25 0V24h7.498v-7.5zm8.25 0V24H24v-7.5z"
fill="currentColor"
/>
</svg>
),
react: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"
fill="currentColor"
/>
</svg>
),
tailwind: (props: IconProps) => (
<svg viewBox="0 0 24 24" {...props}>
<path
d="M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z"
fill="currentColor"
/>
</svg>
),
google: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
),
apple: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
),
paypal: (props: IconProps) => (
<svg role="img" viewBox="0 0 24 24" {...props}>
<path
d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.565-5.946.36-1.847.174-3.388-.777-4.471z"
fill="currentColor"
/>
</svg>
),
spinner: (props: IconProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
),
json: (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M12.043 23.968c.479-.004.953-.029 1.426-.094a11.805 11.805 0 0 0 3.146-.863 12.404 12.404 0 0 0 3.793-2.542 11.977 11.977 0 0 0 2.44-3.427 11.794 11.794 0 0 0 1.02-3.476c.149-1.16.135-2.346-.045-3.499a11.96 11.96 0 0 0-.793-2.788 11.197 11.197 0 0 0-.854-1.617c-1.168-1.837-2.861-3.314-4.81-4.3a12.835 12.835 0 0 0-2.172-.87h-.005c.119.063.24.132.345.201.12.074.239.146.351.225a8.93 8.93 0 0 1 1.559 1.33c1.063 1.145 1.797 2.548 2.218 4.041.284.982.434 1.998.495 3.017.044.743.044 1.491-.047 2.229-.149 1.27-.554 2.51-1.228 3.596a7.475 7.475 0 0 1-1.903 2.084c-1.244.928-2.877 1.482-4.436 1.114a3.916 3.916 0 0 1-.748-.258 4.692 4.692 0 0 1-.779-.45 6.08 6.08 0 0 1-1.244-1.105 6.507 6.507 0 0 1-1.049-1.747 7.366 7.366 0 0 1-.494-2.54c-.03-1.273.225-2.553.854-3.67a6.43 6.43 0 0 1 1.663-1.918c.225-.178.464-.333.704-.479l.016-.007a5.121 5.121 0 0 0-1.441-.12 4.963 4.963 0 0 0-1.228.24c-.359.12-.704.27-1.019.45a6.146 6.146 0 0 0-.733.494c-.211.18-.42.36-.615.555-1.123 1.153-1.768 2.682-2.022 4.256-.15.973-.15 1.96-.091 2.95.105 1.395.391 2.787.945 4.062a8.518 8.518 0 0 0 1.348 2.173 8.14 8.14 0 0 0 3.132 2.23 7.934 7.934 0 0 0 2.113.54c.074.015.149.015.209.015zm-2.934-.398a4.102 4.102 0 0 1-.45-.228 8.5 8.5 0 0 1-2.038-1.534c-1.094-1.137-1.827-2.566-2.247-4.08a15.184 15.184 0 0 1-.495-3.172 12.14 12.14 0 0 1 .046-2.082c.135-1.257.495-2.501 1.124-3.58a6.889 6.889 0 0 1 1.783-2.053 6.23 6.23 0 0 1 1.633-.9 5.363 5.363 0 0 1 3.522-.045c.029 0 .029 0 .045.03.015.015.045.015.06.03.045.016.104.045.165.074.239.12.479.271.704.42a6.294 6.294 0 0 1 2.097 2.502c.42.914.615 1.934.631 2.938.014 1.079-.18 2.157-.645 3.146a6.42 6.42 0 0 1-2.638 2.832c.09.03.18.045.271.075.225.044.449.074.688.074 1.468.045 2.892-.66 3.94-1.647.195-.18.375-.375.54-.585.225-.27.435-.54.614-.823.239-.375.435-.75.614-1.154a8.112 8.112 0 0 0 .509-1.664c.196-1.004.211-2.022.149-3.026-.135-2.022-.673-4.045-1.842-5.724a9.054 9.054 0 0 0-.555-.719 9.868 9.868 0 0 0-1.063-1.034 8.477 8.477 0 0 0-1.363-.915 9.927 9.927 0 0 0-1.692-.598l-.3-.06c-.209-.03-.42-.044-.634-.06a8.453 8.453 0 0 0-1.015.016c-.704.045-1.412.16-2.112.337C5.799 1.227 2.863 3.566 1.3 6.67A11.834 11.834 0 0 0 .238 9.801a11.81 11.81 0 0 0-.104 3.775c.12 1.02.374 2.023.778 2.977.227.57.511 1.124.825 1.648 1.094 1.783 2.683 3.236 4.51 4.24.688.39 1.408.69 2.157.944.226.074.45.15.689.21z"
/>
</svg>
),
ts: (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<path d="M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z" />
</svg>
),
css: (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<path d="M0 0v20.16A3.84 3.84 0 0 0 3.84 24h16.32A3.84 3.84 0 0 0 24 20.16V3.84A3.84 3.84 0 0 0 20.16 0Zm14.256 13.08c1.56 0 2.28 1.08 2.304 2.64h-1.608c.024-.288-.048-.6-.144-.84-.096-.192-.288-.264-.552-.264-.456 0-.696.264-.696.84-.024.576.288.888.768 1.08.72.288 1.608.744 1.92 1.296q.432.648.432 1.656c0 1.608-.912 2.592-2.496 2.592-1.656 0-2.4-1.032-2.424-2.688h1.68c0 .792.264 1.176.792 1.176.264 0 .456-.072.552-.24.192-.312.24-1.176-.048-1.512-.312-.408-.912-.6-1.32-.816q-.828-.396-1.224-.936c-.24-.36-.36-.888-.36-1.536 0-1.44.936-2.472 2.424-2.448m5.4 0c1.584 0 2.304 1.08 2.328 2.64h-1.608c0-.288-.048-.6-.168-.84-.096-.192-.264-.264-.528-.264-.48 0-.72.264-.72.84s.288.888.792 1.08c.696.288 1.608.744 1.92 1.296.264.432.408.984.408 1.656.024 1.608-.888 2.592-2.472 2.592-1.68 0-2.424-1.056-2.448-2.688h1.68c0 .744.264 1.176.792 1.176.264 0 .456-.072.552-.24.216-.312.264-1.176-.048-1.512-.288-.408-.888-.6-1.32-.816-.552-.264-.96-.576-1.2-.936s-.36-.888-.36-1.536c-.024-1.44.912-2.472 2.4-2.448m-11.031.018c.711-.006 1.419.198 1.839.63.432.432.672 1.128.648 1.992H9.336c.024-.456-.096-.792-.432-.96-.312-.144-.768-.048-.888.24-.12.264-.192.576-.168.864v3.504c0 .744.264 1.128.768 1.128a.65.65 0 0 0 .552-.264c.168-.24.192-.552.168-.84h1.776c.096 1.632-.984 2.712-2.568 2.688-1.536 0-2.496-.864-2.472-2.472v-4.032c0-.816.24-1.44.696-1.848.432-.408 1.146-.624 1.857-.63" />
</svg>
),
bash: (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
<path d="M21.038 4.9 13.461.402a2.86 2.86 0 0 0-2.923.001L2.961 4.9A3.023 3.023 0 0 0 1.5 7.503v8.995c0 1.073.557 2.066 1.462 2.603l7.577 4.497a2.86 2.86 0 0 0 2.922 0l7.577-4.497a3.023 3.023 0 0 0 1.462-2.603V7.503A3.021 3.021 0 0 0 21.038 4.9zM15.17 18.946l.013.646c.001.078-.05.167-.111.198l-.383.22c-.061.031-.111-.007-.112-.085l-.007-.635c-.328.136-.66.169-.872.084-.04-.016-.057-.075-.041-.142l.139-.584a.24.24 0 0 1 .069-.121.163.163 0 0 1 .036-.026c.022-.011.043-.014.062-.006.229.077.521.041.802-.101.357-.181.596-.545.592-.907-.003-.328-.181-.465-.613-.468-.55.001-1.064-.107-1.072-.917-.007-.667.34-1.361.889-1.8l-.007-.652c-.001-.08.048-.168.111-.2l.37-.236c.061-.031.111.007.112.087l.006.653c.273-.109.511-.138.726-.088.047.012.067.076.048.151l-.144.578a.255.255 0 0 1-.065.116.161.161 0 0 1-.038.028.083.083 0 0 1-.057.009c-.098-.022-.332-.073-.699.113-.385.195-.52.53-.517.778.003.297.155.387.681.396.7.012 1.003.318 1.01 1.023.007.689-.362 1.433-.928 1.888zm3.973-1.087c0 .06-.008.116-.058.145l-1.916 1.164c-.05.029-.09.004-.09-.056v-.494c0-.06.037-.093.087-.122l1.887-1.129c.05-.029.09-.004.09.056v.436zm1.316-11.062-7.168 4.427c-.894.523-1.553 1.109-1.553 2.187v8.833c0 .645.26 1.063.66 1.184a2.304 2.304 0 0 1-.398.039c-.42 0-.833-.114-1.197-.33L3.226 18.64a2.494 2.494 0 0 1-1.201-2.142V7.503c0-.881.46-1.702 1.201-2.142L10.803.863a2.342 2.342 0 0 1 2.394 0l7.577 4.498a2.479 2.479 0 0 1 1.164 1.732c-.252-.536-.818-.682-1.479-.296z" />
</svg>
),
v0: (props: IconProps) => (
<svg
viewBox="0 0 40 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M23.3919 0H32.9188C36.7819 0 39.9136 3.13165 39.9136 6.99475V16.0805H36.0006V6.99475C36.0006 6.90167 35.9969 6.80925 35.9898 6.71766L26.4628 16.079C26.4949 16.08 26.5272 16.0805 26.5595 16.0805H36.0006V19.7762H26.5595C22.6964 19.7762 19.4788 16.6139 19.4788 12.7508V3.68923H23.3919V12.7508C23.3919 12.9253 23.4054 13.0977 23.4316 13.2668L33.1682 3.6995C33.0861 3.6927 33.003 3.68923 32.9188 3.68923H23.3919V0Z"
fill="currentColor"
></path>
<path
d="M13.7688 19.0956L0 3.68759H5.53933L13.6231 12.7337V3.68759H17.7535V17.5746C17.7535 19.6705 15.1654 20.6584 13.7688 19.0956Z"
fill="currentColor"
></path>
</svg>
),
}
export function getIconForLanguageExtension(language: string) {
switch (language) {
case "json":
return <Icons.json />
case "css":
return <Icons.css className="fill-foreground" />
case "js":
case "jsx":
case "ts":
case "tsx":
case "typescript":
return <Icons.ts className="fill-foreground" />
default:
return <FileIcon />
}
}

View File

@@ -0,0 +1,125 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/styles/base-nova/ui/select"
export type Language = "en" | "ar" | "he"
export type Direction = "ltr" | "rtl"
export type Translations<
T extends Record<string, string> = Record<string, string>,
> = Record<
Language,
{
dir: Direction
locale?: string
values: T
}
>
export const languageOptions = [
{ value: "en", label: "English" },
{ value: "ar", label: "Arabic (العربية)" },
{ value: "he", label: "Hebrew (עברית)" },
] as const
type LanguageContextType = {
language: Language
setLanguage: (language: Language) => void
}
const LanguageContext = React.createContext<LanguageContextType | undefined>(
undefined
)
export function LanguageProvider({
children,
defaultLanguage = "ar",
}: {
children: React.ReactNode
defaultLanguage?: Language
}) {
const [language, setLanguage] = React.useState<Language>(defaultLanguage)
return (
<LanguageContext.Provider value={{ language, setLanguage }}>
{children}
</LanguageContext.Provider>
)
}
export function useLanguageContext() {
const context = React.useContext(LanguageContext)
return context
}
export function useTranslation<T extends Record<string, string>>(
translations: Translations<T>,
defaultLanguage: Language = "ar"
) {
const context = useLanguageContext()
const [localLanguage, setLocalLanguage] =
React.useState<Language>(defaultLanguage)
const language = context?.language ?? localLanguage
const setLanguage = context?.setLanguage ?? setLocalLanguage
const { dir, locale, values: t } = translations[language]
return { language, setLanguage, dir, locale, t }
}
export interface LanguageSelectorProps {
value: Language
onValueChange: (value: Language) => void
}
export function LanguageSelector({
value,
onValueChange,
className,
languages = ["en", "ar", "he"],
}: LanguageSelectorProps & {
className?: string
languages?: Language[]
}) {
return (
<Select
items={languageOptions}
value={value}
onValueChange={(value) => onValueChange(value as Language)}
>
<SelectTrigger
size="sm"
className={cn("w-36", className)}
dir="ltr"
data-name="language-selector"
>
<SelectValue />
</SelectTrigger>
<SelectContent
dir="ltr"
className="data-open:animate-none data-closed:animate-none"
>
<SelectGroup>
{languageOptions
.filter((option) => languages.includes(option.value as Language))
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "cn"
import { PAGES_NEW } from "@/lib/docs"
import { Button } from "@/registry/new-york-v4/ui/button"
export function MainNav({
items,
className,
...props
}: React.ComponentProps<"nav"> & {
items: { href: string; label: string }[]
}) {
const pathname = usePathname()
return (
<nav className={cn("items-center gap-0", className)} {...props}>
{items.map((item) => (
<Button
key={item.href}
variant="ghost"
asChild
size="sm"
className="px-2.5"
>
<Link
href={item.href}
data-active={pathname === item.href}
data-new={PAGES_NEW.includes(item.href)}
className="relative items-center"
>
{item.label}
</Link>
</Button>
))}
</nav>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import { code } from "@streamdown/code"
import { cn } from "cn"
import { Streamdown } from "streamdown"
const DEFAULT_PLUGINS = { code }
function Markdown({
className,
plugins = DEFAULT_PLUGINS,
controls = false,
...props
}: React.ComponentProps<typeof Streamdown>) {
return (
<Streamdown
data-slot="markdown"
plugins={plugins}
controls={controls}
className={cn("cn-markdown w-full min-w-0 overflow-hidden", className)}
{...props}
/>
)
}
export { Markdown }

View File

@@ -0,0 +1,186 @@
"use client"
import * as React from "react"
import { BrainIcon } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import type { MessageAnimationPreset } from "@/lib/message-animations"
import { MESSAGE_ANIMATIONS } from "@/lib/message-animations"
import { Bubble, BubbleContent } from "@/styles/radix-rhea/ui/bubble"
import { Message, MessageContent } from "@/styles/radix-rhea/ui/message"
import { MessageScrollerItem } from "@/styles/radix-rhea/ui/message-scroller"
type MessageAnimatedPart = {
content?: unknown
type: string
text?: unknown
}
type MessageAnimatedMessage = {
id: string
role: string
text?: string
parts?: ReadonlyArray<MessageAnimatedPart>
}
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
function MessageAnimated({
message,
animationPreset = MESSAGE_ANIMATIONS["slide-up"],
assistantVariant = "ghost",
scrollAnchor,
userVariant = "muted",
...props
}: Omit<
React.ComponentProps<typeof MotionMessageScrollerItem>,
"animate" | "children" | "exit" | "initial" | "messageId" | "variants"
> & {
animationPreset?: MessageAnimationPreset
assistantVariant?: React.ComponentProps<typeof Bubble>["variant"]
message: MessageAnimatedMessage
userVariant?: React.ComponentProps<typeof Bubble>["variant"]
}) {
const shouldReduceMotion = useReducedMotion()
const isUserMessage = message.role === "user"
if (isUserMessage) {
return (
<MotionMessageScrollerItem
messageId={message.id}
scrollAnchor={scrollAnchor ?? true}
variants={animationPreset.variants}
initial={shouldReduceMotion ? false : "initial"}
animate="animate"
exit={shouldReduceMotion ? undefined : "exit"}
{...props}
>
<MessageAnimatedRow
message={message}
assistantVariant={assistantVariant}
userVariant={userVariant}
/>
</MotionMessageScrollerItem>
)
}
return (
<MotionMessageScrollerItem
messageId={message.id}
scrollAnchor={scrollAnchor}
initial={false}
{...props}
>
<MessageAnimatedRow
message={message}
assistantVariant={assistantVariant}
userVariant={userVariant}
/>
</MotionMessageScrollerItem>
)
}
function MessageAnimatedRow({
message,
assistantVariant,
userVariant,
}: {
assistantVariant: React.ComponentProps<typeof Bubble>["variant"]
message: MessageAnimatedMessage
userVariant: React.ComponentProps<typeof Bubble>["variant"]
}) {
const isUserMessage = message.role === "user"
const parts = getMessageAnimatedContentParts(message)
return (
<Message align={isUserMessage ? "end" : "start"}>
<MessageContent>
{parts.map((part) => {
const paragraphs = part.text
.split(/\n\s*\n/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
if (part.type === "reasoning") {
return (
<div
key={part.key}
className="w-full border-l-2 border-muted-foreground/30 pl-3 text-muted-foreground"
>
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium">
<BrainIcon className="size-3.5" />
Reasoning
</div>
<div className="space-y-1.5 text-sm">
{paragraphs.map((paragraph, paragraphIndex) => (
<p
key={`${part.key}-${paragraphIndex}`}
className="whitespace-pre-wrap"
>
{paragraph}
</p>
))}
</div>
</div>
)
}
return (
<Bubble
key={part.key}
variant={isUserMessage ? userVariant : assistantVariant}
>
<BubbleContent className="space-y-2">
{paragraphs.map((paragraph, paragraphIndex) => (
<p
key={`${part.key}-${paragraphIndex}`}
className="whitespace-pre-wrap"
>
{paragraph}
</p>
))}
</BubbleContent>
</Bubble>
)
})}
</MessageContent>
</Message>
)
}
function getMessageAnimatedContentParts(message: MessageAnimatedMessage) {
if (message.parts) {
return message.parts.flatMap((part, index) => {
const type =
part.type === "reasoning" || part.type === "thinking"
? "reasoning"
: part.type === "text"
? "text"
: null
const text =
typeof part.text === "string"
? part.text
: typeof part.content === "string"
? part.content
: null
if (!type || text === null) {
return []
}
return [
{
key: `${message.id}-${index}`,
text,
type,
},
]
})
}
return typeof message.text === "string"
? [{ key: `${message.id}-text`, text: message.text, type: "text" }]
: []
}
export { MessageAnimated, type MessageAnimatedMessage }

View File

@@ -0,0 +1,645 @@
import * as React from "react"
type MessagePartKind =
| "text"
| "reasoning"
| "tool"
| "file"
| "source"
| "data"
| "custom"
| "step-start"
| "reasoning-file"
| (string & {})
type MessagePartOf<TMessage> = TMessage extends {
parts: ReadonlyArray<infer TPart>
}
? TPart
: unknown
type MessagePartItem<TPart = unknown, TMessage = unknown> = {
key: string
part: TPart
message: TMessage
index: number
kind: MessagePartKind
name?: string
toolCallId?: string
}
type MessageToolPartGroup<TPart = unknown, TMessage = unknown> = {
key: string
name?: string
toolCallId?: string
items: Array<MessagePartItem<TPart, TMessage>>
call?: MessagePartItem<TPart, TMessage>
result?: MessagePartItem<TPart, TMessage>
}
type MessagePartsResult<TPart = unknown, TMessage = unknown> = {
role?: string
all: Array<MessagePartItem<TPart, TMessage>>
protocol: Array<MessagePartItem<TPart, TMessage>>
byKind: Record<string, Array<MessagePartItem<TPart, TMessage>>>
tools: Array<MessageToolPartGroup<TPart, TMessage>>
toolParts: Array<MessagePartItem<TPart, TMessage>>
text: Array<MessagePartItem<TPart, TMessage>>
texts: Array<MessagePartItem<TPart, TMessage>>
reasoning: Array<MessagePartItem<TPart, TMessage>>
files: Array<MessagePartItem<TPart, TMessage>>
sources: Array<MessagePartItem<TPart, TMessage>>
data: Array<MessagePartItem<TPart, TMessage>>
custom: Array<MessagePartItem<TPart, TMessage>>
steps: Array<MessagePartItem<TPart, TMessage>>
reasoningFiles: Array<MessagePartItem<TPart, TMessage>>
get: (kind: MessagePartKind) => Array<MessagePartItem<TPart, TMessage>>
}
type MessagePartsAdapter<TMessage, TPart> = {
getParts?: (message: TMessage) => ReadonlyArray<TPart>
getRole?: (message: TMessage) => string | undefined
getPartKey?: (part: TPart, index: number, message: TMessage) => string
getPartKind?: (
part: TPart,
index: number,
message: TMessage
) => MessagePartKind
getPartName?: (
part: TPart,
index: number,
message: TMessage
) => string | undefined
getToolCallId?: (
part: TPart,
index: number,
message: TMessage
) => string | undefined
isToolCall?: (part: TPart, index: number, message: TMessage) => boolean
isToolResult?: (part: TPart, index: number, message: TMessage) => boolean
}
type CreateMessagePartsOptions<TMessage, TPart> = {
adapter?: MessagePartsAdapter<TMessage, TPart>
order?:
| "role"
| "protocol"
| MessagePartKind[]
| ((
items: Array<MessagePartItem<TPart, TMessage>>,
message: TMessage
) => Array<MessagePartItem<TPart, TMessage>>)
classifyPart?: (
part: TPart,
context: {
message: TMessage
index: number
kind: MessagePartKind
name?: string
toolCallId?: string
}
) =>
| {
kind?: MessagePartKind
name?: string
toolCallId?: string
}
| undefined
getKey?: (part: TPart, index: number, message: TMessage) => string
}
type MessagePartRenderProps<TPart = unknown, TMessage = unknown> = {
item: MessagePartItem<TPart, TMessage>
part: TPart
message: TMessage
index: number
kind: MessagePartKind
name?: string
toolCallId?: string
}
type MessagePartComponent<
TPart = unknown,
TMessage = unknown,
> = React.ComponentType<MessagePartRenderProps<TPart, TMessage>>
type MessagePartComponents<TPart = unknown, TMessage = unknown> = {
text?: MessagePartComponent<TPart, TMessage>
reasoning?: MessagePartComponent<TPart, TMessage>
tool?: MessagePartComponent<TPart, TMessage>
file?: MessagePartComponent<TPart, TMessage>
source?: MessagePartComponent<TPart, TMessage>
data?: MessagePartComponent<TPart, TMessage>
custom?: MessagePartComponent<TPart, TMessage>
stepStart?: MessagePartComponent<TPart, TMessage>
reasoningFile?: MessagePartComponent<TPart, TMessage>
fallback?: MessagePartComponent<TPart, TMessage>
types?: Record<string, MessagePartComponent<TPart, TMessage>>
tools?: Record<string, MessagePartComponent<TPart, TMessage>>
dataTypes?: Record<string, MessagePartComponent<TPart, TMessage>>
}
type MessagePartProps<TPart = unknown, TMessage = unknown> = {
item: MessagePartItem<TPart, TMessage>
components: MessagePartComponents<TPart, TMessage>
}
type MessagePartsProps<TPart = unknown, TMessage = unknown> = {
parts:
| MessagePartsResult<TPart, TMessage>
| Array<MessagePartItem<TPart, TMessage>>
components: MessagePartComponents<TPart, TMessage>
renderPart?: (props: {
item: MessagePartItem<TPart, TMessage>
children: React.ReactNode
}) => React.ReactNode
}
const ASSISTANT_PART_ORDER: MessagePartKind[] = [
"reasoning",
"tool",
"reasoning-file",
"file",
"text",
"source",
"data",
"custom",
"step-start",
]
const USER_PART_ORDER: MessagePartKind[] = [
"file",
"text",
"data",
"source",
"custom",
]
const DEFAULT_PART_ORDER: MessagePartKind[] = ["text", "data", "custom"]
function createMessageParts<TMessage>(
message: TMessage,
options: CreateMessagePartsOptions<TMessage, MessagePartOf<TMessage>> = {}
): MessagePartsResult<MessagePartOf<TMessage>, TMessage> {
type TPart = MessagePartOf<TMessage>
const adapter = options.adapter ?? {}
const role = adapter.getRole?.(message) ?? getStructuralRole(message)
const messageParts =
adapter.getParts?.(message) ??
(getStructuralParts(message) as ReadonlyArray<TPart>)
const protocol = messageParts.map((part, index) => {
const kind =
adapter.getPartKind?.(part, index, message) ?? getStructuralPartKind(part)
const name =
adapter.getPartName?.(part, index, message) ?? getStructuralPartName(part)
const toolCallId =
adapter.getToolCallId?.(part, index, message) ??
getStructuralToolCallId(part)
const classification = options.classifyPart?.(part, {
message,
index,
kind,
name,
toolCallId,
})
return {
key:
options.getKey?.(part, index, message) ??
adapter.getPartKey?.(part, index, message) ??
getStructuralPartKey(part, index),
part,
message,
index,
kind: classification?.kind ?? kind,
name: classification?.name ?? name,
toolCallId: classification?.toolCallId ?? toolCallId,
} satisfies MessagePartItem<TPart, TMessage>
})
const all = orderMessagePartItems(
protocol,
options.order ?? "role",
role,
message
)
const byKind = groupMessagePartItems(all)
function get(kind: MessagePartKind) {
return byKind[kind] ?? []
}
return {
role,
all,
protocol,
byKind,
tools: groupToolPartItems(all, adapter, message),
toolParts: get("tool"),
text: get("text"),
texts: get("text"),
reasoning: get("reasoning"),
files: get("file"),
sources: get("source"),
data: get("data"),
custom: get("custom"),
steps: get("step-start"),
reasoningFiles: get("reasoning-file"),
get,
}
}
function MessageParts<TPart, TMessage>({
parts,
components,
renderPart,
}: MessagePartsProps<TPart, TMessage>) {
const items = Array.isArray(parts) ? parts : parts.all
return (
<>
{items.map((item) => {
const children = (
<MessagePart key={item.key} item={item} components={components} />
)
return renderPart ? (
<React.Fragment key={item.key}>
{renderPart({ item, children })}
</React.Fragment>
) : (
children
)
})}
</>
)
}
function MessagePart<TPart, TMessage>({
item,
components,
}: MessagePartProps<TPart, TMessage>) {
const Component = getMessagePartComponent(item, components)
if (!Component) {
return null
}
return React.createElement(Component, {
item,
part: item.part,
message: item.message,
index: item.index,
kind: item.kind,
name: item.name,
toolCallId: item.toolCallId,
})
}
function getMessagePartComponent<TPart, TMessage>(
item: MessagePartItem<TPart, TMessage>,
components: MessagePartComponents<TPart, TMessage>
) {
const partType = getStructuralPartType(item.part)
if (partType && components.types?.[partType]) {
return components.types[partType]
}
if (item.kind === "tool" && item.name && components.tools?.[item.name]) {
return components.tools[item.name]
}
if (item.kind === "data" && item.name && components.dataTypes?.[item.name]) {
return components.dataTypes[item.name]
}
if (item.kind === "text") {
return components.text ?? components.fallback
}
if (item.kind === "reasoning") {
return components.reasoning ?? components.fallback
}
if (item.kind === "tool") {
return components.tool ?? components.fallback
}
if (item.kind === "file") {
return components.file ?? components.fallback
}
if (item.kind === "source") {
return components.source ?? components.fallback
}
if (item.kind === "data") {
return components.data ?? components.fallback
}
if (item.kind === "custom") {
return components.custom ?? components.fallback
}
if (item.kind === "step-start") {
return components.stepStart ?? components.fallback
}
if (item.kind === "reasoning-file") {
return components.reasoningFile ?? components.file ?? components.fallback
}
return components.fallback
}
function orderMessagePartItems<TPart, TMessage>(
items: Array<MessagePartItem<TPart, TMessage>>,
order: NonNullable<CreateMessagePartsOptions<TMessage, TPart>["order"]>,
role: string | undefined,
message: TMessage
) {
if (order === "protocol") {
return items
}
if (typeof order === "function") {
return order(items, message)
}
const orderedKinds = Array.isArray(order)
? order
: role === "user"
? USER_PART_ORDER
: role === "assistant"
? ASSISTANT_PART_ORDER
: DEFAULT_PART_ORDER
const orderWeight = new Map(orderedKinds.map((kind, index) => [kind, index]))
return [...items].sort((a, b) => {
const aWeight = orderWeight.get(a.kind) ?? Number.MAX_SAFE_INTEGER
const bWeight = orderWeight.get(b.kind) ?? Number.MAX_SAFE_INTEGER
return aWeight === bWeight ? a.index - b.index : aWeight - bWeight
})
}
function groupMessagePartItems<TPart, TMessage>(
items: Array<MessagePartItem<TPart, TMessage>>
) {
return items.reduce<Record<string, Array<MessagePartItem<TPart, TMessage>>>>(
(groups, item) => {
groups[item.kind] ??= []
groups[item.kind].push(item)
return groups
},
{}
)
}
function groupToolPartItems<TPart, TMessage>(
items: Array<MessagePartItem<TPart, TMessage>>,
adapter: MessagePartsAdapter<TMessage, TPart>,
message: TMessage
) {
const groups: MessageToolPartGroup<TPart, TMessage>[] = []
const groupsByKey = new Map<string, MessageToolPartGroup<TPart, TMessage>>()
for (const item of items) {
if (item.kind !== "tool") {
continue
}
const groupKey = item.toolCallId ?? item.name ?? item.key
let group = groupsByKey.get(groupKey)
if (!group) {
group = {
key: groupKey,
name: item.name,
toolCallId: item.toolCallId,
items: [],
}
groupsByKey.set(groupKey, group)
groups.push(group)
}
group.items.push(item)
if (adapter.isToolResult?.(item.part, item.index, message)) {
group.result = item
} else if (adapter.isToolCall?.(item.part, item.index, message)) {
group.call = item
} else if (isStructuralToolResult(item.part)) {
group.result = item
} else if (!group.call) {
group.call = item
}
}
return groups
}
function getStructuralParts(message: unknown) {
if (isRecord(message) && Array.isArray(message.parts)) {
return message.parts
}
return []
}
function getStructuralRole(message: unknown) {
if (isRecord(message) && typeof message.role === "string") {
return message.role
}
return undefined
}
function getStructuralPartKey(part: unknown, index: number) {
const type = getStructuralPartType(part) ?? "part"
const toolCallId = getStructuralToolCallId(part)
if (toolCallId) {
return `${type}:${toolCallId}`
}
if (isRecord(part)) {
if (typeof part.sourceId === "string") {
return `${type}:${part.sourceId}`
}
if (typeof part.id === "string") {
return `${type}:${part.id}`
}
if (typeof part.url === "string") {
return `${type}:${part.url}:${index}`
}
}
return `${type}:${index}`
}
function getStructuralPartKind(part: unknown): MessagePartKind {
const type = getStructuralPartType(part)
if (!type) {
return "custom"
}
if (type === "text") {
return "text"
}
if (type === "reasoning" || type === "thinking") {
return "reasoning"
}
if (
type === "file" ||
type === "image" ||
type === "audio" ||
type === "video"
) {
return "file"
}
if (type === "reasoning-file") {
return "reasoning-file"
}
if (
type === "source" ||
type === "source-url" ||
type === "source-document" ||
type === "citation" ||
type === "search-result"
) {
return "source"
}
if (
type === "dynamic-tool" ||
type === "tool-call" ||
type === "tool-result" ||
type === "tool_call" ||
type === "tool_result" ||
type.startsWith("tool-")
) {
return "tool"
}
if (type.startsWith("data-")) {
return "data"
}
if (type === "step-start" || type === "step_start") {
return "step-start"
}
if (type === "custom") {
return "custom"
}
return "custom"
}
function getStructuralPartName(part: unknown) {
const type = getStructuralPartType(part)
if (!type) {
return undefined
}
if (isRecord(part)) {
if (typeof part.toolName === "string") {
return part.toolName
}
if (typeof part.name === "string") {
return part.name
}
if (typeof part.kind === "string") {
return part.kind
}
}
if (type.startsWith("tool-")) {
return type.slice("tool-".length)
}
if (type.startsWith("data-")) {
return type.slice("data-".length)
}
if (type === "source-url") {
return "url"
}
if (type === "source-document") {
return "document"
}
return undefined
}
function getStructuralToolCallId(part: unknown) {
if (!isRecord(part)) {
return undefined
}
if (typeof part.toolCallId === "string") {
return part.toolCallId
}
if (typeof part.callId === "string") {
return part.callId
}
return undefined
}
function isStructuralToolResult(part: unknown) {
const type = getStructuralPartType(part)
return (
type === "tool-result" ||
type === "tool_result" ||
(isRecord(part) && "output" in part)
)
}
function getStructuralPartType(part: unknown) {
if (isRecord(part) && typeof part.type === "string") {
return part.type
}
return undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export {
MessagePart,
MessageParts,
createMessageParts,
getMessagePartComponent,
type CreateMessagePartsOptions,
type MessagePartComponent,
type MessagePartComponents,
type MessagePartItem,
type MessagePartKind,
type MessagePartProps,
type MessagePartRenderProps,
type MessagePartsAdapter,
type MessagePartsProps,
type MessagePartsResult,
type MessageToolPartGroup,
}

View File

@@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import Link, { type LinkProps } from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { cn } from "cn"
import { PAGES_NEW } from "@/lib/docs"
import { showMcpDocs } from "@/lib/flags"
import { getCurrentBase, getPagesFromFolder } from "@/lib/page-tree"
import { type source } from "@/lib/source"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/new-york-v4/ui/popover"
const TOP_LEVEL_SECTIONS = [
{ name: "Introduction", href: "/docs" },
{
name: "Components",
href: "/docs/components",
},
{
name: "Installation",
href: "/docs/installation",
},
{
name: "Theming",
href: "/docs/theming",
},
{
name: "CLI",
href: "/docs/cli",
},
{
name: "RTL",
href: "/docs/rtl",
},
{
name: "Skills",
href: "/docs/skills",
},
{
name: "MCP Server",
href: "/docs/mcp",
},
{
name: "Registry",
href: "/docs/registry",
},
{
name: "Forms",
href: "/docs/forms",
},
{
name: "Changelog",
href: "/docs/changelog",
},
]
export function MobileNav({
tree,
items,
className,
}: {
tree: typeof source.pageTree
items: { href: string; label: string }[]
className?: string
}) {
const [open, setOpen] = React.useState(false)
const pathname = usePathname()
const currentBase = getCurrentBase(pathname)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
"extend-touch-target h-8 touch-manipulation items-center justify-start gap-2.5 p-0! hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 active:bg-transparent dark:hover:bg-transparent",
className
)}
>
<div className="relative flex h-8 w-4 items-center justify-center">
<div className="relative size-4">
<span
className={cn(
"absolute left-0 block h-0.5 w-4 bg-foreground transition-all duration-100",
open ? "top-[0.4rem] -rotate-45" : "top-1"
)}
/>
<span
className={cn(
"absolute left-0 block h-0.5 w-4 bg-foreground transition-all duration-100",
open ? "top-[0.4rem] rotate-45" : "top-2.5"
)}
/>
</div>
<span className="sr-only">Toggle Menu</span>
</div>
<span className="flex h-8 items-center text-lg leading-none font-medium">
Menu
</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="no-scrollbar h-(--radix-popper-available-height) w-(--radix-popper-available-width) overflow-y-auto rounded-none border-none bg-background/90 p-0 shadow-none backdrop-blur duration-100 data-open:animate-none!"
align="start"
side="bottom"
alignOffset={-16}
sideOffset={14}
>
<div className="flex flex-col gap-12 overflow-auto px-6 py-6">
<div className="flex flex-col gap-4">
<div className="text-sm font-medium text-muted-foreground">
Menu
</div>
<div className="flex flex-col gap-3">
{items.map((item, index) => (
<MobileLink key={index} href={item.href} onOpenChange={setOpen}>
{item.label}
</MobileLink>
))}
</div>
</div>
<div className="flex flex-col gap-4">
<div className="text-sm font-medium text-muted-foreground">
Sections
</div>
<div className="flex flex-col gap-3">
{TOP_LEVEL_SECTIONS.map(({ name, href }) => {
if (!showMcpDocs && href.includes("/mcp")) {
return null
}
return (
<MobileLink key={name} href={href} onOpenChange={setOpen}>
{name}
{PAGES_NEW.includes(href) && (
<span
className="flex size-2 rounded-full bg-blue-500"
title="New"
/>
)}
</MobileLink>
)
})}
</div>
</div>
<div className="flex flex-col gap-8">
{tree?.children?.map((group, index) => {
if (group.type === "folder") {
const pages = getPagesFromFolder(group, currentBase)
return (
<div key={index} className="flex flex-col gap-4">
<div className="text-sm font-medium text-muted-foreground">
{group.name}
</div>
<div className="flex flex-col gap-3">
{pages.map((item) => {
if (!showMcpDocs && item.url.includes("/mcp")) {
return null
}
return (
<MobileLink
key={`${item.url}-${index}`}
href={item.url}
onOpenChange={setOpen}
className="flex items-center gap-2"
>
{item.name}{" "}
{PAGES_NEW.includes(item.url) && (
<span className="flex size-2 rounded-full bg-blue-500" />
)}
</MobileLink>
)
})}
</div>
</div>
)
}
})}
</div>
</div>
</PopoverContent>
</Popover>
)
}
function MobileLink({
href,
onOpenChange,
className,
children,
...props
}: LinkProps & {
onOpenChange?: (open: boolean) => void
children: React.ReactNode
className?: string
}) {
const router = useRouter()
return (
<Link
href={href}
onClick={() => {
router.push(href.toString())
onOpenChange?.(false)
}}
className={cn("flex items-center gap-2 text-2xl font-medium", className)}
{...props}
>
{children}
</Link>
)
}

View File

@@ -0,0 +1,97 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "cn"
import { useTheme } from "next-themes"
import { useMetaColor } from "@/hooks/use-meta-color"
import { Button } from "@/registry/new-york-v4/ui/button"
export const DARK_MODE_FORWARD_TYPE = "dark-mode-forward"
export function ModeSwitcher({
variant = "ghost",
className,
}: {
variant?: React.ComponentProps<typeof Button>["variant"]
className?: React.ComponentProps<typeof Button>["className"]
}) {
const { setTheme, resolvedTheme } = useTheme()
const { setMetaColor, metaColor } = useMetaColor()
React.useEffect(() => {
setMetaColor(metaColor)
}, [metaColor, setMetaColor])
const toggleTheme = React.useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setTheme])
return (
<Button
variant={variant}
size="icon"
className={cn("group/toggle extend-touch-target size-8", className)}
onClick={toggleTheme}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4.5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
<span className="sr-only">Toggle theme</span>
</Button>
)
}
export function DarkModeScript() {
return (
// eslint-disable-next-line @next/next/no-before-interactive-script-outside-document
<Script
id="dark-mode-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward D key
document.addEventListener('keydown', function(e) {
if ((e.key === 'd' || e.key === 'D') && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${DARK_MODE_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -0,0 +1,37 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
} from "@/registry/new-york-v4/ui/navigation-menu"
export function NavHeader() {
const pathname = usePathname()
return (
<NavigationMenu className="hidden sm:flex">
<NavigationMenuList className="gap-2 *:data-[slot=navigation-menu-item]:h-7 **:data-[slot=navigation-menu-link]:py-1 **:data-[slot=navigation-menu-link]:font-medium">
<NavigationMenuItem>
<NavigationMenuLink asChild data-active={pathname === "/"}>
<Link href="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild data-active={pathname === "/charts"}>
<Link href="/charts">Charts</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild data-active={pathname === "/forms"}>
<Link href="/forms">Forms</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)
}

View File

@@ -0,0 +1,114 @@
"use client"
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
} from "lucide-react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/registry/new-york-v4/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/new-york-v4/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/registry/new-york-v4/ui/sidebar"
export function NavUser({
user,
}: {
user: {
name: string
email: string
avatar: string
}
}) {
const { isMobile } = useSidebar()
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@@ -0,0 +1,31 @@
import { cn } from "cn"
import { Icons } from "@/components/icons"
import { Button } from "@/registry/new-york-v4/ui/button"
// v0 uses the default style.
const V0_STYLE = "new-york-v4"
export function OpenInV0Button({
name,
className,
...props
}: React.ComponentProps<typeof Button> & {
name: string
}) {
return (
<Button
size="sm"
asChild
className={cn("h-[1.8rem] gap-1", className)}
{...props}
>
<a
href={`${process.env.NEXT_PUBLIC_V0_URL}/chat/api/open?url=${process.env.NEXT_PUBLIC_APP_URL}/r/styles/${V0_STYLE}/${name}.json`}
target="_blank"
>
Open in <Icons.v0 className="size-5" />
</a>
</Button>
)
}

View File

@@ -0,0 +1,36 @@
import { cn } from "cn"
import { Button } from "@/styles/base-nova/ui/button"
export function OpenInV0Cta({ className }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"group relative flex flex-col gap-2 rounded-2xl bg-surface p-6 text-sm text-surface-foreground",
className
)}
>
<div className="text-base leading-tight font-semibold text-balance group-hover:underline">
Deploy your shadcn/ui app on Vercel
</div>
<div className="text-muted-foreground">
Trusted by OpenAI, Sonos, Adobe, and more.
</div>
<div className="text-muted-foreground">
Vercel provides tools and infrastructure to deploy apps and features at
scale.
</div>
<Button variant="outline" size="sm" className="mt-2 w-fit">
Deploy Now
</Button>
<a
href="https://vercel.com/new?utm_source=shadcn_site&utm_medium=web&utm_campaign=docs_cta_deploy_now_callout"
target="_blank"
rel="noreferrer"
className="absolute inset-0"
>
<span className="sr-only">Deploy to Vercel</span>
</a>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { cn } from "cn"
function PageHeader({
className,
children,
...props
}: React.ComponentProps<"section">) {
return (
<section className={cn("border-grid", className)} {...props}>
<div className="container-wrapper">
<div className="container flex flex-col items-center gap-2 px-6 py-8 text-center md:py-16 lg:py-20 xl:gap-4">
{children}
</div>
</div>
</section>
)
}
function PageHeaderHeading({
className,
...props
}: React.ComponentProps<"h1">) {
return (
<h1
className={cn(
"leading-tighter max-w-3xl text-3xl font-semibold tracking-tight text-balance text-primary lg:leading-[1.1] lg:font-semibold xl:text-5xl xl:tracking-tighter",
className
)}
{...props}
/>
)
}
function PageHeaderDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
className={cn(
"max-w-4xl text-base text-balance text-foreground sm:text-lg",
className
)}
{...props}
/>
)
}
function PageActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex w-full items-center justify-center gap-2 pt-2 **:data-[slot=button]:shadow-none",
className
)}
{...props}
/>
)
}
export { PageActions, PageHeader, PageHeaderDescription, PageHeaderHeading }

View File

@@ -0,0 +1,15 @@
import { cn } from "cn"
export function PageNav({
children,
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("container-wrapper scroll-mt-24", className)} {...props}>
<div className="container flex items-center justify-between gap-4 py-4">
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { GalleryHorizontalIcon } from "lucide-react"
import { trackEvent } from "@/lib/events"
import { useLayout } from "@/hooks/use-layout"
import { Button } from "@/registry/new-york-v4/ui/button"
export function SiteConfig({ className }: React.ComponentProps<typeof Button>) {
const { layout, setLayout } = useLayout()
return (
<Button
variant="ghost"
size="icon"
onClick={() => {
const newLayout = layout === "fixed" ? "full" : "fixed"
setLayout(newLayout)
trackEvent({
name: "set_layout",
properties: { layout: newLayout },
})
}}
className={cn("size-8", className)}
title="Toggle layout"
>
<span className="sr-only">Toggle layout</span>
<GalleryHorizontalIcon />
</Button>
)
}

View File

@@ -0,0 +1,42 @@
import { siteConfig } from "@/lib/config"
export function SiteFooter() {
return (
<footer className="group-has-[.docs-nav]/body:pb-20 group-has-[.section-soft]/body:bg-surface/40 group-has-[[data-slot=designer]]/body:hidden group-has-[[data-slot=docs]]/body:hidden group-has-[.docs-nav]/body:sm:pb-0 dark:bg-transparent dark:group-has-[.section-soft]/body:bg-surface/40 3xl:fixed:bg-transparent">
<div className="container-wrapper px-4 xl:px-6">
<div className="flex h-(--footer-height) items-center justify-between">
<div className="w-full px-1 text-center text-xs leading-loose text-muted-foreground sm:text-sm">
Built by{" "}
<a
href={siteConfig.links.twitter}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
shadcn
</a>{" "}
at{" "}
<a
href="https://vercel.com/new?utm_source=shadcn_site&utm_medium=web&utm_campaign=docs_cta_deploy_now_callout"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Vercel
</a>
. The source code is available on{" "}
<a
href={siteConfig.links.github}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub
</a>
.
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,61 @@
import Link from "next/link"
import { PlusSignIcon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"
import { getColors } from "@/lib/colors"
import { siteConfig } from "@/lib/config"
import { source } from "@/lib/source"
import { CommandMenu } from "@/components/command-menu"
import { DesignerActions } from "@/components/designer-actions"
import { GitHubLink } from "@/components/github-link"
import { MainNav } from "@/components/main-nav"
import { MobileNav } from "@/components/mobile-nav"
import { ModeSwitcher } from "@/components/mode-switcher"
import { Separator } from "@/registry/new-york-v4/ui/separator"
import { Button } from "@/styles/radix-nova/ui/button"
export function SiteHeader() {
const colors = getColors()
const pageTree = source.pageTree
return (
<header className="sticky top-0 z-50 w-full bg-background">
<div className="container-wrapper px-6 group-has-data-[slot=designer]/layout:max-w-none 3xl:fixed:px-0">
<div className="flex h-(--header-height) items-center **:data-[slot=separator]:h-4! group-has-data-[slot=designer]/layout:fixed:max-w-none 3xl:fixed:container">
<MobileNav
tree={pageTree}
items={siteConfig.navItems}
className="flex lg:hidden"
/>
<MainNav items={siteConfig.navItems} className="hidden lg:flex" />
<div className="ml-auto flex items-center gap-2 md:flex-1 md:justify-end">
<div className="hidden w-full flex-1 md:flex md:w-auto md:flex-none">
<CommandMenu
tree={pageTree}
colors={colors}
navItems={siteConfig.navItems}
/>
</div>
<Separator
orientation="vertical"
className="ml-2 hidden lg:block"
/>
<GitHubLink />
<Separator orientation="vertical" />
<ModeSwitcher />
<DesignerActions />
<div className="flex items-center gap-2 group-has-data-[slot=designer]/layout:hidden">
<Separator orientation="vertical" />
<Button asChild size="sm" className="h-[31px] rounded-lg">
<Link href="/create">
<HugeiconsIcon icon={PlusSignIcon} />
New
</Link>
</Button>
</div>
</div>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,25 @@
const SHOW = false
export function TailwindIndicator({
forceMount = false,
}: {
forceMount?: boolean
}) {
if (process.env.NODE_ENV === "production" || (!SHOW && !forceMount)) {
return null
}
return (
<div
data-tailwind-indicator=""
className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white"
>
<div className="block sm:hidden">xs</div>
<div className="hidden sm:block md:hidden">sm</div>
<div className="hidden md:block lg:hidden">md</div>
<div className="hidden lg:block xl:hidden">lg</div>
<div className="hidden xl:block 2xl:hidden">xl</div>
<div className="hidden 2xl:block">2xl</div>
</div>
)
}

View File

@@ -0,0 +1,751 @@
"use client"
import * as React from "react"
import { IconCheck, IconCopy } from "@tabler/icons-react"
import { cn } from "cn"
import template from "lodash/template"
import { THEMES } from "@/lib/themes"
import { useThemeConfig } from "@/components/active-theme"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Icons } from "@/components/icons"
import {
baseColors,
baseColorsOKLCH,
type BaseColor,
} from "@/registry/_legacy-base-colors"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/registry/new-york-v4/ui/drawer"
import { Label } from "@/registry/new-york-v4/ui/label"
import { ScrollArea, ScrollBar } from "@/registry/new-york-v4/ui/scroll-area"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/new-york-v4/ui/select"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
interface BaseColorOKLCH {
light: Record<string, string>
dark: Record<string, string>
}
export function ThemeCustomizer({ className }: React.ComponentProps<"div">) {
const { activeTheme = "neutral", setActiveTheme } = useThemeConfig()
return (
<div className={cn("flex w-full items-center gap-2", className)}>
<ScrollArea className="hidden max-w-[96%] md:max-w-[600px] lg:flex lg:max-w-none">
<div className="flex items-center">
{THEMES.map((theme) => (
<Button
key={theme.name}
variant="link"
size="sm"
data-active={activeTheme === theme.name}
className="flex h-7 cursor-pointer items-center justify-center px-4 text-center text-base font-medium text-muted-foreground capitalize transition-colors hover:text-primary hover:no-underline data-[active=true]:text-primary"
onClick={() => setActiveTheme(theme.name)}
>
{theme.name === "neutral" ? "Default" : theme.name}
</Button>
))}
</div>
<ScrollBar orientation="horizontal" className="invisible" />
</ScrollArea>
<div className="flex items-center gap-2 lg:hidden">
<Label htmlFor="theme-selector" className="sr-only">
Theme
</Label>
<Select
value={activeTheme === "default" ? "neutral" : activeTheme}
onValueChange={setActiveTheme}
>
<SelectTrigger
id="theme-selector"
size="sm"
className="justify-start capitalize shadow-none *:data-[slot=select-value]:w-12 *:data-[slot=select-value]:capitalize"
>
<span className="font-medium">Theme:</span>
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent align="end">
<SelectGroup>
{THEMES.map((theme) => (
<SelectItem
key={theme.name}
value={theme.name}
className="capitalize data-[state=checked]:opacity-50"
>
{theme.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<CopyCodeButton variant="secondary" size="sm" className="ml-auto" />
</div>
)
}
export function CopyCodeButton({
className,
...props
}: React.ComponentProps<typeof Button>) {
let { activeTheme: activeThemeName = "neutral" } = useThemeConfig()
activeThemeName = activeThemeName === "default" ? "neutral" : activeThemeName
return (
<>
<Drawer>
<DrawerTrigger asChild>
<Button className={cn("sm:hidden", className)} {...props}>
Copy Code
</Button>
</DrawerTrigger>
<DrawerContent className="h-auto rounded-t-2xl">
<DrawerHeader>
<DrawerTitle className="capitalize">{activeThemeName}</DrawerTitle>
<DrawerDescription>
Copy and paste the following code into your CSS file.
</DrawerDescription>
</DrawerHeader>
<CustomizerCode themeName={activeThemeName} />
</DrawerContent>
</Drawer>
<Dialog>
<DialogTrigger asChild>
<Button
data-size={props.size}
className={cn("group/button hidden sm:flex", className)}
{...props}
>
<IconCopy />
<span className="group-data-[size=icon-sm]/button:sr-only">
Copy Code
</span>
</Button>
</DialogTrigger>
<DialogContent className="rounded-2xl border-none bg-clip-padding shadow-2xl ring-4 ring-neutral-200/80 outline-none md:max-w-2xl dark:bg-neutral-800 dark:ring-neutral-900">
<DialogHeader>
<DialogTitle className="capitalize">{activeThemeName}</DialogTitle>
<DialogDescription>
Copy and paste the following code into your CSS file.
</DialogDescription>
</DialogHeader>
<CustomizerCode themeName={activeThemeName} />
</DialogContent>
</Dialog>
</>
)
}
function CustomizerCode({ themeName }: { themeName: string }) {
const [hasCopied, setHasCopied] = React.useState(false)
const [tailwindVersion, setTailwindVersion] = React.useState("v4-oklch")
const activeTheme = React.useMemo(
() => baseColors.find((theme) => theme.name === themeName),
[themeName]
)
const activeThemeOKLCH = React.useMemo(
() => baseColorsOKLCH[themeName as keyof typeof baseColorsOKLCH],
[themeName]
)
React.useEffect(() => {
if (hasCopied) {
setTimeout(() => {
setHasCopied(false)
}, 2000)
}
}, [hasCopied])
return (
<>
<Tabs
value={tailwindVersion}
onValueChange={setTailwindVersion}
className="min-w-0 px-4 pb-4 md:p-0"
>
<TabsList>
<TabsTrigger value="v4-oklch">OKLCH</TabsTrigger>
<TabsTrigger value="v4-hsl">HSL</TabsTrigger>
<TabsTrigger value="v3">Tailwind v3</TabsTrigger>
</TabsList>
<TabsContent value="v4-oklch">
<figure
data-rehype-pretty-code-figure
className="mx-0! mt-0 rounded-xl"
>
<figcaption
className="flex items-center gap-2 text-code-foreground [&_svg]:size-4 [&_svg]:text-code-foreground [&_svg]:opacity-70"
data-rehype-pretty-code-title=""
data-language="css"
data-theme="github-dark github-light-default"
>
<Icons.css className="fill-foreground" />
app/globals.css
</figcaption>
<pre className="no-scrollbar max-h-[300px] min-w-0 overflow-x-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0 md:max-h-[450px]">
<Button
data-slot="copy-button"
size="icon"
variant="ghost"
className="absolute top-3 right-2 z-10 size-7 bg-code text-code-foreground shadow-none hover:opacity-100 focus-visible:opacity-100"
onClick={() => {
copyToClipboardWithMeta(
getThemeCodeOKLCH(activeThemeOKLCH, 0.65),
{
name: "copy_theme_code",
properties: {
theme: themeName,
radius: 0.65,
},
}
)
setHasCopied(true)
}}
>
<span className="sr-only">Copy</span>
{hasCopied ? <IconCheck /> : <IconCopy />}
</Button>
<code data-line-numbers data-language="css">
<span data-line className="line text-code-foreground">
&nbsp;:root &#123;
</span>
<span data-line className="line text-code-foreground">
&nbsp;&nbsp;&nbsp;--radius: 0.65rem;
</span>
{Object.entries(activeThemeOKLCH?.light).map(([key, value]) => (
<span
data-line
className="line text-code-foreground"
key={key}
>
&nbsp;&nbsp;&nbsp;--{key}: <ColorIndicator color={value} />{" "}
{value};
</span>
))}
<span data-line className="line text-code-foreground">
&nbsp;&#125;
</span>
<span data-line className="line text-code-foreground">
&nbsp;
</span>
<span data-line className="line text-code-foreground">
&nbsp;.dark &#123;
</span>
{Object.entries(activeThemeOKLCH?.dark).map(([key, value]) => (
<span
data-line
className="line text-code-foreground"
key={key}
>
&nbsp;&nbsp;&nbsp;--{key}: <ColorIndicator color={value} />{" "}
{value};
</span>
))}
<span data-line className="line text-code-foreground">
&nbsp;&#125;
</span>
</code>
</pre>
</figure>
</TabsContent>
<TabsContent value="v4-hsl">
<figure
data-rehype-pretty-code-figure
className="mx-0! mt-0 rounded-xl"
>
<figcaption
className="flex items-center gap-2 text-code-foreground [&_svg]:size-4 [&_svg]:text-code-foreground [&_svg]:opacity-70"
data-rehype-pretty-code-title=""
data-language="css"
data-theme="github-dark github-light-default"
>
<Icons.css className="fill-foreground" />
app/globals.css
</figcaption>
<pre className="no-scrollbar max-h-[300px] min-w-0 overflow-x-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0 md:max-h-[450px]">
<Button
data-slot="copy-button"
size="icon"
variant="ghost"
className="absolute top-3 right-2 z-10 size-7 bg-code text-code-foreground shadow-none hover:opacity-100 focus-visible:opacity-100"
onClick={() => {
copyToClipboardWithMeta(
getThemeCodeHSLV4(activeTheme, 0.65),
{
name: "copy_theme_code",
properties: {
theme: themeName,
radius: 0.65,
},
}
)
setHasCopied(true)
}}
>
<span className="sr-only">Copy</span>
{hasCopied ? <IconCheck /> : <IconCopy />}
</Button>
<code data-line-numbers data-language="css">
<span data-line className="line text-code-foreground">
&nbsp;:root &#123;
</span>
<span data-line className="line text-code-foreground">
&nbsp;&nbsp;&nbsp;--radius: 0.65rem;
</span>
{Object.entries(activeTheme?.cssVars.light || {}).map(
([key, value]) => (
<span
data-line
className="line text-code-foreground"
key={key}
>
&nbsp;&nbsp;&nbsp;--{key}:{" "}
<ColorIndicator color={`hsl(${value})`} /> hsl({value});
</span>
)
)}
<span data-line className="line text-code-foreground">
&nbsp;&#125;
</span>
<span data-line className="line text-code-foreground">
&nbsp;
</span>
<span data-line className="line text-code-foreground">
&nbsp;.dark &#123;
</span>
{Object.entries(activeTheme?.cssVars.dark || {}).map(
([key, value]) => (
<span
data-line
className="line text-code-foreground"
key={key}
>
&nbsp;&nbsp;&nbsp;--{key}:{" "}
<ColorIndicator color={`hsl(${value})`} /> hsl({value});
</span>
)
)}
<span data-line className="line text-code-foreground">
&nbsp;&#125;
</span>
</code>
</pre>
</figure>
</TabsContent>
<TabsContent value="v3">
<figure
data-rehype-pretty-code-figure
className="mx-0! mt-0 rounded-xl"
>
<figcaption
className="flex items-center gap-2 text-code-foreground [&_svg]:size-4 [&_svg]:text-code-foreground [&_svg]:opacity-70"
data-rehype-pretty-code-title=""
data-language="css"
data-theme="github-dark github-light-default"
>
<Icons.css className="fill-foreground" />
app/globals.css
</figcaption>
<pre className="no-scrollbar max-h-[300px] min-w-0 overflow-x-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0 md:max-h-[450px]">
<Button
data-slot="copy-button"
size="icon"
variant="ghost"
className="absolute top-3 right-2 z-10 size-7 bg-code text-code-foreground shadow-none hover:opacity-100 focus-visible:opacity-100"
onClick={() => {
copyToClipboardWithMeta(getThemeCode(activeTheme, 0.5), {
name: "copy_theme_code",
properties: {
theme: themeName,
radius: 0.5,
},
})
setHasCopied(true)
}}
>
<span className="sr-only">Copy</span>
{hasCopied ? <IconCheck /> : <IconCopy />}
</Button>
<code data-line-numbers data-language="css">
<span data-line className="line">
@layer base &#123;
</span>
<span data-line className="line">
&nbsp;&nbsp;:root &#123;
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--background:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.light["background"]})`}
/>{" "}
{activeTheme?.cssVars.light["background"]};
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--foreground:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.light["foreground"]})`}
/>{" "}
{activeTheme?.cssVars.light["foreground"]};
</span>
{[
"card",
"popover",
"primary",
"secondary",
"muted",
"accent",
"destructive",
].map((prefix) => (
<React.Fragment key={prefix}>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--{prefix}:{" "}
<ColorIndicator
color={`hsl(${
activeTheme?.cssVars.light[
prefix as keyof typeof activeTheme.cssVars.light
]
})`}
/>{" "}
{
activeTheme?.cssVars.light[
prefix as keyof typeof activeTheme.cssVars.light
]
}
;
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--{prefix}-foreground:{" "}
<ColorIndicator
color={`hsl(${
activeTheme?.cssVars.light[
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.light
]
})`}
/>{" "}
{
activeTheme?.cssVars.light[
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.light
]
}
;
</span>
</React.Fragment>
))}
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--border:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.light["border"]})`}
/>{" "}
{activeTheme?.cssVars.light["border"]};
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--input:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.light["input"]})`}
/>{" "}
{activeTheme?.cssVars.light["input"]};
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--ring:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.light["ring"]})`}
/>{" "}
{activeTheme?.cssVars.light["ring"]};
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--radius: 0.5rem;
</span>
{["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"].map(
(prefix) => (
<React.Fragment key={prefix}>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--{prefix}:{" "}
<ColorIndicator
color={`hsl(${
activeTheme?.cssVars.light[
prefix as keyof typeof activeTheme.cssVars.light
]
})`}
/>{" "}
{
activeTheme?.cssVars.light[
prefix as keyof typeof activeTheme.cssVars.light
]
}
;
</span>
</React.Fragment>
)
)}
<span data-line className="line">
&nbsp;&nbsp;&#125;
</span>
<span data-line className="line">
&nbsp;
</span>
<span data-line className="line">
&nbsp;&nbsp;.dark &#123;
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--background:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.dark["background"]})`}
/>{" "}
{activeTheme?.cssVars.dark["background"]};
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--foreground:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.dark["foreground"]})`}
/>{" "}
{activeTheme?.cssVars.dark["foreground"]};
</span>
{[
"card",
"popover",
"primary",
"secondary",
"muted",
"accent",
"destructive",
].map((prefix) => (
<React.Fragment key={prefix}>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--{prefix}:{" "}
<ColorIndicator
color={`hsl(${
activeTheme?.cssVars.dark[
prefix as keyof typeof activeTheme.cssVars.dark
]
})`}
/>{" "}
{
activeTheme?.cssVars.dark[
prefix as keyof typeof activeTheme.cssVars.dark
]
}
;
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--{prefix}-foreground:{" "}
<ColorIndicator
color={`hsl(${
activeTheme?.cssVars.dark[
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.dark
]
})`}
/>{" "}
{
activeTheme?.cssVars.dark[
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.dark
]
}
;
</span>
</React.Fragment>
))}
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--border:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.dark["border"]})`}
/>{" "}
{activeTheme?.cssVars.dark["border"]};
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--input:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.dark["input"]})`}
/>{" "}
{activeTheme?.cssVars.dark["input"]};
</span>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--ring:{" "}
<ColorIndicator
color={`hsl(${activeTheme?.cssVars.dark["ring"]})`}
/>{" "}
{activeTheme?.cssVars.dark["ring"]};
</span>
{["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"].map(
(prefix) => (
<React.Fragment key={prefix}>
<span data-line className="line">
&nbsp;&nbsp;&nbsp;&nbsp;--{prefix}:{" "}
<ColorIndicator
color={`hsl(${
activeTheme?.cssVars.dark[
prefix as keyof typeof activeTheme.cssVars.dark
]
})`}
/>{" "}
{
activeTheme?.cssVars.dark[
prefix as keyof typeof activeTheme.cssVars.dark
]
}
;
</span>
</React.Fragment>
)
)}
<span data-line className="line">
&nbsp;&nbsp;&#125;
</span>
<span data-line className="line">
&#125;
</span>
</code>
</pre>
</figure>
</TabsContent>
</Tabs>
</>
)
}
function ColorIndicator({ color }: { color: string }) {
return (
<span
className="inline-block size-3 border border-border/50"
style={{ backgroundColor: color }}
/>
)
}
function getThemeCodeOKLCH(theme: BaseColorOKLCH | undefined, radius: number) {
if (!theme) {
return ""
}
const rootSection =
":root {\n --radius: " +
radius +
"rem;\n" +
Object.entries(theme.light)
.map((entry) => " --" + entry[0] + ": " + entry[1] + ";")
.join("\n") +
"\n}\n\n.dark {\n" +
Object.entries(theme.dark)
.map((entry) => " --" + entry[0] + ": " + entry[1] + ";")
.join("\n") +
"\n}\n"
return rootSection
}
function getThemeCode(theme: BaseColor | undefined, radius: number) {
if (!theme) {
return ""
}
return template(BASE_STYLES_WITH_VARIABLES)({
colors: theme.cssVars,
radius: radius.toString(),
})
}
function getThemeCodeHSLV4(theme: BaseColor | undefined, radius: number) {
if (!theme) {
return ""
}
const rootSection =
":root {\n --radius: " +
radius +
"rem;\n" +
Object.entries(theme.cssVars.light)
.map((entry) => " --" + entry[0] + ": hsl(" + entry[1] + ");")
.join("\n") +
"\n}\n\n.dark {\n" +
Object.entries(theme.cssVars.dark)
.map((entry) => " --" + entry[0] + ": hsl(" + entry[1] + ");")
.join("\n") +
"\n}\n"
return rootSection
}
const BASE_STYLES_WITH_VARIABLES = `
@layer base {
:root {
--background: <%- colors.light["background"] %>;
--foreground: <%- colors.light["foreground"] %>;
--card: <%- colors.light["card"] %>;
--card-foreground: <%- colors.light["card-foreground"] %>;
--popover: <%- colors.light["popover"] %>;
--popover-foreground: <%- colors.light["popover-foreground"] %>;
--primary: <%- colors.light["primary"] %>;
--primary-foreground: <%- colors.light["primary-foreground"] %>;
--secondary: <%- colors.light["secondary"] %>;
--secondary-foreground: <%- colors.light["secondary-foreground"] %>;
--muted: <%- colors.light["muted"] %>;
--muted-foreground: <%- colors.light["muted-foreground"] %>;
--accent: <%- colors.light["accent"] %>;
--accent-foreground: <%- colors.light["accent-foreground"] %>;
--destructive: <%- colors.light["destructive"] %>;
--destructive-foreground: <%- colors.light["destructive-foreground"] %>;
--border: <%- colors.light["border"] %>;
--input: <%- colors.light["input"] %>;
--ring: <%- colors.light["ring"] %>;
--radius: <%- radius %>rem;
--chart-1: <%- colors.light["chart-1"] %>;
--chart-2: <%- colors.light["chart-2"] %>;
--chart-3: <%- colors.light["chart-3"] %>;
--chart-4: <%- colors.light["chart-4"] %>;
--chart-5: <%- colors.light["chart-5"] %>;
}
.dark {
--background: <%- colors.dark["background"] %>;
--foreground: <%- colors.dark["foreground"] %>;
--card: <%- colors.dark["card"] %>;
--card-foreground: <%- colors.dark["card-foreground"] %>;
--popover: <%- colors.dark["popover"] %>;
--popover-foreground: <%- colors.dark["popover-foreground"] %>;
--primary: <%- colors.dark["primary"] %>;
--primary-foreground: <%- colors.dark["primary-foreground"] %>;
--secondary: <%- colors.dark["secondary"] %>;
--secondary-foreground: <%- colors.dark["secondary-foreground"] %>;
--muted: <%- colors.dark["muted"] %>;
--muted-foreground: <%- colors.dark["muted-foreground"] %>;
--accent: <%- colors.dark["accent"] %>;
--accent-foreground: <%- colors.dark["accent-foreground"] %>;
--destructive: <%- colors.dark["destructive"] %>;
--destructive-foreground: <%- colors.dark["destructive-foreground"] %>;
--border: <%- colors.dark["border"] %>;
--input: <%- colors.dark["input"] %>;
--ring: <%- colors.dark["ring"] %>;
--chart-1: <%- colors.dark["chart-1"] %>;
--chart-2: <%- colors.dark["chart-2"] %>;
--chart-3: <%- colors.dark["chart-3"] %>;
--chart-4: <%- colors.dark["chart-4"] %>;
--chart-5: <%- colors.dark["chart-5"] %>;
}
}
`

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
function ThemeShortcut() {
const { setTheme, resolvedTheme } = useTheme()
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (
(e.key === "d" || e.key === "D") &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return
}
e.preventDefault()
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [resolvedTheme, setTheme])
return null
}
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
enableColorScheme
{...props}
>
<ThemeShortcut />
{children}
</NextThemesProvider>
)
}

View File

@@ -0,0 +1,65 @@
"use client"
import { cn } from "cn"
import { THEMES } from "@/lib/themes"
import { useThemeConfig } from "@/components/active-theme"
import { Label } from "@/styles/base-nova/ui/label"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/styles/base-nova/ui/select"
import { CopyCodeButton } from "./theme-customizer"
export function ThemeSelector({ className }: React.ComponentProps<"div">) {
const { activeTheme, setActiveTheme } = useThemeConfig()
const value = activeTheme === "default" ? "neutral" : activeTheme
const items = THEMES.map((theme) => ({
label: theme.label,
value: theme.name,
}))
return (
<div className={cn("flex items-center gap-2", className)}>
<Label htmlFor="theme-selector" className="sr-only">
Theme
</Label>
<Select
items={items}
value={value}
onValueChange={(value) => value && setActiveTheme(value)}
>
<SelectTrigger id="theme-selector" className="w-36">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Theme</SelectLabel>
{items.map((item) => (
<SelectItem
key={item.value}
value={item.value}
className="data-[state=checked]:opacity-50"
>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<CopyCodeButton
variant="secondary"
size="icon-sm"
className="rounded-lg border bg-transparent"
/>
</div>
)
}