mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-11 09:51:40 +00:00
Merge branch 'main' of github.com:shadcn-ui/ui
This commit is contained in:
@@ -6,7 +6,7 @@ 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 "@/registry/new-york-v4/ui/button"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/registry/new-york-v4/ui/dialog"
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -25,42 +24,74 @@ import {
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/registry/new-york-v4/ui/drawer"
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/registry/new-york-v4/ui/tabs"
|
||||
} from "@/styles/base-nova/ui/tabs"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/registry/new-york-v4/ui/tooltip"
|
||||
} 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 [open, setOpen] = 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 ${registry.name}`,
|
||||
npm: `npx shadcn@latest registry add ${registry.name}`,
|
||||
yarn: `yarn dlx shadcn@latest registry add ${registry.name}`,
|
||||
bun: `bunx --bun shadcn@latest registry add ${registry.name}`,
|
||||
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}`,
|
||||
}
|
||||
}, [registry.name])
|
||||
}, [selectedRegistry])
|
||||
|
||||
const command = commands[packageManager]
|
||||
const command = commands?.[packageManager] ?? ""
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasCopied) {
|
||||
@@ -74,19 +105,23 @@ export function DirectoryAddButton({
|
||||
name: "copy_registry_add_command",
|
||||
properties: {
|
||||
command,
|
||||
registry: registry.name,
|
||||
registry: selectedRegistry?.name ?? "",
|
||||
},
|
||||
})
|
||||
setHasCopied(true)
|
||||
}, [command, registry.name])
|
||||
}, [command, selectedRegistry?.name])
|
||||
|
||||
const Trigger = (
|
||||
<Button size="sm" variant="outline" className="relative z-10">
|
||||
Add <IconPlus />
|
||||
</Button>
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
open: (registry: { name: string }) => {
|
||||
setSelectedRegistry(registry)
|
||||
setIsOpen(true)
|
||||
},
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const Content = (
|
||||
const Content = commands ? (
|
||||
<Tabs
|
||||
value={packageManager}
|
||||
onValueChange={(value) => {
|
||||
@@ -95,30 +130,28 @@ export function DirectoryAddButton({
|
||||
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
|
||||
})
|
||||
}}
|
||||
className="gap-0 overflow-hidden rounded-lg border"
|
||||
className="gap-0 overflow-hidden rounded-xl border"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b p-2">
|
||||
<TabsList className="h-auto rounded-none bg-transparent p-0 font-mono *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none! *:data-[slot=tabs-trigger]:data-[state=active]:border-input">
|
||||
<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 asChild>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="ml-auto size-7 rounded-lg"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<IconCheck className="size-4" />
|
||||
) : (
|
||||
<IconCopy className="size-4" />
|
||||
)}
|
||||
<span className="sr-only">Copy command</span>
|
||||
</Button>
|
||||
<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"}
|
||||
@@ -135,47 +168,48 @@ export function DirectoryAddButton({
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Add Registry</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Run this command to add {registry.name} to your project.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">{Content}</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
||||
<DialogContent className="dialog-ring animate-none! rounded-xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Registry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Run this command to add {registry.name} to your project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{Content}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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 asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconArrowUpRight } from "@tabler/icons-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
IconArrowUpRight,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useSearchRegistry } from "@/hooks/use-search-registry"
|
||||
import { DirectoryAddButton } from "@/components/directory-add-button"
|
||||
import globalRegistries from "@/registry/directory.json"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import {
|
||||
DirectoryAddButton,
|
||||
DirectoryAddProvider,
|
||||
} from "@/components/directory-add-button"
|
||||
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -17,9 +25,16 @@ import {
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@/registry/new-york-v4/ui/item"
|
||||
} 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 "./search-directory"
|
||||
import { SearchDirectory } from "./directory-search"
|
||||
|
||||
function getHomepageUrl(homepage: string) {
|
||||
const url = new URL(homepage)
|
||||
@@ -29,55 +44,308 @@ function getHomepageUrl(homepage: string) {
|
||||
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() {
|
||||
const { registries } = useSearchRegistry()
|
||||
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]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<SearchDirectory />
|
||||
<DirectoryAddProvider>
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<DirectoryListSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DirectoryAddProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectoryListSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<ItemGroup className="my-8">
|
||||
{registries.map((registry, index) => (
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Item className="group/item relative gap-6 px-0">
|
||||
<ItemMedia
|
||||
variant="image"
|
||||
dangerouslySetInnerHTML={{ __html: registry.logo }}
|
||||
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
|
||||
/>
|
||||
<Item className="relative items-start gap-6 px-0">
|
||||
<Skeleton className="size-8 rounded-lg" />
|
||||
<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>
|
||||
)}
|
||||
<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="relative z-10 hidden self-start sm:flex">
|
||||
<DirectoryAddButton registry={registry} />
|
||||
<ItemActions className="hidden self-start sm:flex">
|
||||
<Skeleton className="h-7 w-16 rounded-lg" />
|
||||
</ItemActions>
|
||||
<ItemFooter className="justify-start pl-16 sm:hidden">
|
||||
<Button size="sm" variant="outline">
|
||||
View <IconArrowUpRight />
|
||||
</Button>
|
||||
<DirectoryAddButton registry={registry} />
|
||||
<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 < globalRegistries.length - 1 && (
|
||||
<ItemSeparator className="my-1" />
|
||||
)}
|
||||
{index < 4 && <ItemSeparator className="my-1" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import * as React from "react"
|
||||
"use client"
|
||||
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { useSearchRegistry } from "@/hooks/use-search-registry"
|
||||
import { Field } from "@/registry/new-york-v4/ui/field"
|
||||
import { Field } from "@/styles/base-nova/ui/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/registry/new-york-v4/ui/input-group"
|
||||
|
||||
export const SearchDirectory = () => {
|
||||
const { query, registries, setQuery } = useSearchRegistry()
|
||||
|
||||
const onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
}
|
||||
} 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>
|
||||
@@ -25,14 +26,15 @@ export const SearchDirectory = () => {
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
className="h-full"
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-muted-foreground tabular-nums sm:text-xs">
|
||||
{registries.length}{" "}
|
||||
{registries.length === 1 ? "registry" : "registries"}
|
||||
{registriesCount}{" "}
|
||||
{registriesCount === 1 ? "registry" : "registries"}
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon
|
||||
@@ -3,14 +3,9 @@ title: Registry Directory
|
||||
description: Discover community registries for shadcn/ui components and blocks.
|
||||
---
|
||||
|
||||
import { TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
These registries are built into the CLI with no additional configuration required. To add a component, run: `npx shadcn add @<registry>/<component>`.
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
className="border-amber-200 bg-amber-50 font-semibold dark:border-amber-900 dark:bg-amber-950"
|
||||
>
|
||||
<Callout className="bg-muted font-semibold">
|
||||
Community registries are maintained by third-party developers. Always review
|
||||
code on installation to ensure it meets your security and quality standards.
|
||||
</Callout>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { debounce, useQueryState } from "nuqs"
|
||||
import { debounce, parseAsInteger, useQueryState } from "nuqs"
|
||||
|
||||
import { useMounted } from "@/hooks/use-mounted"
|
||||
import globalRegistries from "@/registry/directory.json"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const normalizeQuery = (query: string) =>
|
||||
query.toLowerCase().replaceAll(" ", "").replaceAll("@", "")
|
||||
|
||||
@@ -25,15 +28,44 @@ const searchDirectory = (query: string | null) => {
|
||||
return globalRegistries.filter((registry) => finderFn(registry, query))
|
||||
}
|
||||
|
||||
export const useSearchRegistry = () => {
|
||||
export function useSearchRegistry() {
|
||||
const mounted = useMounted()
|
||||
const [query, setQuery] = useQueryState("q", {
|
||||
defaultValue: "",
|
||||
limitUrlUpdates: debounce(250),
|
||||
})
|
||||
|
||||
const [page, setPage] = useQueryState("page", {
|
||||
...parseAsInteger,
|
||||
defaultValue: 1,
|
||||
history: "push",
|
||||
})
|
||||
|
||||
const currentQuery = mounted ? query : ""
|
||||
const currentPageValue = mounted ? page : 1
|
||||
|
||||
const registries = searchDirectory(currentQuery)
|
||||
const totalPages = Math.ceil(registries.length / PAGE_SIZE)
|
||||
|
||||
// Clamp page to valid range.
|
||||
const currentPage = Math.max(1, Math.min(currentPageValue, totalPages))
|
||||
|
||||
const paginatedRegistries = registries.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
registries: searchDirectory(query),
|
||||
setQuery,
|
||||
isLoading: !mounted,
|
||||
query: currentQuery,
|
||||
setQuery: (value: string | null) => {
|
||||
setQuery(value)
|
||||
setPage(null)
|
||||
},
|
||||
registries,
|
||||
paginatedRegistries,
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
setPage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,6 +1029,6 @@
|
||||
"name": "@termcn",
|
||||
"homepage": "https://termcn.vercel.app",
|
||||
"url": "https://termcn.vercel.app/r/{name}.json",
|
||||
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React.",
|
||||
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React."
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user