mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-27 22:54:18 +00:00
514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import Image from "next/image"
|
|
import Link from "next/link"
|
|
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 { cn } from "@/lib/utils"
|
|
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-v4/${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-v4/${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 }
|