@nixopus/ui
Advanced tools
| import * as React from 'react'; | ||
| import { LucideIcon } from 'lucide-react'; | ||
| import { cn } from '../../lib/utils'; | ||
| import { CardWrapper } from './card-wrapper'; | ||
| import { Badge } from '../badge'; | ||
| import { DropdownWrapper } from './dropdown-wrapper'; | ||
| export interface StatusIndicatorConfig { | ||
| /** Background color class for status indicator container */ | ||
| bg: string; | ||
| /** Dot color class */ | ||
| dot: string; | ||
| /** Whether to show pulse animation */ | ||
| pulse: boolean; | ||
| /** Status label text */ | ||
| label: string; | ||
| } | ||
| export interface MetadataItem { | ||
| /** Icon component */ | ||
| icon: LucideIcon; | ||
| /** Label text */ | ||
| label: string; | ||
| /** Unique key */ | ||
| key: string; | ||
| /** Optional className for the item */ | ||
| className?: string; | ||
| } | ||
| export interface BadgeItem { | ||
| /** Badge label */ | ||
| label: string; | ||
| /** Badge variant */ | ||
| variant?: 'default' | 'secondary' | 'destructive' | 'outline'; | ||
| /** Custom className */ | ||
| className?: string; | ||
| } | ||
| export interface DomainItem { | ||
| /** Domain value */ | ||
| domain: string; | ||
| /** Optional onClick handler */ | ||
| onClick?: (domain: string, e?: React.MouseEvent) => void; | ||
| } | ||
| export interface ProjectCardWrapperProps { | ||
| /** Project/Item title */ | ||
| title: string; | ||
| /** Click handler for the card */ | ||
| onClick?: () => void; | ||
| /** Status indicator configuration */ | ||
| statusIndicator?: StatusIndicatorConfig; | ||
| /** Status text color class */ | ||
| statusTextColor?: string; | ||
| /** Header action (e.g., external link button) */ | ||
| headerAction?: React.ReactNode; | ||
| /** Primary domain to display */ | ||
| primaryDomain?: string; | ||
| /** All domains (for dropdown if multiple) */ | ||
| domains?: DomainItem[]; | ||
| /** External link icon component */ | ||
| externalLinkIcon?: LucideIcon; | ||
| /** Badges to display (environment, labels, etc.) */ | ||
| badges?: BadgeItem[]; | ||
| /** Metadata items (branch, build pack, etc.) */ | ||
| metadataItems?: MetadataItem[]; | ||
| /** Footer left content (e.g., time ago) */ | ||
| footerLeft?: React.ReactNode; | ||
| /** Footer right content (e.g., status label) */ | ||
| footerRight?: React.ReactNode; | ||
| /** Custom className for the card */ | ||
| className?: string; | ||
| /** Custom className for the content */ | ||
| contentClassName?: string; | ||
| /** Custom className for the status indicator container */ | ||
| statusIndicatorClassName?: string; | ||
| /** Custom className for badges container */ | ||
| badgesClassName?: string; | ||
| /** Custom className for metadata container */ | ||
| metadataClassName?: string; | ||
| /** Custom className for footer */ | ||
| footerClassName?: string; | ||
| /** Custom className for the title */ | ||
| titleClassName?: string; | ||
| /** Custom className for the header container */ | ||
| headerClassName?: string; | ||
| /** Custom className for the main content container */ | ||
| mainContentClassName?: string; | ||
| /** Custom className for the primary domain display */ | ||
| primaryDomainClassName?: string; | ||
| /** Custom className for footer left content */ | ||
| footerLeftClassName?: string; | ||
| /** Custom className for footer right content */ | ||
| footerRightClassName?: string; | ||
| /** Show/hide status indicator */ | ||
| showStatusIndicator?: boolean; | ||
| /** Show/hide badges section */ | ||
| showBadges?: boolean; | ||
| /** Show/hide metadata section */ | ||
| showMetadata?: boolean; | ||
| /** Show/hide footer */ | ||
| showFooter?: boolean; | ||
| /** Show/hide primary domain in badges section */ | ||
| showPrimaryDomain?: boolean; | ||
| /** Custom render function for title (overrides default title) */ | ||
| renderTitle?: () => React.ReactNode; | ||
| /** Custom render function for status indicator (overrides default) */ | ||
| renderStatusIndicator?: () => React.ReactNode; | ||
| /** Custom render function for badges section (overrides default) */ | ||
| renderBadges?: () => React.ReactNode; | ||
| /** Custom render function for metadata section (overrides default) */ | ||
| renderMetadata?: () => React.ReactNode; | ||
| /** Custom render function for footer (overrides default) */ | ||
| renderFooter?: () => React.ReactNode; | ||
| /** Custom render function for primary domain display (overrides default) */ | ||
| renderPrimaryDomain?: (domain: string) => React.ReactNode; | ||
| /** Custom render function for header actions (overrides default domain links) */ | ||
| renderHeaderActions?: () => React.ReactNode; | ||
| /** Props to pass to CardWrapper */ | ||
| cardWrapperProps?: React.ComponentProps<typeof CardWrapper>; | ||
| } | ||
| /** | ||
| * ProjectCardWrapper - A reusable card component for displaying project/item information | ||
| * | ||
| * Provides a consistent layout for project cards with: | ||
| * - Status indicator with pulse animation | ||
| * - Title with optional header actions | ||
| * - Domain display with external links | ||
| * - Badges (environment, labels, etc.) | ||
| * - Metadata items (branch, build pack, etc.) | ||
| * - Footer with time and status | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * <ProjectCardWrapper | ||
| * title="My Project" | ||
| * onClick={() => router.push('/projects/1')} | ||
| * statusIndicator={{ | ||
| * bg: 'bg-emerald-500/10', | ||
| * dot: 'bg-emerald-500', | ||
| * pulse: true, | ||
| * label: 'Running' | ||
| * }} | ||
| * primaryDomain="example.com" | ||
| * badges={[ | ||
| * { label: 'production', variant: 'outline', className: 'border-emerald-500/30' } | ||
| * ]} | ||
| * metadataItems={[ | ||
| * { icon: GitBranch, label: 'main', key: 'branch' } | ||
| * ]} | ||
| * footerLeft="2 hours ago" | ||
| * footerRight="Running" | ||
| * /> | ||
| * ``` | ||
| */ | ||
| export function ProjectCardWrapper({ | ||
| title, | ||
| onClick, | ||
| statusIndicator, | ||
| statusTextColor, | ||
| headerAction, | ||
| primaryDomain, | ||
| domains = [], | ||
| externalLinkIcon: ExternalLinkIcon, | ||
| badges = [], | ||
| metadataItems = [], | ||
| footerLeft, | ||
| footerRight, | ||
| className, | ||
| contentClassName, | ||
| statusIndicatorClassName, | ||
| badgesClassName, | ||
| metadataClassName, | ||
| footerClassName, | ||
| titleClassName, | ||
| headerClassName, | ||
| mainContentClassName, | ||
| primaryDomainClassName, | ||
| footerLeftClassName, | ||
| footerRightClassName, | ||
| showStatusIndicator = true, | ||
| showBadges = true, | ||
| showMetadata = true, | ||
| showFooter = true, | ||
| showPrimaryDomain = true, | ||
| renderTitle, | ||
| renderStatusIndicator, | ||
| renderBadges, | ||
| renderMetadata, | ||
| renderFooter, | ||
| renderPrimaryDomain, | ||
| renderHeaderActions, | ||
| cardWrapperProps | ||
| }: ProjectCardWrapperProps) { | ||
| const handleDomainClick = React.useCallback( | ||
| (domain: string, e?: React.MouseEvent) => { | ||
| if (e) { | ||
| e.stopPropagation(); | ||
| } | ||
| const domainItem = domains.find((d) => d.domain === domain); | ||
| if (domainItem?.onClick) { | ||
| domainItem.onClick(domain, e); | ||
| } else { | ||
| window.open(`https://${domain}`, '_blank', 'noopener,noreferrer'); | ||
| } | ||
| }, | ||
| [domains] | ||
| ); | ||
| const hasDomains = domains.length > 0; | ||
| const hasMultipleDomains = domains.length > 1; | ||
| const hasStatusIndicator = !!statusIndicator && showStatusIndicator; | ||
| const hasBadges = showBadges && (badges.length > 0 || (showPrimaryDomain && !!primaryDomain)); | ||
| const hasMetadata = showMetadata && metadataItems.length > 0; | ||
| const hasFooter = showFooter && (!!footerLeft || !!footerRight); | ||
| return ( | ||
| <CardWrapper | ||
| className={cn( | ||
| 'relative w-full cursor-pointer overflow-hidden transition-all duration-200 hover:shadow-lg hover:border-primary/30 group', | ||
| className | ||
| )} | ||
| contentClassName={cn('p-5', contentClassName)} | ||
| onClick={onClick} | ||
| {...cardWrapperProps} | ||
| > | ||
| <div className="flex items-start gap-4"> | ||
| {/* Status Indicator */} | ||
| {hasStatusIndicator && ( | ||
| <> | ||
| {renderStatusIndicator ? ( | ||
| renderStatusIndicator() | ||
| ) : ( | ||
| <div | ||
| className={cn( | ||
| 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 transition-colors', | ||
| statusIndicator.bg, | ||
| statusIndicatorClassName | ||
| )} | ||
| > | ||
| <div | ||
| className={cn( | ||
| 'w-2.5 h-2.5 rounded-full', | ||
| statusIndicator.dot, | ||
| statusIndicator.pulse && 'animate-pulse' | ||
| )} | ||
| /> | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
| <div className={cn('flex-1 min-w-0', mainContentClassName)}> | ||
| {/* Header: Title and Action */} | ||
| <div className={cn('flex items-center justify-between gap-2', headerClassName)}> | ||
| {renderTitle ? ( | ||
| renderTitle() | ||
| ) : ( | ||
| <h3 className={cn('font-semibold text-base tracking-tight truncate group-hover:text-primary transition-colors', titleClassName)}> | ||
| {title} | ||
| </h3> | ||
| )} | ||
| {renderHeaderActions ? ( | ||
| <div className="shrink-0" onClick={(e) => e.stopPropagation()}> | ||
| {renderHeaderActions()} | ||
| </div> | ||
| ) : ( | ||
| <> | ||
| {hasDomains && ( | ||
| <div className="shrink-0" onClick={(e) => e.stopPropagation()}> | ||
| {hasMultipleDomains && ExternalLinkIcon ? ( | ||
| <DropdownWrapper | ||
| trigger={ | ||
| <button | ||
| className="text-muted-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-muted" | ||
| title={`${domains.length} domains`} | ||
| > | ||
| <ExternalLinkIcon className="h-4 w-4" /> | ||
| </button> | ||
| } | ||
| items={domains.map((domainItem, index) => ({ | ||
| key: `domain-${index}`, | ||
| label: ( | ||
| <> | ||
| {ExternalLinkIcon && <ExternalLinkIcon className="h-4 w-4" />} | ||
| <span className="font-mono text-sm">{domainItem.domain}</span> | ||
| </> | ||
| ), | ||
| onClick: (e?: React.MouseEvent) => handleDomainClick(domainItem.domain, e), | ||
| className: 'flex items-center gap-2 cursor-pointer' | ||
| }))} | ||
| align="end" | ||
| onContentClick={(e) => e.stopPropagation()} | ||
| /> | ||
| ) : primaryDomain && ExternalLinkIcon ? ( | ||
| <button | ||
| onClick={(e) => handleDomainClick(primaryDomain, e)} | ||
| className="text-muted-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-muted" | ||
| title={`Open ${primaryDomain}`} | ||
| > | ||
| <ExternalLinkIcon className="h-4 w-4" /> | ||
| </button> | ||
| ) : null} | ||
| </div> | ||
| )} | ||
| {headerAction && ( | ||
| <div className="shrink-0" onClick={(e) => e.stopPropagation()}> | ||
| {headerAction} | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
| </div> | ||
| {/* Badges: Domain, Environment, Labels */} | ||
| {hasBadges && ( | ||
| <> | ||
| {renderBadges ? ( | ||
| renderBadges() | ||
| ) : ( | ||
| <div className={cn('flex flex-wrap items-center gap-2 mt-2', badgesClassName)}> | ||
| {showPrimaryDomain && primaryDomain && ( | ||
| <> | ||
| {renderPrimaryDomain ? ( | ||
| renderPrimaryDomain(primaryDomain) | ||
| ) : ( | ||
| <span className={cn('text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]', primaryDomainClassName)}> | ||
| {primaryDomain} | ||
| </span> | ||
| )} | ||
| </> | ||
| )} | ||
| {badges.map((badge, index) => ( | ||
| <Badge key={index} variant={badge.variant || 'outline'} className={cn('text-xs', badge.className)}> | ||
| {badge.label} | ||
| </Badge> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
| {/* Metadata Items */} | ||
| {hasMetadata && ( | ||
| <> | ||
| {renderMetadata ? ( | ||
| renderMetadata() | ||
| ) : ( | ||
| <div className={cn('flex items-center gap-4 mt-4 text-xs text-muted-foreground', metadataClassName)}> | ||
| {metadataItems.map((item) => { | ||
| const Icon = item.icon; | ||
| return ( | ||
| <span key={item.key} className={cn('flex items-center gap-1', item.className)}> | ||
| <Icon className="h-3 w-3" /> | ||
| <span>{item.label}</span> | ||
| </span> | ||
| ); | ||
| })} | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
| {/* Footer */} | ||
| {hasFooter && ( | ||
| <> | ||
| {renderFooter ? ( | ||
| renderFooter() | ||
| ) : ( | ||
| <div className={cn('flex items-center justify-between mt-4 pt-3 border-t border-border/50', footerClassName)}> | ||
| {footerLeft && ( | ||
| <span className={cn('text-xs text-muted-foreground', footerLeftClassName)}> | ||
| {footerLeft} | ||
| </span> | ||
| )} | ||
| {footerRight && ( | ||
| <span className={cn('text-xs font-medium', statusTextColor, footerRightClassName)}> | ||
| {footerRight} | ||
| </span> | ||
| )} | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </CardWrapper> | ||
| ); | ||
| } |
+1
-1
| { | ||
| "name": "@nixopus/ui", | ||
| "version": "0.1.30", | ||
| "version": "0.1.31", | ||
| "description": "Nixopus UI component library - Pure React components for building Nixopus applications", | ||
@@ -5,0 +5,0 @@ "main": "./dist/index.js", |
+9
-0
@@ -80,2 +80,3 @@ // Core UI Components (Pure Components) | ||
| export * from './components/wrapper/logs-table-wrapper'; | ||
| export * from './components/wrapper/project-card-wrapper'; | ||
| export * from './components/wrapper/sidebar-layout-wrapper'; | ||
@@ -119,2 +120,10 @@ | ||
| } from './components/wrapper/repository-card-wrapper'; | ||
| export { ProjectCardWrapper } from './components/wrapper/project-card-wrapper'; | ||
| export type { | ||
| ProjectCardWrapperProps, | ||
| StatusIndicatorConfig, | ||
| MetadataItem, | ||
| BadgeItem, | ||
| DomainItem | ||
| } from './components/wrapper/project-card-wrapper'; | ||
| export { SidebarLayoutWrapper } from './components/wrapper/sidebar-layout-wrapper'; | ||
@@ -121,0 +130,0 @@ export type { |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Unidentified License
LicenseSomething that seems like a license was found, but its contents could not be matched with a known license.
Unidentified License
LicenseSomething that seems like a license was found, but its contents could not be matched with a known license.
1950648
4.3%95
1.06%25704
3.37%