diff --git a/apps/v4/components/directory-add-button.tsx b/apps/v4/components/directory-add-button.tsx index d5ffce5df6..e3497bf9ec 100644 --- a/apps/v4/components/directory-add-button.tsx +++ b/apps/v4/components/directory-add-button.tsx @@ -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 ( + + ) +} + +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 = ( - + const contextValue = React.useMemo( + () => ({ + open: (registry: { name: string }) => { + setSelectedRegistry(registry) + setIsOpen(true) + }, + }), + [] ) - const Content = ( + const Content = commands ? ( { @@ -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" > -
- +
+ pnpm npm yarn bun - - + + } + > + {hasCopied ? : } + Copy command {hasCopied ? "Copied!" : "Copy command"} @@ -135,47 +168,48 @@ export function DirectoryAddButton({ ))} - ) - - if (isMobile) { - return ( - - {Trigger} - - - Add Registry - - Run this command to add {registry.name} to your project. - - -
{Content}
- - - - - -
-
- ) - } + ) : null return ( - - {Trigger} - - - Add Registry - - Run this command to add {registry.name} to your project. - - - {Content} - - - - - - - + + {children} + {isMobile ? ( + + + + Add Registry + + Run this command to add {selectedRegistry?.name} to your + project. + + +
{Content}
+ + + + + +
+
+ ) : ( + + + + Add Registry + + Run this command to add {selectedRegistry?.name} to your + project. + + + {Content} + + }> + Done + + + + + )} +
) } diff --git a/apps/v4/components/directory-list.tsx b/apps/v4/components/directory-list.tsx index e84ba6113e..012a6f4583 100644 --- a/apps/v4/components/directory-list.tsx +++ b/apps/v4/components/directory-list.tsx @@ -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["size"] +} + +function DirectoryPaginationLink({ + className, + isActive, + size = "icon", + ...props +}: DirectoryPaginationLinkProps) { + return ( + + ) +} + +function DirectoryPaginationPrevious({ + className, + text = "Previous", + ...props +}: DirectoryPaginationLinkProps & { text?: string }) { + return ( + + + {text} + + ) +} + +function DirectoryPaginationNext({ + className, + text = "Next", + ...props +}: DirectoryPaginationLinkProps & { text?: string }) { + return ( + + {text} + + + ) +} + 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, + 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 ( -
- + +
+ {isLoading ? ( + + ) : ( + <> + + + {paginatedRegistries.map((registry, index) => ( + + + + + + + {registry.name}{" "} + + + + {registry.description && ( + + {registry.description} + + )} + + + + + + + + + + {index < paginatedRegistries.length - 1 && ( + + )} + + ))} + + {totalPages > 1 && ( + + + + + handlePageChange(event, page - 1, page <= 1) + } + className={cn( + page <= 1 + ? "pointer-events-none opacity-50" + : "cursor-pointer" + )} + /> + + {getPageNumbers(page, totalPages).map((p, i) => + p === "ellipsis" ? ( + + + + ) : ( + + handlePageChange(event, p)} + className="cursor-pointer" + > + {p} + + + ) + )} + + = 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" + )} + /> + + + + )} + + )} +
+ + ) +} + +function DirectoryListSkeleton() { + return ( + <> + - {registries.map((registry, index) => ( + {Array.from({ length: 5 }, (_, index) => ( - - + + - - - {registry.name}{" "} - - - - {registry.description && ( - - {registry.description} - - )} + + + - - + + - - - + + + - {index < globalRegistries.length - 1 && ( - - )} + {index < 4 && } ))} -
+ ) } diff --git a/apps/v4/components/search-directory.tsx b/apps/v4/components/directory-search.tsx similarity index 62% rename from apps/v4/components/search-directory.tsx rename to apps/v4/components/directory-search.tsx index 73b88b9346..77d51edd71 100644 --- a/apps/v4/components/search-directory.tsx +++ b/apps/v4/components/directory-search.tsx @@ -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) => { - 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 ( @@ -25,14 +26,15 @@ export const SearchDirectory = () => { setQuery(e.target.value)} /> - {registries.length}{" "} - {registries.length === 1 ? "registry" : "registries"} + {registriesCount}{" "} + {registriesCount === 1 ? "registry" : "registries"} /`. - + Community registries are maintained by third-party developers. Always review code on installation to ensure it meets your security and quality standards. diff --git a/apps/v4/hooks/use-search-registry.ts b/apps/v4/hooks/use-search-registry.ts index a008e0f0d3..13c6732fd2 100644 --- a/apps/v4/hooks/use-search-registry.ts +++ b/apps/v4/hooks/use-search-registry.ts @@ -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, } }