Files
shadcn-ui/apps/www/components/block-viewer.tsx
shadcn 254198b4bf feat: add shadcn/registry (#6339)
* feat: implement shadcn/registry

* feat: add schema field

* fix: import

* chore: add changeset

* chore: remove console

* fix: tests

* fix: diff command

* feat: move to schema/registy-item.json

* fix

* ci: switch to node 20

* ci: build packages
2025-01-14 10:50:19 +04:00

455 lines
14 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,
Smartphone,
Tablet,
Terminal,
} from "lucide-react"
import { ImperativePanelHandle } from "react-resizable-panels"
import { registryItemFileSchema, registryItemSchema } from "shadcn/registry"
import { z } from "zod"
import { trackEvent } from "@/lib/events"
import { FileTree, createFileTreeForRegistryItemFiles } from "@/lib/registry"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { V0Button } from "@/components/v0-button"
import { Button } from "@/registry/new-york/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/new-york/ui/collapsible"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/registry/new-york/ui/resizable"
import { Separator } from "@/registry/new-york/ui/separator"
import {
Sidebar,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarProvider,
} from "@/registry/new-york/ui/sidebar"
import { Tabs, TabsList, TabsTrigger } from "@/registry/new-york/ui/tabs"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/new-york/ui/toggle-group"
import { Style } from "@/registry/registry-styles"
type BlockViewerContext = {
item: z.infer<typeof registryItemSchema>
view: "code" | "preview"
setView: (view: "code" | "preview") => void
style?: Style["name"]
setStyle: (style: Style["name"]) => void
activeFile: string | null
setActiveFile: (file: string) => void
resizablePanelRef: React.RefObject<ImperativePanelHandle> | null
tree: ReturnType<typeof createFileTreeForRegistryItemFiles> | null
highlightedFiles:
| (z.infer<typeof registryItemFileSchema> & {
highlightedContent: string
})[]
| null
}
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 [style, setStyle] =
React.useState<BlockViewerContext["style"]>("new-york")
const [activeFile, setActiveFile] = React.useState<
BlockViewerContext["activeFile"]
>(highlightedFiles?.[0].target ?? null)
const resizablePanelRef = React.useRef<ImperativePanelHandle>(null)
return (
<BlockViewerContext.Provider
value={{
item,
view,
setView,
style,
setStyle,
resizablePanelRef,
activeFile,
setActiveFile,
tree,
highlightedFiles,
}}
>
<div
id={item.name}
data-view={view}
className="group/block-view-wrapper flex min-w-0 flex-col items-stretch gap-4"
style={
{
"--height": item.meta?.iframeHeight ?? "930px",
} as React.CSSProperties
}
>
{children}
</div>
</BlockViewerContext.Provider>
)
}
function BlockViewerToolbar() {
const { setView, item, resizablePanelRef, style } = useBlockViewer()
const { copyToClipboard, isCopied } = useCopyToClipboard()
return (
<div className="flex w-full items-center gap-2 md:pr-[14px]">
<Tabs
defaultValue="preview"
onValueChange={(value) => setView(value as "preview" | "code")}
className="hidden lg:flex"
>
<TabsList className="h-7 items-center rounded-md p-0 px-[calc(theme(spacing.1)_-_2px)] py-[theme(spacing.1)]">
<TabsTrigger
value="preview"
className="h-[1.45rem] rounded-sm px-2 text-xs"
>
Preview
</TabsTrigger>
<TabsTrigger
value="code"
className="h-[1.45rem] rounded-sm px-2 text-xs"
>
Code
</TabsTrigger>
</TabsList>
</Tabs>
<Separator orientation="vertical" className="mx-2 hidden h-4 lg:flex" />
<a
href={`#${item.name}`}
className="text-sm font-medium underline-offset-2 hover:underline"
>
{item.description}
</a>
<div className="ml-auto hidden items-center gap-2 md:flex">
<div className="hidden h-7 items-center gap-1.5 rounded-md border p-[2px] shadow-none lg:flex">
<ToggleGroup
type="single"
defaultValue="100"
onValueChange={(value) => {
if (resizablePanelRef?.current) {
resizablePanelRef.current.resize(parseInt(value))
}
}}
>
<ToggleGroupItem
value="100"
className="h-[22px] w-[22px] min-w-0 rounded-sm p-0"
title="Desktop"
>
<Monitor className="h-3.5 w-3.5" />
</ToggleGroupItem>
<ToggleGroupItem
value="60"
className="h-[22px] w-[22px] min-w-0 rounded-sm p-0"
title="Tablet"
>
<Tablet className="h-3.5 w-3.5" />
</ToggleGroupItem>
<ToggleGroupItem
value="30"
className="h-[22px] w-[22px] min-w-0 rounded-sm p-0"
title="Mobile"
>
<Smartphone className="h-3.5 w-3.5" />
</ToggleGroupItem>
<Separator orientation="vertical" className="h-4" />
<Button
size="icon"
variant="ghost"
className="h-[22px] w-[22px] rounded-sm p-0"
asChild
title="Open in New Tab"
>
<Link href={`/view/styles/${style}/${item.name}`} target="_blank">
<span className="sr-only">Open in New Tab</span>
<Fullscreen className="h-3.5 w-3.5" />
</Link>
</Button>
</ToggleGroup>
</div>
<Separator orientation="vertical" className="mx-1 hidden h-4 md:flex" />
<div className="flex h-7 items-center gap-1 rounded-md border p-[2px]">
<Button
variant="ghost"
className="hidden h-[22px] w-auto gap-1 rounded-sm px-2 md:flex lg:w-auto"
size="sm"
onClick={() => {
copyToClipboard(`npx shadcn@latest add ${item.name}`)
}}
>
{isCopied ? <Check /> : <Terminal />}
<span className="hidden lg:inline">npx shadcn add {item.name}</span>
</Button>
</div>
<Separator orientation="vertical" className="mx-1 hidden h-4 xl:flex" />
<V0Button
className="hidden shadow-none sm:flex"
id={`v0-button-${item.name}`}
name={`${item.name}`}
/>
</div>
</div>
)
}
function BlockViewerView() {
const { item, style, resizablePanelRef } = useBlockViewer()
return (
<div className="group-data-[view=code]/block-view-wrapper:hidden md:h-[--height]">
<div className="grid w-full gap-4">
<ResizablePanelGroup direction="horizontal" className="relative z-10">
<ResizablePanel
ref={resizablePanelRef}
className="relative aspect-[4/2.5] rounded-xl border bg-background md:aspect-auto"
defaultSize={100}
minSize={30}
>
<Image
src={`/r/styles/${style}/${item.name}-light.png`}
alt={item.name}
data-block={item.name}
width={1440}
height={900}
className="object-cover dark:hidden md:hidden md:dark:hidden"
/>
<Image
src={`/r/styles/${style}/${item.name}-dark.png`}
alt={item.name}
data-block={item.name}
width={1440}
height={900}
className="hidden object-cover dark:block md:hidden md:dark:hidden"
/>
<iframe
src={`/view/styles/${style}/${item.name}`}
height={item.meta?.iframeHeight ?? 930}
className="relative z-20 hidden w-full bg-background md:block"
/>
</ResizablePanel>
<ResizableHandle className="relative hidden w-3 bg-transparent p-0 after:absolute after:right-0 after:top-1/2 after:h-8 after:w-[6px] after:-translate-y-1/2 after:translate-x-[-1px] after:rounded-full after:bg-border after:transition-all after:hover:h-10 md:block" />
<ResizablePanel defaultSize={0} minSize={0} />
</ResizablePanelGroup>
</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
}
return (
<div className="mr-[14px] flex overflow-hidden rounded-xl bg-zinc-950 text-white group-data-[view=preview]/block-view-wrapper:hidden md:h-[--height]">
<div className="w-[280px]">
<BlockViewerFileTree />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex h-12 items-center gap-2 border-b border-zinc-700 bg-zinc-900 px-4 text-sm font-medium">
<File className="size-4" />
{file.target}
<div className="ml-auto flex items-center gap-2">
<BlockCopyCodeButton />
</div>
</div>
<div
key={file?.path}
data-rehype-pretty-code-fragment
dangerouslySetInnerHTML={{ __html: file?.highlightedContent ?? "" }}
className="relative flex-1 overflow-hidden after:absolute after:inset-y-0 after:left-0 after:w-10 after:bg-zinc-950 [&_.line:before]:sticky [&_.line:before]:left-2 [&_.line:before]:z-10 [&_.line:before]:translate-y-[-1px] [&_.line:before]:pr-1 [&_pre]:h-[--height] [&_pre]:overflow-auto [&_pre]:!bg-transparent [&_pre]:pb-20 [&_pre]:pt-4 [&_pre]:font-mono [&_pre]:text-sm [&_pre]:leading-relaxed"
/>
</div>
</div>
)
}
export function BlockViewerFileTree() {
const { tree } = useBlockViewer()
if (!tree) {
return null
}
return (
<SidebarProvider className="flex !min-h-full flex-col">
<Sidebar
collapsible="none"
className="w-full flex-1 border-r border-zinc-700 bg-zinc-900 text-white"
>
<SidebarGroupLabel className="h-12 rounded-none border-b border-zinc-700 px-4 text-sm text-white">
Files
</SidebarGroupLabel>
<SidebarGroup className="p-0">
<SidebarGroupContent>
<SidebarMenu className="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="whitespace-nowrap rounded-none pl-[--index] hover:bg-zinc-700 hover:text-white focus:bg-zinc-700 focus:text-white focus-visible:bg-zinc-700 focus-visible:text-white active:bg-zinc-700 active:text-white data-[active=true]:bg-zinc-700 data-[active=true]:text-white"
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="whitespace-nowrap rounded-none pl-[--index] hover:bg-zinc-700 hover:text-white focus-visible:bg-zinc-700 focus-visible:text-white active:bg-zinc-700 active:text-white data-[active=true]:bg-zinc-700 data-[active=true]:text-white data-[state=open]:hover:bg-zinc-700 data-[state=open]:hover:text-white"
style={
{
"--index": `${index * (index === 1 ? 1 : 1.2)}rem`,
} as React.CSSProperties
}
>
<ChevronRight className="h-4 w-4 transition-transform" />
<Folder className="h-4 w-4" />
{item.name}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub className="m-0 w-full 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
onClick={() => {
copyToClipboard(content)
trackEvent({
name: "copy_block_code",
properties: {
name: item.name,
file: file.path,
},
})
}}
className="h-7 w-7 shrink-0 rounded-lg p-0 hover:bg-zinc-700 hover:text-white focus:bg-zinc-700 focus:text-white focus-visible:bg-zinc-700 focus-visible:text-white active:bg-zinc-700 active:text-white data-[active=true]:bg-zinc-700 data-[active=true]:text-white [&>svg]:size-3"
variant="ghost"
>
{isCopied ? <Check /> : <Clipboard />}
</Button>
)
}
function BlockViewer({
item,
tree,
highlightedFiles,
...props
}: Pick<BlockViewerContext, "item" | "tree" | "highlightedFiles">) {
return (
<BlockViewerProvider
item={item}
tree={tree}
highlightedFiles={highlightedFiles}
{...props}
>
<BlockViewerToolbar />
<BlockViewerView />
<BlockViewerCode />
</BlockViewerProvider>
)
}
export { BlockViewer }