-
Notifications
You must be signed in to change notification settings - Fork 725
♻️ Refactor: extract reusable ResourceCard components #3862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| "use client"; | ||
|
|
||
| import { Plus } from "lucide-react"; | ||
|
|
||
| export interface CreateResourceCardProps { | ||
| title: string; | ||
| onClick: () => void; | ||
| } | ||
|
|
||
| export default function CreateResourceCard({ | ||
| title, | ||
| onClick, | ||
| }: CreateResourceCardProps) { | ||
|
Check warning on line 13 in frontend/components/resource/CreateResourceCard.tsx
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={onClick} | ||
| aria-label={title} | ||
| className="group flex h-full min-h-[240px] w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-white p-6 text-center transition hover:border-blue-400 hover:bg-slate-50/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" | ||
| > | ||
| <span className="mb-4 flex size-12 items-center justify-center rounded-full border-2 border-slate-400 text-slate-400 transition group-hover:border-blue-500 group-hover:text-blue-500"> | ||
| <Plus size={24} aria-hidden="true" /> | ||
| </span> | ||
| <span className="text-base font-normal text-slate-500 transition group-hover:text-blue-600"> | ||
| {title} | ||
| </span> | ||
| </button> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| "use client"; | ||
|
|
||
| import { useId, type ReactNode, type Ref } from "react"; | ||
|
|
||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| export interface ResourceCardProps { | ||
| title: ReactNode; | ||
| description?: ReactNode; | ||
| icon?: ReactNode; | ||
| /** Status or lifecycle content displayed beside the title. */ | ||
| badge?: ReactNode; | ||
| /** Resource tags displayed below the description. */ | ||
| tags?: ReactNode; | ||
| meta?: ReactNode; | ||
| /** Status icons displayed beside the title, left of actions. */ | ||
| headerActions?: ReactNode; | ||
| /** Actions displayed at the top-right (e.g., "..." menu). */ | ||
| actions?: ReactNode; | ||
| footer?: ReactNode; | ||
| onClick?: () => void; | ||
| selected?: boolean; | ||
| className?: string; | ||
| containerRef?: Ref<HTMLDivElement>; | ||
| } | ||
|
|
||
| export default function ResourceCard({ | ||
| title, | ||
| description, | ||
| icon, | ||
| badge, | ||
| tags, | ||
| meta, | ||
| headerActions, | ||
| actions, | ||
| footer, | ||
| onClick, | ||
| selected, | ||
| className, | ||
| containerRef, | ||
| }: ResourceCardProps) { | ||
|
Check warning on line 41 in frontend/components/resource/ResourceCard.tsx
|
||
| const titleId = `resource-card-title-${useId()}`; | ||
| const isInteractive = onClick !== undefined; | ||
| const actionClassName = isInteractive | ||
| ? "pointer-events-auto relative z-10" | ||
| : undefined; | ||
|
|
||
| return ( | ||
| <div | ||
| ref={containerRef} | ||
| className={cn( | ||
| "group relative flex min-h-[240px] flex-col rounded-lg border bg-white text-left transition", | ||
| "p-5 shadow-sm hover:border-blue-300 hover:shadow-md", | ||
| selected && "border-blue-400 ring-1 ring-blue-200", | ||
| !selected && "border-slate-200", | ||
| className | ||
| )} | ||
| > | ||
| {isInteractive ? ( | ||
| <button | ||
| type="button" | ||
| aria-labelledby={titleId} | ||
| aria-pressed={selected} | ||
| onClick={onClick} | ||
| className="absolute inset-0 z-0 size-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset" | ||
| /> | ||
| ) : null} | ||
| <div | ||
| className={cn( | ||
| "flex min-h-0 flex-1 flex-col", | ||
| isInteractive && "pointer-events-none relative z-[1]" | ||
| )} | ||
| > | ||
| <div className="flex items-start gap-3"> | ||
| {icon ? <span className="shrink-0">{icon}</span> : null} | ||
| <div className="min-w-0 flex-1"> | ||
| <h2 | ||
| id={titleId} | ||
| className="truncate text-base font-semibold text-slate-900" | ||
| > | ||
| {title} | ||
| </h2> | ||
| {badge ? ( | ||
| <div className="mt-1 flex flex-wrap gap-2">{badge}</div> | ||
| ) : null} | ||
| </div> | ||
| {headerActions || actions ? ( | ||
| <div | ||
| data-resource-card-action | ||
| className={cn( | ||
| "flex shrink-0 items-center gap-1", | ||
| actionClassName | ||
| )} | ||
| > | ||
| {headerActions} | ||
| {actions} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| <div className="flex flex-1 flex-col"> | ||
| {description ? ( | ||
| <div className="mt-4 line-clamp-3 text-sm leading-6 text-slate-600"> | ||
| {description} | ||
| </div> | ||
| ) : null} | ||
| {tags ? ( | ||
| <div | ||
| className={cn( | ||
| "flex flex-wrap gap-2 pb-2 text-xs", | ||
| description ? "mt-3" : "mt-4" | ||
| )} | ||
| > | ||
| {tags} | ||
| </div> | ||
| ) : null} | ||
| <div className="mt-auto"> | ||
| {meta || footer ? ( | ||
| <div className="flex items-center justify-between gap-3 border-t border-slate-100 pt-4"> | ||
| <div className="min-w-0">{meta}</div> | ||
| {footer ? ( | ||
| <div data-resource-card-action className={actionClassName}> | ||
| {footer} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useMemo } from "react"; | ||
| import { Empty, Input, Pagination } from "antd"; | ||
| import { Search } from "lucide-react"; | ||
| import type { CSSProperties, ReactNode } from "react"; | ||
|
|
||
| import CreateResourceCard from "./CreateResourceCard"; | ||
|
|
||
| export interface ResourceFilterOption { | ||
| key: string; | ||
| label: ReactNode; | ||
| count?: number; | ||
| } | ||
|
|
||
| export interface ResourceCardGridProps<T> { | ||
| items: T[]; | ||
| page?: number; | ||
| total?: number; | ||
| onPageChange?: (page: number) => void; | ||
| search?: string; | ||
| searchPlaceholder?: string; | ||
| onSearchChange?: (value: string) => void; | ||
| filters?: ResourceFilterOption[]; | ||
| activeFilter?: string; | ||
| onFilterChange?: (key: string) => void; | ||
| showToolbar?: boolean; | ||
| showCreateCard?: boolean; | ||
| createCard?: ReactNode; | ||
| createCardTitle?: string; | ||
| onCreate?: () => void; | ||
| columns?: number; | ||
| rows?: number; | ||
| /** Keep support for callers that need to place another item before the cards. */ | ||
| headerItem?: ReactNode; | ||
| emptyState?: ReactNode; | ||
| /** Set to false when items already contain only the current server page. */ | ||
| paginateItems?: boolean; | ||
| renderItem: (item: T, index: number) => ReactNode; | ||
| } | ||
|
|
||
| export default function ResourceCardGrid<T>({ | ||
|
Check failure on line 42 in frontend/components/resource/ResourceCardGrid.tsx
|
||
| items, | ||
| page, | ||
| total, | ||
| onPageChange, | ||
| search, | ||
| searchPlaceholder, | ||
| onSearchChange, | ||
| filters = [], | ||
| activeFilter, | ||
| onFilterChange, | ||
| showToolbar, | ||
| showCreateCard, | ||
| createCard, | ||
| createCardTitle, | ||
| onCreate, | ||
| columns, | ||
| rows, | ||
| headerItem, | ||
| emptyState = <Empty />, | ||
| paginateItems = true, | ||
| renderItem, | ||
| }: ResourceCardGridProps<T>) { | ||
|
Check warning on line 64 in frontend/components/resource/ResourceCardGrid.tsx
|
||
| const columnCount = normalizePositiveInteger(columns, 4); | ||
| const rowCount = normalizePositiveInteger(rows, 3); | ||
| const slotsPerPage = columnCount * rowCount; | ||
| const createEnabled = | ||
| showCreateCard ?? Boolean(createCard || (createCardTitle && onCreate)); | ||
| const itemsPerPage = createEnabled | ||
| ? Math.max(1, slotsPerPage - 1) | ||
| : slotsPerPage; | ||
| const itemCount = total ?? items.length; | ||
| const totalPages = Math.max(1, Math.ceil(itemCount / itemsPerPage)); | ||
| const currentPage = Math.min(Math.max(page ?? 1, 1), totalPages); | ||
| const visibleItems = useMemo(() => { | ||
| if (!paginateItems) return items; | ||
| const start = (currentPage - 1) * itemsPerPage; | ||
| return items.slice(start, start + itemsPerPage); | ||
| }, [currentPage, items, itemsPerPage, paginateItems]); | ||
| const shouldShowCreateCard = createEnabled; | ||
| const createCardNode = shouldShowCreateCard | ||
| ? (createCard ?? | ||
| (createCardTitle && onCreate ? ( | ||
| <CreateResourceCard title={createCardTitle} onClick={onCreate} /> | ||
| ) : null)) | ||
|
Check warning on line 86 in frontend/components/resource/ResourceCardGrid.tsx
|
||
| : null; | ||
| const toolbarVisible = | ||
| showToolbar ?? | ||
| Boolean(onSearchChange || search !== undefined || filters.length > 0); | ||
| const hasSearchControl = onSearchChange !== undefined || search !== undefined; | ||
| const gridStyle = { | ||
| "--resource-card-columns": columnCount, | ||
| } as CSSProperties; | ||
|
|
||
| useEffect(() => { | ||
| if (page !== undefined && page > totalPages) onPageChange?.(totalPages); | ||
| }, [onPageChange, page, totalPages]); | ||
|
|
||
| return ( | ||
| <> | ||
| {toolbarVisible ? ( | ||
| <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> | ||
| {hasSearchControl ? ( | ||
| <Input | ||
| value={search ?? ""} | ||
| onChange={(event) => onSearchChange?.(event.target.value)} | ||
| allowClear={onSearchChange !== undefined} | ||
| readOnly={onSearchChange === undefined} | ||
| prefix={<Search size={16} className="text-slate-400" />} | ||
| placeholder={searchPlaceholder} | ||
| className="h-10 w-full sm:max-w-md" | ||
| /> | ||
| ) : null} | ||
| {filters.length > 0 ? ( | ||
| <div className="flex items-center gap-5 border-b border-slate-200 text-sm"> | ||
| {filters.map((filter) => ( | ||
| <button | ||
| key={filter.key} | ||
| type="button" | ||
| aria-pressed={activeFilter === filter.key} | ||
| onClick={() => onFilterChange?.(filter.key)} | ||
| className={cnFilter(activeFilter === filter.key)} | ||
| > | ||
| {filter.label} | ||
| {filter.count !== undefined ? ( | ||
| <span className="ml-1 text-xs text-slate-400"> | ||
| {filter.count} | ||
| </span> | ||
| ) : null} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| ) : null} | ||
| <div | ||
| className="grid grid-cols-1 gap-5 sm:[grid-template-columns:repeat(var(--resource-card-columns),minmax(0,1fr))]" | ||
| style={gridStyle} | ||
| > | ||
| {headerItem != null && currentPage === 1 ? headerItem : null} | ||
| {createCardNode} | ||
| {itemCount === 0 ? ( | ||
| <div className="col-span-full flex min-h-[220px] items-center justify-center"> | ||
| {emptyState} | ||
| </div> | ||
| ) : ( | ||
| visibleItems.map((item, index) => renderItem(item, index)) | ||
| )} | ||
| </div> | ||
| {itemCount > 0 && totalPages > 1 && onPageChange ? ( | ||
| <div className="mt-7 flex justify-end"> | ||
| <Pagination | ||
| current={currentPage} | ||
| pageSize={itemsPerPage} | ||
| total={itemCount} | ||
| showSizeChanger={false} | ||
| onChange={onPageChange} | ||
| /> | ||
| </div> | ||
| ) : null} | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| function normalizePositiveInteger(value: number | undefined, fallback: number) { | ||
| return Number.isFinite(value) && value !== undefined && value > 0 | ||
| ? Math.floor(value) | ||
| : fallback; | ||
| } | ||
|
|
||
| function cnFilter(active: boolean) { | ||
| return `border-b-2 px-1 pb-2 font-medium transition-colors ${ | ||
| active | ||
| ? "border-blue-600 text-blue-600" | ||
| : "border-transparent text-slate-500 hover:text-slate-800" | ||
| }`; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.