snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
56
apps/v4/components/active-theme.tsx
Normal file
56
apps/v4/components/active-theme.tsx
Normal 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
|
||||
}
|
||||
7
apps/v4/components/analytics.tsx
Normal file
7
apps/v4/components/analytics.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
|
||||
|
||||
export function Analytics() {
|
||||
return <VercelAnalytics />
|
||||
}
|
||||
14
apps/v4/components/announcement.tsx
Normal file
14
apps/v4/components/announcement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
apps/v4/components/block-display.tsx
Normal file
78
apps/v4/components/block-display.tsx
Normal 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 ?? ""),
|
||||
}))
|
||||
)
|
||||
}
|
||||
)
|
||||
35
apps/v4/components/block-image.tsx
Normal file
35
apps/v4/components/block-image.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
513
apps/v4/components/block-viewer.tsx
Normal file
513
apps/v4/components/block-viewer.tsx
Normal 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 }
|
||||
55
apps/v4/components/blocks-nav.tsx
Normal file
55
apps/v4/components/blocks-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
apps/v4/components/callout.tsx
Normal file
36
apps/v4/components/callout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
apps/v4/components/cards/activity-goal.tsx
Normal file
132
apps/v4/components/cards/activity-goal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
apps/v4/components/cards/calendar.tsx
Normal file
26
apps/v4/components/cards/calendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
273
apps/v4/components/cards/chat.tsx
Normal file
273
apps/v4/components/cards/chat.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
43
apps/v4/components/cards/cookie-settings.tsx
Normal file
43
apps/v4/components/cards/cookie-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
apps/v4/components/cards/create-account.tsx
Normal file
72
apps/v4/components/cards/create-account.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
apps/v4/components/cards/exercise-minutes.tsx
Normal file
129
apps/v4/components/cards/exercise-minutes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
apps/v4/components/cards/forms.tsx
Normal file
140
apps/v4/components/cards/forms.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
apps/v4/components/cards/index.tsx
Normal file
59
apps/v4/components/cards/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
apps/v4/components/cards/payment-method.tsx
Normal file
134
apps/v4/components/cards/payment-method.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
apps/v4/components/cards/report-issue.tsx
Normal file
100
apps/v4/components/cards/report-issue.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/v4/components/cards/share.tsx
Normal file
117
apps/v4/components/cards/share.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
apps/v4/components/cards/stats.tsx
Normal file
132
apps/v4/components/cards/stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
apps/v4/components/cards/team-members.tsx
Normal file
142
apps/v4/components/cards/team-members.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
apps/v4/components/chart-code-viewer.tsx
Normal file
120
apps/v4/components/chart-code-viewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
apps/v4/components/chart-copy-button.tsx
Normal file
63
apps/v4/components/chart-copy-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
apps/v4/components/chart-display.tsx
Normal file
55
apps/v4/components/chart-display.tsx
Normal 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)
|
||||
})
|
||||
30
apps/v4/components/chart-iframe.tsx
Normal file
30
apps/v4/components/chart-iframe.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
107
apps/v4/components/chart-toolbar.tsx
Normal file
107
apps/v4/components/chart-toolbar.tsx
Normal 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
|
||||
}
|
||||
67
apps/v4/components/charts-nav.tsx
Normal file
67
apps/v4/components/charts-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
apps/v4/components/code-block-command.tsx
Normal file
128
apps/v4/components/code-block-command.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
apps/v4/components/code-collapsible-wrapper.tsx
Normal file
51
apps/v4/components/code-collapsible-wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
apps/v4/components/code-tabs.tsx
Normal file
26
apps/v4/components/code-tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
apps/v4/components/color-format-selector.tsx
Normal file
71
apps/v4/components/color-format-selector.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
24
apps/v4/components/color-palette.tsx
Normal file
24
apps/v4/components/color-palette.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
apps/v4/components/color.tsx
Normal file
56
apps/v4/components/color.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
apps/v4/components/colors-nav.tsx
Normal file
41
apps/v4/components/colors-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
642
apps/v4/components/command-menu.tsx
Normal file
642
apps/v4/components/command-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
apps/v4/components/component-preview-tabs.tsx
Normal file
283
apps/v4/components/component-preview-tabs.tsx
Normal 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'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>
|
||||
}
|
||||
130
apps/v4/components/component-preview.tsx
Normal file
130
apps/v4/components/component-preview.tsx
Normal 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
|
||||
}
|
||||
117
apps/v4/components/component-source.tsx
Normal file
117
apps/v4/components/component-source.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
apps/v4/components/component-wrapper.tsx
Normal file
65
apps/v4/components/component-wrapper.tsx
Normal 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())
|
||||
}
|
||||
69
apps/v4/components/components-list.tsx
Normal file
69
apps/v4/components/components-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
apps/v4/components/copy-button.tsx
Normal file
121
apps/v4/components/copy-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
apps/v4/components/designer-actions.tsx
Normal file
39
apps/v4/components/designer-actions.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
213
apps/v4/components/directory-add-button.tsx
Normal file
213
apps/v4/components/directory-add-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
359
apps/v4/components/directory-list.tsx
Normal file
359
apps/v4/components/directory-list.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
57
apps/v4/components/directory-search.tsx
Normal file
57
apps/v4/components/directory-search.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
apps/v4/components/docs-base-switcher.tsx
Normal file
49
apps/v4/components/docs-base-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
apps/v4/components/docs-breadcrumb.tsx
Normal file
65
apps/v4/components/docs-breadcrumb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
223
apps/v4/components/docs-copy-page.tsx
Normal file
223
apps/v4/components/docs-copy-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
apps/v4/components/docs-page-links.tsx
Normal file
179
apps/v4/components/docs-page-links.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
273
apps/v4/components/docs-sidebar.tsx
Normal file
273
apps/v4/components/docs-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
apps/v4/components/docs-toc.tsx
Normal file
126
apps/v4/components/docs-toc.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
apps/v4/components/examples-nav.tsx
Normal file
94
apps/v4/components/examples-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
apps/v4/components/github-link.tsx
Normal file
38
apps/v4/components/github-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
apps/v4/components/icons.tsx
Normal file
207
apps/v4/components/icons.tsx
Normal 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 />
|
||||
}
|
||||
}
|
||||
125
apps/v4/components/language-selector.tsx
Normal file
125
apps/v4/components/language-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
apps/v4/components/main-nav.tsx
Normal file
41
apps/v4/components/main-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
apps/v4/components/markdown.tsx
Normal file
27
apps/v4/components/markdown.tsx
Normal 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 }
|
||||
186
apps/v4/components/message-animated.tsx
Normal file
186
apps/v4/components/message-animated.tsx
Normal 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 }
|
||||
645
apps/v4/components/message-parts.tsx
Normal file
645
apps/v4/components/message-parts.tsx
Normal 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,
|
||||
}
|
||||
216
apps/v4/components/mobile-nav.tsx
Normal file
216
apps/v4/components/mobile-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
apps/v4/components/mode-switcher.tsx
Normal file
97
apps/v4/components/mode-switcher.tsx
Normal 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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
37
apps/v4/components/nav-header.tsx
Normal file
37
apps/v4/components/nav-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
apps/v4/components/nav-user.tsx
Normal file
114
apps/v4/components/nav-user.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
apps/v4/components/open-in-v0-button.tsx
Normal file
31
apps/v4/components/open-in-v0-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
apps/v4/components/open-in-v0-cta.tsx
Normal file
36
apps/v4/components/open-in-v0-cta.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
apps/v4/components/page-header.tsx
Normal file
61
apps/v4/components/page-header.tsx
Normal 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 }
|
||||
15
apps/v4/components/page-nav.tsx
Normal file
15
apps/v4/components/page-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
apps/v4/components/site-config.tsx
Normal file
33
apps/v4/components/site-config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
apps/v4/components/site-footer.tsx
Normal file
42
apps/v4/components/site-footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
apps/v4/components/site-header.tsx
Normal file
61
apps/v4/components/site-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/v4/components/tailwind-indicator.tsx
Normal file
25
apps/v4/components/tailwind-indicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
751
apps/v4/components/theme-customizer.tsx
Normal file
751
apps/v4/components/theme-customizer.tsx
Normal 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">
|
||||
:root {
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
--radius: 0.65rem;
|
||||
</span>
|
||||
{Object.entries(activeThemeOKLCH?.light).map(([key, value]) => (
|
||||
<span
|
||||
data-line
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}: <ColorIndicator color={value} />{" "}
|
||||
{value};
|
||||
</span>
|
||||
))}
|
||||
<span data-line className="line text-code-foreground">
|
||||
}
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
.dark {
|
||||
</span>
|
||||
{Object.entries(activeThemeOKLCH?.dark).map(([key, value]) => (
|
||||
<span
|
||||
data-line
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}: <ColorIndicator color={value} />{" "}
|
||||
{value};
|
||||
</span>
|
||||
))}
|
||||
<span data-line className="line text-code-foreground">
|
||||
}
|
||||
</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">
|
||||
:root {
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
--radius: 0.65rem;
|
||||
</span>
|
||||
{Object.entries(activeTheme?.cssVars.light || {}).map(
|
||||
([key, value]) => (
|
||||
<span
|
||||
data-line
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}:{" "}
|
||||
<ColorIndicator color={`hsl(${value})`} /> hsl({value});
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span data-line className="line text-code-foreground">
|
||||
}
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
.dark {
|
||||
</span>
|
||||
{Object.entries(activeTheme?.cssVars.dark || {}).map(
|
||||
([key, value]) => (
|
||||
<span
|
||||
data-line
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}:{" "}
|
||||
<ColorIndicator color={`hsl(${value})`} /> hsl({value});
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span data-line className="line text-code-foreground">
|
||||
}
|
||||
</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 {
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
:root {
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--background:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["background"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["background"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--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">
|
||||
--{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">
|
||||
--{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">
|
||||
--border:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["border"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["border"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--input:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["input"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["input"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--ring:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["ring"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["ring"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--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">
|
||||
--{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">
|
||||
}
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
.dark {
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--background:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["background"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["background"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--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">
|
||||
--{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">
|
||||
--{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">
|
||||
--border:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["border"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["border"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--input:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["input"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["input"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--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">
|
||||
--{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">
|
||||
}
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
}
|
||||
</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"] %>;
|
||||
}
|
||||
}
|
||||
`
|
||||
55
apps/v4/components/theme-provider.tsx
Normal file
55
apps/v4/components/theme-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
apps/v4/components/theme-selector.tsx
Normal file
65
apps/v4/components/theme-selector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user