snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user