canvas-ui-sdk 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +28 -1
- package/dist/index.js +198 -182
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/registry/blocks/activity-feed.json +5 -4
- package/registry/blocks/bottom-input-chat-widget.json +4 -3
- package/registry/blocks/chat-message.json +1 -1
- package/registry/blocks/circular-progress-bar-list.json +3 -3
- package/registry/blocks/component-search.json +2 -2
- package/registry/blocks/content-dropzone.json +1 -1
- package/registry/blocks/credit-card-display.json +1 -1
- package/registry/blocks/custom-component-helper.json +2 -2
- package/registry/blocks/demo-avatars.json +14 -0
- package/registry/blocks/empty-state.json +1 -1
- package/registry/blocks/faqs-table.json +2 -2
- package/registry/blocks/filter-popover.json +11 -11
- package/registry/blocks/fixed-column-data-table.json +6 -5
- package/registry/blocks/flair-banner.json +1 -1
- package/registry/blocks/form-group.json +15 -15
- package/registry/blocks/gradient-banner.json +1 -1
- package/registry/blocks/graph-metric-tiles.json +1 -1
- package/registry/blocks/grid-tiles-list.json +2 -2
- package/registry/blocks/image-feed-with-nested-comments.json +6 -5
- package/registry/blocks/large-image-labels-list.json +2 -2
- package/registry/blocks/loader.json +2 -2
- package/registry/blocks/login-branding-panel.json +1 -1
- package/registry/blocks/menu-section.json +1 -1
- package/registry/blocks/menufocus-template.json +2 -2
- package/registry/blocks/messenger-sidebar.json +4 -3
- package/registry/blocks/mobile-bottom-nav.json +1 -1
- package/registry/blocks/monthly-calendar-widget.json +2 -2
- package/registry/blocks/nested-comments-table.json +7 -6
- package/registry/blocks/nested-data-table.json +6 -5
- package/registry/blocks/page-header-section.json +2 -2
- package/registry/blocks/page-previews.json +4 -4
- package/registry/blocks/pagination.json +3 -3
- package/registry/blocks/participant-list.json +2 -2
- package/registry/blocks/persona-card.json +1 -1
- package/registry/blocks/pill-tabs.json +2 -2
- package/registry/blocks/profile-card.json +3 -3
- package/registry/blocks/profile-grid-tiles-list.json +5 -4
- package/registry/blocks/profile-image-uploader.json +2 -2
- package/registry/blocks/profile-info-cards.json +2 -2
- package/registry/blocks/progress-bar.json +1 -1
- package/registry/blocks/prompt-template.json +1 -1
- package/registry/blocks/reviews-grid.json +1 -1
- package/registry/blocks/reviews-table.json +5 -4
- package/registry/blocks/screen-flowchart.json +1 -1
- package/registry/blocks/screen-prompt-builder.json +2 -2
- package/registry/blocks/screen-prompt-template.json +1 -1
- package/registry/blocks/search-bar.json +2 -2
- package/registry/blocks/search-sidebar.json +8 -8
- package/registry/blocks/settings-list-row.json +3 -3
- package/registry/blocks/sidebar-cards.json +1 -1
- package/registry/blocks/sidebar-profile-card.json +4 -4
- package/registry/blocks/slideshow-grid-tiles.json +5 -4
- package/registry/blocks/social-feed.json +6 -5
- package/registry/blocks/standard-data-table.json +6 -5
- package/registry/blocks/standard-list-with-image.json +3 -3
- package/registry/blocks/step-tracker.json +1 -1
- package/registry/blocks/team-cards-grid.json +1 -1
- package/registry/blocks/team-circular-grid.json +1 -1
- package/registry/blocks/testimonial-carousel.json +1 -1
- package/registry/blocks/title-group.json +4 -4
- package/registry/blocks/upvoting-posts-table.json +7 -6
- package/registry/blocks/vertical-step-tracker.json +2 -2
- package/registry/blocks/video-chat-controls.json +1 -1
- package/registry/blocks/video-content-section.json +1 -1
- package/registry/blocks/video-playlist.json +1 -1
- package/registry/blocks/webcam-preview.json +1 -1
- package/registry/blocks/youtube-player.json +1 -1
- package/registry/index.json +5 -0
- package/registry/layout/account-settings-shell.json +3 -3
- package/registry/layout/dashboard-shell.json +5 -5
- package/registry/layout/double-sidebar-shell.json +5 -5
- package/registry/layout/double-sidebar.json +2 -2
- package/registry/layout/header.json +6 -5
- package/registry/layout/icon-sidebar-shell.json +5 -5
- package/registry/layout/icon-sidebar.json +1 -1
- package/registry/layout/mobile-menu-shell.json +4 -4
- package/registry/layout/multistep-progressbar-shell.json +8 -8
- package/registry/layout/multistep-shell.json +6 -6
- package/registry/layout/multistep-sidebar-shell.json +7 -7
- package/registry/layout/project-context-shell.json +2 -2
- package/registry/layout/search-bar-shell.json +7 -7
- package/registry/layout/sidebar-nav.json +1 -1
- package/registry/layout/sidebar.json +3 -3
- package/registry/layout/standard-page-shell.json +6 -6
- package/registry/layout/vertical-multistep-shell.json +8 -8
- package/registry/ui/avatar.json +1 -1
- package/registry/ui/button.json +1 -1
- package/registry/ui/calendar.json +2 -2
- package/registry/ui/checkbox.json +1 -1
- package/registry/ui/date-input.json +1 -1
- package/registry/ui/dialog.json +1 -1
- package/registry/ui/dropdown-menu.json +1 -1
- package/registry/ui/file-uploader.json +1 -1
- package/registry/ui/image-uploader.json +1 -1
- package/registry/ui/input.json +1 -1
- package/registry/ui/label.json +1 -1
- package/registry/ui/line-tabs.json +1 -1
- package/registry/ui/multiselect-checkbox-field.json +1 -1
- package/registry/ui/multiselect-tags.json +1 -1
- package/registry/ui/popover.json +1 -1
- package/registry/ui/radio-group.json +1 -1
- package/registry/ui/range-input.json +2 -2
- package/registry/ui/scroll-area.json +1 -1
- package/registry/ui/searchbox.json +1 -1
- package/registry/ui/select.json +1 -1
- package/registry/ui/selectable-pills.json +1 -1
- package/registry/ui/separator.json +1 -1
- package/registry/ui/sheet.json +1 -1
- package/registry/ui/sidebar.json +8 -8
- package/registry/ui/skeleton.json +1 -1
- package/registry/ui/slider.json +1 -1
- package/registry/ui/switch.json +1 -1
- package/registry/ui/tabs.json +1 -1
- package/registry/ui/text-input.json +1 -1
- package/registry/ui/textarea.json +1 -1
- package/registry/ui/tooltip.json +1 -1
- package/registry/ui/typography.json +1 -1
|
@@ -13,16 +13,16 @@
|
|
|
13
13
|
"lucide-react"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"utils",
|
|
17
|
-
"button",
|
|
18
|
-
"popover",
|
|
19
|
-
"select",
|
|
20
|
-
"checkbox",
|
|
21
|
-
"radio-group",
|
|
22
|
-
"switch",
|
|
23
|
-
"text-input",
|
|
24
|
-
"searchbox",
|
|
25
|
-
"date-input",
|
|
26
|
-
"multiselect-tags"
|
|
16
|
+
"lib/utils",
|
|
17
|
+
"ui/button",
|
|
18
|
+
"ui/popover",
|
|
19
|
+
"ui/select",
|
|
20
|
+
"ui/checkbox",
|
|
21
|
+
"ui/radio-group",
|
|
22
|
+
"ui/switch",
|
|
23
|
+
"ui/text-input",
|
|
24
|
+
"ui/searchbox",
|
|
25
|
+
"ui/date-input",
|
|
26
|
+
"ui/multiselect-tags"
|
|
27
27
|
]
|
|
28
28
|
}
|
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/fixed-column-data-table.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\nimport { TitleGroup } from \"./title-group\";\n\n// ============================================\n// Types\n// ============================================\n\nexport type FixedColumnTableStatus = \"pending\" | \"paid\" | \"overdue\";\n\nexport interface FixedColumnTableRow {\n id: string;\n name: string;\n avatarUrl?: string;\n amount: string;\n status: FixedColumnTableStatus;\n logoUrl?: string;\n company: string;\n dateSent: string;\n}\n\nexport interface FixedColumnTableColumn {\n id: string;\n label: string;\n width?: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FixedColumnDataTableProps {\n /** Table title */\n title?: string;\n /** Number of results to display */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** Table data rows */\n data?: FixedColumnTableRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when add/action button is clicked */\n onAddNew?: () => void;\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when row action is clicked */\n onRowAction?: (action: string, row: FixedColumnTableRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: FixedColumnTableRow[] = [\n {\n id: \"1\",\n name: \"Jeff Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n amount: \"$3,200\",\n status: \"pending\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"5/23/2024\",\n },\n {\n id: \"2\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n amount: \"$2,400\",\n status: \"paid\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"2/19/2024\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n { id: \"amount-high\", label: \"Amount (High)\" },\n { id: \"amount-low\", label: \"Amount (Low)\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableHeaderCell({ children, className, isFixed }: TableHeaderCellProps) {\n return (\n <th\n className={cn(\n \"text-left h-8\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n }}\n >\n {children}\n </th>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableCell({ children, className, isFixed }: TableCellProps) {\n return (\n <td\n className={cn(\n \"h-12\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {children}\n </td>\n );\n}\n\ninterface StatusBadgeProps {\n status: FixedColumnTableStatus;\n}\n\nfunction StatusBadge({ status }: StatusBadgeProps) {\n const statusConfig: Record<FixedColumnTableStatus, { label: string; bgColor: string; textColor: string }> = {\n pending: {\n label: \"Pending\",\n bgColor: \"var(--canvas-surface-brand)\",\n textColor: \"var(--canvas-primary)\",\n },\n paid: {\n label: \"Paid\",\n bgColor: \"var(--canvas-success-surface)\",\n textColor: \"var(--canvas-success)\",\n },\n overdue: {\n label: \"Overdue\",\n bgColor: \"var(--canvas-destructive-surface)\",\n textColor: \"var(--canvas-destructive)\",\n },\n };\n\n const config = statusConfig[status];\n\n return (\n <span\n className=\"inline-flex items-center justify-center whitespace-nowrap\"\n style={{\n backgroundColor: config.bgColor,\n color: config.textColor,\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n padding: \"var(--spacing-md) var(--spacing-xl)\",\n borderRadius: \"var(--spacing-3xl)\",\n height: \"32px\",\n }}\n >\n {config.label}\n </span>\n );\n}\n\ninterface CompanyLogoProps {\n logoUrl?: string;\n company: string;\n}\n\nfunction CompanyLogo({ logoUrl, company }: CompanyLogoProps) {\n // Default favicon-style logo if no URL provided\n if (!logoUrl) {\n return (\n <div\n className=\"flex items-center justify-center\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-primary)\",\n borderRadius: \"var(--radius-xs)\",\n }}\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"var(--canvas-primary-foreground)\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\" />\n <polyline points=\"9,22 9,12 15,12 15,22\" />\n </svg>\n </div>\n );\n }\n\n return (\n <img\n src={logoUrl}\n alt={`${company} logo`}\n className=\"object-contain\"\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n }}\n />\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Fixed Column Data Table Block\n * \n * A data table with a fixed first column (Name with avatar) that stays\n * visible during horizontal scrolling. Ideal for invoice-style tables\n * with many columns that need horizontal scrolling on smaller screens.\n * \n * @example\n * ```tsx\n * <FixedColumnDataTable\n * title=\"Invoices\"\n * data={[\n * { id: \"1\", name: \"John\", amount: \"$1,000\", status: \"paid\", company: \"Acme\", dateSent: \"1/1/2024\" }\n * ]}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function FixedColumnDataTable({\n title = \"Invoices\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onRowAction,\n className,\n}: FixedColumnDataTableProps) {\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <TitleGroup title={title} subtitle={displayResultText} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n\n {/* Table Section with horizontal scroll */}\n <div className=\"w-full overflow-x-auto\">\n <table className=\"w-full min-w-max border-collapse\">\n <thead>\n <tr style={{ borderBottom: \"1px solid var(--canvas-border)\" }}>\n {/* Fixed Name Column Header */}\n <TableHeaderCell isFixed className=\"pr-8 min-w-[200px]\">\n Name\n </TableHeaderCell>\n {/* Scrollable Column Headers */}\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Amount\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Status\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[120px]\">\n Logo\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Company\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Date Sent\n </TableHeaderCell>\n <TableHeaderCell className=\"w-12 px-4\">\n \n </TableHeaderCell>\n </tr>\n </thead>\n <tbody>\n {data.map((row) => (\n <tr\n key={row.id}\n className=\"hover:bg-[var(--canvas-surface)] transition-colors\"\n style={{ borderBottom: \"1px solid var(--canvas-border)\" }}\n >\n {/* Fixed Name Column */}\n <TableCell isFixed className=\"pr-8\">\n <div className=\"flex items-center gap-2\">\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={row.avatarUrl} alt={row.name} />\n <AvatarFallback>\n {row.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{row.name}</span>\n </div>\n </TableCell>\n {/* Scrollable Columns */}\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.amount}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <StatusBadge status={row.status} />\n </TableCell>\n <TableCell className=\"px-6\">\n <CompanyLogo logoUrl={row.logoUrl} company={row.company} />\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.company}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.dateSent}</span>\n </TableCell>\n <TableCell className=\"px-4\">\n <MenufocusTemplate\n ariaLabel=\"Row actions\"\n items={[\n { id: \"edit\", label: \"Edit\", onClick: () => onRowAction?.(\"edit\", row) },\n { id: \"view\", label: \"View details\", onClick: () => onRowAction?.(\"view\", row) },\n { id: \"download\", label: \"Download\", onClick: () => onRowAction?.(\"download\", row) },\n { id: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </TableCell>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\nimport { TitleGroup } from \"./title-group\";\nimport { AVATAR_MARCUS_WEBB, AVATAR_SARAH_CHEN } from \"./demo-avatars\";\n\n// ============================================\n// Types\n// ============================================\n\nexport type FixedColumnTableStatus = \"pending\" | \"paid\" | \"overdue\";\n\nexport interface FixedColumnTableRow {\n id: string;\n name: string;\n avatarUrl?: string;\n amount: string;\n status: FixedColumnTableStatus;\n logoUrl?: string;\n company: string;\n dateSent: string;\n}\n\nexport interface FixedColumnTableColumn {\n id: string;\n label: string;\n width?: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FixedColumnDataTableProps {\n /** Table title */\n title?: string;\n /** Number of results to display */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** Table data rows */\n data?: FixedColumnTableRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: { id: string; label: string }[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when add/action button is clicked */\n onAddNew?: () => void;\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when row action is clicked */\n onRowAction?: (action: string, row: FixedColumnTableRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: FixedColumnTableRow[] = [\n {\n id: \"1\",\n name: \"Marcus Webb\",\n avatarUrl: AVATAR_MARCUS_WEBB,\n amount: \"$3,200\",\n status: \"pending\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"5/23/2024\",\n },\n {\n id: \"2\",\n name: \"Sarah Chen\",\n avatarUrl: AVATAR_SARAH_CHEN,\n amount: \"$2,400\",\n status: \"paid\",\n logoUrl: \"\",\n company: \"Airdev\",\n dateSent: \"2/19/2024\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n { id: \"amount-high\", label: \"Amount (High)\" },\n { id: \"amount-low\", label: \"Amount (Low)\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableHeaderCell({ children, className, isFixed }: TableHeaderCellProps) {\n return (\n <th\n className={cn(\n \"text-left h-8\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n }}\n >\n {children}\n </th>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n isFixed?: boolean;\n}\n\nfunction TableCell({ children, className, isFixed }: TableCellProps) {\n return (\n <td\n className={cn(\n \"h-12\",\n isFixed && \"sticky left-0 z-10\",\n className\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n backgroundColor: isFixed ? \"var(--canvas-background)\" : undefined,\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {children}\n </td>\n );\n}\n\ninterface StatusBadgeProps {\n status: FixedColumnTableStatus;\n}\n\nfunction StatusBadge({ status }: StatusBadgeProps) {\n const statusConfig: Record<FixedColumnTableStatus, { label: string; bgColor: string; textColor: string }> = {\n pending: {\n label: \"Pending\",\n bgColor: \"var(--canvas-surface-brand)\",\n textColor: \"var(--canvas-primary)\",\n },\n paid: {\n label: \"Paid\",\n bgColor: \"var(--canvas-success-surface)\",\n textColor: \"var(--canvas-success)\",\n },\n overdue: {\n label: \"Overdue\",\n bgColor: \"var(--canvas-destructive-surface)\",\n textColor: \"var(--canvas-destructive)\",\n },\n };\n\n const config = statusConfig[status];\n\n return (\n <span\n className=\"inline-flex items-center justify-center whitespace-nowrap\"\n style={{\n backgroundColor: config.bgColor,\n color: config.textColor,\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n padding: \"var(--spacing-md) var(--spacing-xl)\",\n borderRadius: \"var(--spacing-3xl)\",\n height: \"32px\",\n }}\n >\n {config.label}\n </span>\n );\n}\n\ninterface CompanyLogoProps {\n logoUrl?: string;\n company: string;\n}\n\nfunction CompanyLogo({ logoUrl, company }: CompanyLogoProps) {\n // Default favicon-style logo if no URL provided\n if (!logoUrl) {\n return (\n <div\n className=\"flex items-center justify-center\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-primary)\",\n borderRadius: \"var(--radius-xs)\",\n }}\n >\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"var(--canvas-primary-foreground)\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\" />\n <polyline points=\"9,22 9,12 15,12 15,22\" />\n </svg>\n </div>\n );\n }\n\n return (\n <img\n src={logoUrl}\n alt={`${company} logo`}\n className=\"object-contain\"\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n }}\n />\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Fixed Column Data Table Block\n * \n * A data table with a fixed first column (Name with avatar) that stays\n * visible during horizontal scrolling. Ideal for invoice-style tables\n * with many columns that need horizontal scrolling on smaller screens.\n * \n * @example\n * ```tsx\n * <FixedColumnDataTable\n * title=\"Invoices\"\n * data={[\n * { id: \"1\", name: \"John\", amount: \"$1,000\", status: \"paid\", company: \"Acme\", dateSent: \"1/1/2024\" }\n * ]}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function FixedColumnDataTable({\n title = \"Invoices\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onRowAction,\n className,\n}: FixedColumnDataTableProps) {\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <TitleGroup title={title} subtitle={displayResultText} sortOptions={sortOptions} onSort={onSort} actionButtonText={actionButtonText} onAction={onAddNew} />\n\n {/* Table Section with horizontal scroll */}\n <div className=\"w-full overflow-x-auto\">\n <table className=\"w-full min-w-max border-collapse\">\n <thead>\n <tr style={{ borderBottom: \"1px solid var(--canvas-border)\" }}>\n {/* Fixed Name Column Header */}\n <TableHeaderCell isFixed className=\"pr-8 min-w-[200px]\">\n Name\n </TableHeaderCell>\n {/* Scrollable Column Headers */}\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Amount\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Status\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[120px]\">\n Logo\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Company\n </TableHeaderCell>\n <TableHeaderCell className=\"px-6 min-w-[160px]\">\n Date Sent\n </TableHeaderCell>\n <TableHeaderCell className=\"w-12 px-4\">\n \n </TableHeaderCell>\n </tr>\n </thead>\n <tbody>\n {data.map((row) => (\n <tr\n key={row.id}\n className=\"hover:bg-[var(--canvas-surface)] transition-colors\"\n style={{ borderBottom: \"1px solid var(--canvas-border)\" }}\n >\n {/* Fixed Name Column */}\n <TableCell isFixed className=\"pr-8\">\n <div className=\"flex items-center gap-2\">\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={row.avatarUrl} alt={row.name} />\n <AvatarFallback>\n {row.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{row.name}</span>\n </div>\n </TableCell>\n {/* Scrollable Columns */}\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.amount}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <StatusBadge status={row.status} />\n </TableCell>\n <TableCell className=\"px-6\">\n <CompanyLogo logoUrl={row.logoUrl} company={row.company} />\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.company}</span>\n </TableCell>\n <TableCell className=\"px-6\">\n <span className=\"whitespace-nowrap\">{row.dateSent}</span>\n </TableCell>\n <TableCell className=\"px-4\">\n <MenufocusTemplate\n ariaLabel=\"Row actions\"\n items={[\n { id: \"edit\", label: \"Edit\", onClick: () => onRowAction?.(\"edit\", row) },\n { id: \"view\", label: \"View details\", onClick: () => onRowAction?.(\"view\", row) },\n { id: \"download\", label: \"Download\", onClick: () => onRowAction?.(\"download\", row) },\n { id: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </TableCell>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"utils",
|
|
15
|
-
"avatar",
|
|
16
|
-
"menufocus-template",
|
|
17
|
-
"title-group"
|
|
14
|
+
"lib/utils",
|
|
15
|
+
"ui/avatar",
|
|
16
|
+
"blocks/menufocus-template",
|
|
17
|
+
"blocks/title-group",
|
|
18
|
+
"blocks/demo-avatars"
|
|
18
19
|
]
|
|
19
20
|
}
|
|
@@ -11,20 +11,20 @@
|
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
13
13
|
"registryDependencies": [
|
|
14
|
-
"utils",
|
|
15
|
-
"button",
|
|
16
|
-
"label",
|
|
17
|
-
"text-input",
|
|
18
|
-
"textarea",
|
|
19
|
-
"date-input",
|
|
20
|
-
"slider",
|
|
21
|
-
"radio-group",
|
|
22
|
-
"checkbox",
|
|
23
|
-
"multiselect-tags",
|
|
24
|
-
"multiselect-checkbox-field",
|
|
25
|
-
"image-uploader",
|
|
26
|
-
"file-uploader",
|
|
27
|
-
"select",
|
|
28
|
-
"title-group"
|
|
14
|
+
"lib/utils",
|
|
15
|
+
"ui/button",
|
|
16
|
+
"ui/label",
|
|
17
|
+
"ui/text-input",
|
|
18
|
+
"ui/textarea",
|
|
19
|
+
"ui/date-input",
|
|
20
|
+
"ui/slider",
|
|
21
|
+
"ui/radio-group",
|
|
22
|
+
"ui/checkbox",
|
|
23
|
+
"ui/multiselect-tags",
|
|
24
|
+
"ui/multiselect-checkbox-field",
|
|
25
|
+
"ui/image-uploader",
|
|
26
|
+
"ui/file-uploader",
|
|
27
|
+
"ui/select",
|
|
28
|
+
"blocks/title-group"
|
|
29
29
|
]
|
|
30
30
|
}
|
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/image-feed-with-nested-comments.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { TitleGroup } from \"./title-group\";\nimport { Heart, MessageCircle, Send, Bookmark } from \"lucide-react\";\nimport { \n NestedCommentsTable, \n type Comment, \n type CommentAuthor \n} from \"./nested-comments-table\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PostAuthor {\n id: string;\n name: string;\n username?: string;\n avatarUrl?: string;\n}\n\nexport interface ImagePost {\n id: string;\n author: PostAuthor;\n date: string;\n imageUrl: string;\n caption?: string;\n likesCount: number;\n likedByText?: string;\n commentsCount: number;\n isLiked?: boolean;\n isBookmarked?: boolean;\n comments?: Comment[];\n}\n\nexport interface ImageFeedWithNestedCommentsProps {\n /** Section title */\n title?: string;\n /** Section subtitle */\n subtitle?: string;\n /** Posts data */\n posts?: ImagePost[];\n /** Current user for comment avatars */\n currentUser?: CommentAuthor;\n /** Callback when post like is clicked */\n onLike?: (postId: string) => void;\n /** Callback when new comment is submitted */\n onComment?: (postId: string, content: string) => void;\n /** Callback when share is clicked */\n onShare?: (postId: string) => void;\n /** Callback when bookmark is clicked */\n onBookmark?: (postId: string) => void;\n /** Callback when reply is submitted */\n onReply?: (postId: string, commentId: string, content: string) => void;\n /** Callback when comment like is clicked */\n onCommentLike?: (postId: string, commentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: CommentAuthor = {\n id: \"current\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n};\n\nconst defaultPosts: ImagePost[] = [\n {\n id: \"1\",\n author: {\n id: \"aya\",\n name: \"Aya Williams\",\n username: \"ayawilliams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"May 23, 2024\",\n imageUrl: \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=1200&h=800&fit=crop\",\n caption: \"Beautiful day\",\n likesCount: 42,\n likedByText: \"Liked by sc04116 and others\",\n commentsCount: 6,\n isLiked: false,\n isBookmarked: false,\n comments: [\n {\n id: \"c1\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Wow, Paris looks absolutely stunning! The Eiffel Tower is such an iconic landmark. Hope you have an amazing time exploring the city and soaking in all its beauty. Safe travels!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 3,\n isLiked: true,\n replies: [\n {\n id: \"r1\",\n author: {\n id: \"stacy\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Ah, the City of Love! Your picture brings back memories of my own trip to Paris. The Eiffel Tower is even more breathtaking in person. Have a fantastic time exploring all the charm Paris has to offer!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 0,\n isLiked: false,\n replies: [\n {\n id: \"r2\",\n author: {\n id: \"mary\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Paris is truly a dream destination! The Eiffel Tower never fails to impress. Enjoy every moment of your adventure and make unforgettable memories. Can't wait to see more of your journey!\",\n timestamp: \"Mar 8, 11:23 AM\",\n likes: 0,\n isLiked: false,\n },\n ],\n },\n ],\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ActionIconsRowProps {\n isLiked?: boolean;\n isBookmarked?: boolean;\n onLike?: () => void;\n onComment?: () => void;\n onShare?: () => void;\n onBookmark?: () => void;\n}\n\nfunction ActionIconsRow({\n isLiked,\n isBookmarked,\n onLike,\n onComment,\n onShare,\n onBookmark,\n}: ActionIconsRowProps) {\n return (\n <div\n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <button\n type=\"button\"\n onClick={onLike}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Heart\n className=\"w-6 h-6\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n stroke: isLiked ? \"var(--canvas-destructive)\" : \"currentColor\",\n }}\n />\n </button>\n <button\n type=\"button\"\n onClick={onComment}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <MessageCircle className=\"w-6 h-6\" />\n </button>\n <button\n type=\"button\"\n onClick={onShare}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Send className=\"w-6 h-6\" />\n </button>\n <div className=\"flex-1 flex justify-end\">\n <button\n type=\"button\"\n onClick={onBookmark}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Bookmark\n className=\"w-6 h-6\"\n style={{\n fill: isBookmarked ? \"currentColor\" : \"transparent\",\n }}\n />\n </button>\n </div>\n </div>\n );\n}\n\ninterface SocialMetadataProps {\n likedByText?: string;\n username?: string;\n caption?: string;\n commentsCount: number;\n onViewComments?: () => void;\n}\n\nfunction SocialMetadata({\n likedByText,\n username,\n caption,\n commentsCount,\n onViewComments,\n}: SocialMetadataProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {likedByText && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {likedByText}\n </p>\n )}\n {caption && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n <span style={{ fontWeight: 600 }}>{username}</span>{\" \"}\n <span style={{ fontWeight: 400 }}>{caption}</span>\n </p>\n )}\n {commentsCount > 0 && (\n <button\n type=\"button\"\n onClick={onViewComments}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n textAlign: \"left\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n }}\n >\n View all {commentsCount} comments\n </button>\n )}\n </div>\n );\n}\n\ninterface PostCardProps {\n post: ImagePost;\n currentUser?: CommentAuthor;\n onLike?: () => void;\n onComment?: (content: string) => void;\n onShare?: () => void;\n onBookmark?: () => void;\n onReply?: (commentId: string, content: string) => void;\n onCommentLike?: (commentId: string) => void;\n}\n\nfunction PostCard({\n post,\n currentUser,\n onLike,\n onComment,\n onShare,\n onBookmark,\n onReply,\n onCommentLike,\n}: PostCardProps) {\n const [showComments, setShowComments] = useState(true);\n\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingBottom: \"var(--spacing-5xl)\",\n }}\n >\n {/* Author Row */}\n <div\n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={post.author.avatarUrl} />\n <AvatarFallback>\n {post.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col flex-1\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {post.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {post.date}\n </span>\n </div>\n </div>\n\n {/* Post Image */}\n <div\n className=\"w-full overflow-hidden\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n <img\n src={post.imageUrl}\n alt={post.caption || \"Post image\"}\n className=\"w-full h-auto object-cover\"\n style={{ maxHeight: 768 }}\n />\n </div>\n\n {/* Action Icons */}\n <ActionIconsRow\n isLiked={post.isLiked}\n isBookmarked={post.isBookmarked}\n onLike={onLike}\n onComment={() => setShowComments(!showComments)}\n onShare={onShare}\n onBookmark={onBookmark}\n />\n\n {/* Social Metadata */}\n <SocialMetadata\n likedByText={post.likedByText}\n username={post.author.username}\n caption={post.caption}\n commentsCount={post.commentsCount}\n onViewComments={() => setShowComments(!showComments)}\n />\n\n {/* Comments Section - using NestedCommentsTable */}\n {showComments && post.comments && post.comments.length > 0 && (\n <NestedCommentsTable\n title=\"\"\n subtitle=\"\"\n comments={post.comments}\n currentUser={currentUser}\n onComment={onComment}\n onReply={onReply}\n onLike={onCommentLike}\n className=\"pt-0\"\n />\n )}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Image Feed with Nested Comments Block\n *\n * An Instagram-style image feed component with large images, social interactions\n * (like/comment/share/bookmark), and nested comment threads. Uses NestedCommentsTable\n * internally for the comments section.\n *\n * @example\n * ```tsx\n * <ImageFeedWithNestedComments\n * title=\"My posts\"\n * subtitle=\"In the past year\"\n * posts={[...]}\n * onLike={(postId) => console.log(\"Liked\", postId)}\n * />\n * ```\n */\nexport function ImageFeedWithNestedComments({\n title = \"My posts\",\n subtitle = \"In the past year\",\n posts = defaultPosts,\n currentUser = defaultCurrentUser,\n onLike,\n onComment,\n onShare,\n onBookmark,\n onReply,\n onCommentLike,\n className,\n}: ImageFeedWithNestedCommentsProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n {(title || subtitle) && <TitleGroup title={title ?? \"\"} subtitle={subtitle} />}\n\n {/* Posts List */}\n <div className=\"flex flex-col w-full\">\n {posts.map((post) => (\n <PostCard\n key={post.id}\n post={post}\n currentUser={currentUser}\n onLike={() => onLike?.(post.id)}\n onComment={(content) => onComment?.(post.id, content)}\n onShare={() => onShare?.(post.id)}\n onBookmark={() => onBookmark?.(post.id)}\n onReply={(commentId, content) => onReply?.(post.id, commentId, content)}\n onCommentLike={(commentId) => onCommentLike?.(post.id, commentId)}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { TitleGroup } from \"./title-group\";\nimport { Heart, MessageCircle, Send, Bookmark } from \"lucide-react\";\nimport { AVATAR_NICOLE_PALMER, AVATAR_SARAH_CHEN, AVATAR_ETHAN_BROOKS, AVATAR_MAYA_JOHNSON } from \"./demo-avatars\";\nimport { \n NestedCommentsTable, \n type Comment, \n type CommentAuthor \n} from \"./nested-comments-table\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PostAuthor {\n id: string;\n name: string;\n username?: string;\n avatarUrl?: string;\n}\n\nexport interface ImagePost {\n id: string;\n author: PostAuthor;\n date: string;\n imageUrl: string;\n caption?: string;\n likesCount: number;\n likedByText?: string;\n commentsCount: number;\n isLiked?: boolean;\n isBookmarked?: boolean;\n comments?: Comment[];\n}\n\nexport interface ImageFeedWithNestedCommentsProps {\n /** Section title */\n title?: string;\n /** Section subtitle */\n subtitle?: string;\n /** Posts data */\n posts?: ImagePost[];\n /** Current user for comment avatars */\n currentUser?: CommentAuthor;\n /** Callback when post like is clicked */\n onLike?: (postId: string) => void;\n /** Callback when new comment is submitted */\n onComment?: (postId: string, content: string) => void;\n /** Callback when share is clicked */\n onShare?: (postId: string) => void;\n /** Callback when bookmark is clicked */\n onBookmark?: (postId: string) => void;\n /** Callback when reply is submitted */\n onReply?: (postId: string, commentId: string, content: string) => void;\n /** Callback when comment like is clicked */\n onCommentLike?: (postId: string, commentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: CommentAuthor = {\n id: \"current\",\n name: \"Nicole Palmer\",\n avatarUrl: AVATAR_NICOLE_PALMER,\n};\n\nconst defaultPosts: ImagePost[] = [\n {\n id: \"1\",\n author: {\n id: \"sarah\",\n name: \"Sarah Chen\",\n username: \"sarahchen\",\n avatarUrl: AVATAR_SARAH_CHEN,\n },\n date: \"May 23, 2024\",\n imageUrl: \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=1200&h=800&fit=crop\",\n caption: \"Beautiful day\",\n likesCount: 42,\n likedByText: \"Liked by sc04116 and others\",\n commentsCount: 6,\n isLiked: false,\n isBookmarked: false,\n comments: [\n {\n id: \"c1\",\n author: {\n id: \"ethan\",\n name: \"Ethan Brooks\",\n avatarUrl: AVATAR_ETHAN_BROOKS,\n },\n content: \"Wow, Paris looks absolutely stunning! The Eiffel Tower is such an iconic landmark. Hope you have an amazing time exploring the city and soaking in all its beauty. Safe travels!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 3,\n isLiked: true,\n replies: [\n {\n id: \"r1\",\n author: {\n id: \"maya\",\n name: \"Maya Johnson\",\n avatarUrl: AVATAR_MAYA_JOHNSON,\n },\n content: \"Ah, the City of Love! Your picture brings back memories of my own trip to Paris. The Eiffel Tower is even more breathtaking in person. Have a fantastic time exploring all the charm Paris has to offer!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 0,\n isLiked: false,\n replies: [\n {\n id: \"r2\",\n author: {\n id: \"nicole\",\n name: \"Nicole Palmer\",\n avatarUrl: AVATAR_NICOLE_PALMER,\n },\n content: \"Paris is truly a dream destination! The Eiffel Tower never fails to impress. Enjoy every moment of your adventure and make unforgettable memories. Can't wait to see more of your journey!\",\n timestamp: \"Mar 8, 11:23 AM\",\n likes: 0,\n isLiked: false,\n },\n ],\n },\n ],\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ActionIconsRowProps {\n isLiked?: boolean;\n isBookmarked?: boolean;\n onLike?: () => void;\n onComment?: () => void;\n onShare?: () => void;\n onBookmark?: () => void;\n}\n\nfunction ActionIconsRow({\n isLiked,\n isBookmarked,\n onLike,\n onComment,\n onShare,\n onBookmark,\n}: ActionIconsRowProps) {\n return (\n <div\n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <button\n type=\"button\"\n onClick={onLike}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Heart\n className=\"w-6 h-6\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n stroke: isLiked ? \"var(--canvas-destructive)\" : \"currentColor\",\n }}\n />\n </button>\n <button\n type=\"button\"\n onClick={onComment}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <MessageCircle className=\"w-6 h-6\" />\n </button>\n <button\n type=\"button\"\n onClick={onShare}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Send className=\"w-6 h-6\" />\n </button>\n <div className=\"flex-1 flex justify-end\">\n <button\n type=\"button\"\n onClick={onBookmark}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Bookmark\n className=\"w-6 h-6\"\n style={{\n fill: isBookmarked ? \"currentColor\" : \"transparent\",\n }}\n />\n </button>\n </div>\n </div>\n );\n}\n\ninterface SocialMetadataProps {\n likedByText?: string;\n username?: string;\n caption?: string;\n commentsCount: number;\n onViewComments?: () => void;\n}\n\nfunction SocialMetadata({\n likedByText,\n username,\n caption,\n commentsCount,\n onViewComments,\n}: SocialMetadataProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {likedByText && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {likedByText}\n </p>\n )}\n {caption && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n <span style={{ fontWeight: 600 }}>{username}</span>{\" \"}\n <span style={{ fontWeight: 400 }}>{caption}</span>\n </p>\n )}\n {commentsCount > 0 && (\n <button\n type=\"button\"\n onClick={onViewComments}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n textAlign: \"left\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n }}\n >\n View all {commentsCount} comments\n </button>\n )}\n </div>\n );\n}\n\ninterface PostCardProps {\n post: ImagePost;\n currentUser?: CommentAuthor;\n onLike?: () => void;\n onComment?: (content: string) => void;\n onShare?: () => void;\n onBookmark?: () => void;\n onReply?: (commentId: string, content: string) => void;\n onCommentLike?: (commentId: string) => void;\n}\n\nfunction PostCard({\n post,\n currentUser,\n onLike,\n onComment,\n onShare,\n onBookmark,\n onReply,\n onCommentLike,\n}: PostCardProps) {\n const [showComments, setShowComments] = useState(true);\n\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingBottom: \"var(--spacing-5xl)\",\n }}\n >\n {/* Author Row */}\n <div\n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={post.author.avatarUrl} />\n <AvatarFallback>\n {post.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col flex-1\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {post.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {post.date}\n </span>\n </div>\n </div>\n\n {/* Post Image */}\n <div\n className=\"w-full overflow-hidden\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n <img\n src={post.imageUrl}\n alt={post.caption || \"Post image\"}\n className=\"w-full h-auto object-cover\"\n style={{ maxHeight: 768 }}\n />\n </div>\n\n {/* Action Icons */}\n <ActionIconsRow\n isLiked={post.isLiked}\n isBookmarked={post.isBookmarked}\n onLike={onLike}\n onComment={() => setShowComments(!showComments)}\n onShare={onShare}\n onBookmark={onBookmark}\n />\n\n {/* Social Metadata */}\n <SocialMetadata\n likedByText={post.likedByText}\n username={post.author.username}\n caption={post.caption}\n commentsCount={post.commentsCount}\n onViewComments={() => setShowComments(!showComments)}\n />\n\n {/* Comments Section - using NestedCommentsTable */}\n {showComments && post.comments && post.comments.length > 0 && (\n <NestedCommentsTable\n title=\"\"\n subtitle=\"\"\n comments={post.comments}\n currentUser={currentUser}\n onComment={onComment}\n onReply={onReply}\n onLike={onCommentLike}\n className=\"pt-0\"\n />\n )}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Image Feed with Nested Comments Block\n *\n * An Instagram-style image feed component with large images, social interactions\n * (like/comment/share/bookmark), and nested comment threads. Uses NestedCommentsTable\n * internally for the comments section.\n *\n * @example\n * ```tsx\n * <ImageFeedWithNestedComments\n * title=\"My posts\"\n * subtitle=\"In the past year\"\n * posts={[...]}\n * onLike={(postId) => console.log(\"Liked\", postId)}\n * />\n * ```\n */\nexport function ImageFeedWithNestedComments({\n title = \"My posts\",\n subtitle = \"In the past year\",\n posts = defaultPosts,\n currentUser = defaultCurrentUser,\n onLike,\n onComment,\n onShare,\n onBookmark,\n onReply,\n onCommentLike,\n className,\n}: ImageFeedWithNestedCommentsProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n {(title || subtitle) && <TitleGroup title={title ?? \"\"} subtitle={subtitle} />}\n\n {/* Posts List */}\n <div className=\"flex flex-col w-full\">\n {posts.map((post) => (\n <PostCard\n key={post.id}\n post={post}\n currentUser={currentUser}\n onLike={() => onLike?.(post.id)}\n onComment={(content) => onComment?.(post.id, content)}\n onShare={() => onShare?.(post.id)}\n onBookmark={() => onBookmark?.(post.id)}\n onReply={(commentId, content) => onReply?.(post.id, commentId, content)}\n onCommentLike={(commentId) => onCommentLike?.(post.id, commentId)}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
13
13
|
"lucide-react"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"utils",
|
|
17
|
-
"avatar",
|
|
18
|
-
"title-group",
|
|
19
|
-
"
|
|
16
|
+
"lib/utils",
|
|
17
|
+
"ui/avatar",
|
|
18
|
+
"blocks/title-group",
|
|
19
|
+
"blocks/demo-avatars",
|
|
20
|
+
"blocks/nested-comments-table"
|
|
20
21
|
]
|
|
21
22
|
}
|
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/messenger-sidebar.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { MoreHorizontal, PenSquare } from \"lucide-react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"../ui/avatar\";\nimport { Searchbox } from \"../ui/searchbox\";\n\ninterface ThreadItem {\n id: string;\n name: string;\n avatar?: string;\n lastMessage: string;\n timestamp: string;\n unreadCount?: number;\n isOnline?: boolean;\n}\n\nconst sampleThreads: ThreadItem[] = [\n {\n id: \"1\",\n name: \"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { MoreHorizontal, PenSquare } from \"lucide-react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"../ui/avatar\";\nimport { Searchbox } from \"../ui/searchbox\";\nimport { AVATAR_SARAH_CHEN, AVATAR_ETHAN_BROOKS, AVATAR_JASON_MORALES } from \"./demo-avatars\";\n\ninterface ThreadItem {\n id: string;\n name: string;\n avatar?: string;\n lastMessage: string;\n timestamp: string;\n unreadCount?: number;\n isOnline?: boolean;\n}\n\nconst sampleThreads: ThreadItem[] = [\n {\n id: \"1\",\n name: \"Sarah Chen\",\n avatar: AVATAR_SARAH_CHEN,\n lastMessage: \"Sarah: Thank you so much for sending your...\",\n timestamp: \"Just now\",\n unreadCount: 3,\n },\n {\n id: \"2\",\n name: \"Ethan, Sarah, Marcus\",\n avatar: AVATAR_ETHAN_BROOKS,\n lastMessage: \"You: Hi Ethan, could you take a look at the doc\",\n timestamp: \"30 mins ago\",\n unreadCount: 3,\n },\n {\n id: \"3\",\n name: \"Jason Morales\",\n avatar: AVATAR_JASON_MORALES,\n lastMessage: \"You: Hi Jason, could you take a look at the doc\",\n timestamp: \"30 mins ago\",\n },\n];\n\ninterface MessengerSidebarProps {\n selectedThreadId?: string;\n onSelectThread?: (threadId: string) => void;\n className?: string;\n}\n\nexport function MessengerSidebar({\n selectedThreadId,\n onSelectThread,\n className,\n}: MessengerSidebarProps) {\n const [searchValue, setSearchValue] = useState(\"\");\n\n const filteredThreads = sampleThreads.filter((thread) =>\n thread.name.toLowerCase().includes(searchValue.toLowerCase())\n );\n\n return (\n <aside\n className={`flex flex-col h-full border-r w-full md:w-[375px] shrink-0 ${className || \"\"}`}\n style={{\n borderColor: \"var(--canvas-border)\",\n backgroundColor: \"var(--canvas-background)\",\n }}\n >\n {/* Header */}\n <div\n className=\"flex items-center justify-between px-4 lg:px-[var(--spacing-5xl)] py-[var(--spacing-xl)] border-b shrink-0\"\n style={{ borderColor: \"var(--canvas-border)\" }}\n >\n <h1\n className=\"font-semibold\"\n style={{\n color: \"var(--canvas-foreground)\",\n fontSize: \"var(--typo-body-xl-size)\",\n lineHeight: \"28px\",\n }}\n >\n Messages\n </h1>\n <div className=\"flex items-center gap-[var(--spacing-md)]\">\n <button\n className=\"cursor-pointer flex items-center justify-center size-8 rounded-[var(--radius-xs)] border transition-colors hover:opacity-80\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n borderColor: \"var(--canvas-border)\",\n }}\n aria-label=\"More options\"\n >\n <MoreHorizontal\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n <button\n className=\"cursor-pointer flex items-center justify-center size-8 rounded-[var(--radius-xs)] border transition-colors hover:opacity-80\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n borderColor: \"var(--canvas-border)\",\n }}\n aria-label=\"Compose message\"\n >\n <PenSquare\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n </div>\n </div>\n\n {/* Search */}\n <div className=\"px-4 lg:px-[var(--spacing-5xl)] py-[var(--spacing-xl)] shrink-0 border-b\" style={{ borderColor: \"var(--canvas-border)\" }}>\n <Searchbox\n value={searchValue}\n onChange={setSearchValue}\n placeholder=\"Search messages\"\n inputSize=\"sm\"\n />\n </div>\n\n {/* Thread List */}\n <div className=\"flex-1 overflow-y-auto\">\n {filteredThreads.map((thread) => (\n <ThreadRow\n key={thread.id}\n thread={thread}\n isSelected={selectedThreadId === thread.id}\n onSelect={() => onSelectThread?.(thread.id)}\n />\n ))}\n </div>\n </aside>\n );\n}\n\ninterface ThreadRowProps {\n thread: ThreadItem;\n isSelected?: boolean;\n onSelect?: () => void;\n}\n\nfunction ThreadRow({ thread, isSelected, onSelect }: ThreadRowProps) {\n return (\n <button\n onClick={onSelect}\n className=\"cursor-pointer w-full flex items-center gap-[var(--spacing-xl)] px-4 lg:px-[var(--spacing-5xl)] py-[var(--spacing-xl)] transition-colors text-left border-b\"\n style={{\n backgroundColor: isSelected\n ? \"var(--canvas-surface)\"\n : \"var(--canvas-background)\",\n borderColor: \"var(--canvas-border)\",\n }}\n >\n {/* Avatar with unread badge */}\n <div className=\"relative shrink-0\">\n <Avatar className=\"size-12\">\n <AvatarImage src={thread.avatar} alt={thread.name} />\n <AvatarFallback\n style={{\n fontSize: \"var(--typo-body-xs-size)\",\n backgroundColor: \"var(--canvas-primary)\",\n color: \"var(--canvas-primary-foreground)\",\n }}\n >\n {thread.name\n .split(\" \")\n .map((n) => n[0])\n .join(\"\")}\n </AvatarFallback>\n </Avatar>\n {thread.unreadCount && (\n <div\n className=\"absolute size-5 rounded-full flex items-center justify-center text-[10px] font-semibold\"\n style={{\n backgroundColor: \"var(--canvas-text-placeholder)\",\n color: \"var(--canvas-primary-foreground)\",\n bottom: \"1px\",\n right: \"-2px\",\n }}\n >\n {thread.unreadCount}\n </div>\n )}\n </div>\n\n {/* Content */}\n <div className=\"flex-1 min-w-0 flex flex-col gap-[var(--spacing-xs)]\">\n <div className=\"flex items-center justify-between gap-2\">\n <span\n className=\"font-semibold truncate\"\n style={{\n color: \"var(--canvas-foreground)\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n }}\n >\n {thread.name}\n </span>\n <span\n className=\"shrink-0 font-normal\"\n style={{\n color: \"var(--canvas-text-muted)\",\n fontSize: \"var(--typo-body-xs-size)\",\n lineHeight: \"var(--typo-body-xs-line-height)\",\n }}\n >\n {thread.timestamp}\n </span>\n </div>\n <div className=\"flex items-center gap-2\">\n <span\n className=\"truncate\"\n style={{\n color: \"var(--canvas-text-muted)\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n }}\n >\n {thread.lastMessage}\n </span>\n </div>\n </div>\n </button>\n );\n}\n\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
13
13
|
"lucide-react"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"avatar",
|
|
17
|
-
"searchbox"
|
|
16
|
+
"ui/avatar",
|
|
17
|
+
"ui/searchbox",
|
|
18
|
+
"blocks/demo-avatars"
|
|
18
19
|
]
|
|
19
20
|
}
|
|
@@ -6,17 +6,18 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/nested-comments-table.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { TitleGroup } from \"./title-group\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Input } from \"../ui/input\";\nimport { Heart, Paperclip } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface CommentAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface Comment {\n id: string;\n author: CommentAuthor;\n content: string;\n timestamp: string;\n likes: number;\n isLiked?: boolean;\n replies?: Comment[];\n}\n\nexport interface NestedCommentsTableProps {\n /** Section title */\n title?: string;\n /** Section subtitle */\n subtitle?: string;\n /** Comments data */\n comments?: Comment[];\n /** Current user for comment input avatars */\n currentUser?: CommentAuthor;\n /** Callback when main comment is submitted */\n onComment?: (content: string) => void;\n /** Callback when reply is submitted */\n onReply?: (commentId: string, content: string) => void;\n /** Callback when like is clicked */\n onLike?: (commentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: CommentAuthor = {\n id: \"current\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n};\n\nconst defaultComments: Comment[] = [\n {\n id: \"c1\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Wow, Paris looks absolutely stunning! The Eiffel Tower is such an iconic landmark. Hope you have an amazing time exploring the city and soaking in all its beauty. Safe travels!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 3,\n isLiked: true,\n replies: [\n {\n id: \"r1\",\n author: {\n id: \"mary\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Paris is truly a dream destination! The Eiffel Tower never fails to impress. Enjoy every moment of your adventure and make unforgettable memories. Can't wait to see more of your journey!\",\n timestamp: \"Mar 8, 11:23 AM\",\n likes: 0,\n isLiked: false,\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface CommentInputProps {\n avatarUrl?: string;\n avatarFallback?: string;\n placeholder?: string;\n buttonText?: string;\n size?: \"default\" | \"small\";\n showAttachment?: boolean;\n onSubmit?: (content: string) => void;\n}\n\nfunction CommentInput({\n avatarUrl,\n avatarFallback = \"U\",\n placeholder = \"Send a message\",\n buttonText = \"Send\",\n size = \"default\",\n showAttachment = true,\n onSubmit,\n}: CommentInputProps) {\n const [value, setValue] = useState(\"\");\n const avatarSize = size === \"small\" ? 40 : 48;\n\n const handleSubmit = () => {\n if (value.trim() && onSubmit) {\n onSubmit(value.trim());\n setValue(\"\");\n }\n };\n\n return (\n <div\n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: avatarSize,\n height: avatarSize,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={avatarUrl} />\n <AvatarFallback>{avatarFallback}</AvatarFallback>\n </Avatar>\n <div className=\"flex-1 relative\">\n <Input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={placeholder}\n className=\"pr-10\"\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSubmit();\n }\n }}\n />\n {showAttachment && (\n <button\n type=\"button\"\n className=\"cursor-pointer absolute right-3 top-1/2 -translate-y-1/2\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Paperclip className=\"w-5 h-5\" />\n </button>\n )}\n </div>\n <Button variant=\"primary\" onClick={handleSubmit}>\n {buttonText}\n </Button>\n </div>\n );\n}\n\ninterface CommentActionsProps {\n likes: number;\n isLiked?: boolean;\n timestamp: string;\n onReply?: () => void;\n onLike?: () => void;\n}\n\nfunction CommentActions({\n likes,\n isLiked,\n timestamp,\n onReply,\n onLike,\n}: CommentActionsProps) {\n return (\n <div\n className=\"flex items-center\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-xs)\",\n }}\n >\n <button\n type=\"button\"\n onClick={onReply}\n className=\"cursor-pointer flex items-center\"\n style={{\n gap: \"var(--spacing-sm)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n Reply\n </button>\n <button\n type=\"button\"\n onClick={onLike}\n className=\"cursor-pointer flex items-center\"\n style={{\n gap: \"var(--spacing-sm)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <Heart\n className=\"w-5 h-5\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n stroke: isLiked ? \"var(--canvas-destructive)\" : \"currentColor\",\n }}\n />\n {likes > 0 ? `${likes} likes` : \"Like\"}\n </button>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {timestamp}\n </span>\n </div>\n );\n}\n\ninterface CommentItemProps {\n comment: Comment;\n currentUser?: CommentAuthor;\n depth?: number;\n onReply?: (content: string) => void;\n onLike?: () => void;\n}\n\nfunction CommentItem({\n comment,\n currentUser,\n depth = 0,\n onReply,\n onLike,\n}: CommentItemProps) {\n const [showReplyInput, setShowReplyInput] = useState(false);\n const [showReplies, setShowReplies] = useState(true);\n const hasReplies = comment.replies && comment.replies.length > 0;\n\n // Determine padding based on depth\n const getPaddingLeft = () => {\n if (depth === 0) return \"var(--spacing-7xl)\"; // 64px for first level\n return \"var(--spacing-10xl)\"; // 128px for deeper levels\n };\n\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Comment content */}\n <div\n className=\"flex w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={comment.author.avatarUrl} />\n <AvatarFallback>\n {comment.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex-1 flex flex-col\" style={{ gap: \"var(--spacing-sm)\" }}>\n {/* Author and timestamp */}\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {comment.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {comment.timestamp}\n </span>\n </div>\n {/* Comment text */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {comment.content}\n </p>\n {/* Actions */}\n <CommentActions\n likes={comment.likes}\n isLiked={comment.isLiked}\n timestamp={comment.timestamp}\n onReply={() => setShowReplyInput(!showReplyInput)}\n onLike={onLike}\n />\n </div>\n </div>\n\n {/* Reply input */}\n {showReplyInput && (\n <div style={{ paddingLeft: \"var(--spacing-7xl)\" }}>\n <CommentInput\n avatarUrl={currentUser?.avatarUrl}\n avatarFallback={currentUser?.name?.charAt(0) || \"U\"}\n placeholder=\"Send a message\"\n buttonText=\"Reply\"\n size=\"small\"\n showAttachment={false}\n onSubmit={(content) => {\n onReply?.(content);\n setShowReplyInput(false);\n }}\n />\n </div>\n )}\n\n {/* Hide replies toggle */}\n {hasReplies && (\n <div\n className=\"flex items-center\"\n style={{\n paddingLeft: \"var(--spacing-7xl)\",\n gap: \"var(--spacing-md)\",\n width: 196,\n }}\n >\n <div\n className=\"flex-1 h-px\"\n style={{ backgroundColor: \"var(--canvas-border)\" }}\n />\n <button\n type=\"button\"\n className=\"cursor-pointer\"\n onClick={() => setShowReplies(!showReplies)}\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {showReplies ? \"Hide replies\" : \"Show replies\"}\n </button>\n </div>\n )}\n\n {/* Nested replies */}\n {hasReplies && showReplies && (\n <div\n className=\"flex flex-col\"\n style={{\n paddingLeft: getPaddingLeft(),\n paddingTop: \"var(--spacing-xl)\",\n gap: \"var(--spacing-md)\",\n }}\n >\n {comment.replies!.map((reply) => (\n <CommentItem\n key={reply.id}\n comment={reply}\n currentUser={currentUser}\n depth={depth + 1}\n onReply={onReply}\n onLike={onLike}\n />\n ))}\n </div>\n )}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Nested Comments Table Block\n *\n * A threaded discussion component with nested comments, reply/like actions,\n * and collapsible threads. Perfect for discussion sections, comment threads,\n * or messaging interfaces.\n *\n * @example\n * ```tsx\n * <NestedCommentsTable\n * title=\"My discussions\"\n * subtitle=\"In the past year\"\n * comments={[...]}\n * onComment={(content) => console.log(\"Comment:\", content)}\n * />\n * ```\n */\nexport function NestedCommentsTable({\n title = \"My discussions\",\n subtitle = \"In the past year\",\n comments = defaultComments,\n currentUser = defaultCurrentUser,\n onComment,\n onReply,\n onLike,\n className,\n}: NestedCommentsTableProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <TitleGroup title={title} subtitle={subtitle} />\n\n {/* Comments List Shell */}\n <div\n className=\"flex flex-col w-full\"\n style={{\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingBottom: \"var(--spacing-5xl)\",\n }}\n >\n {/* Main Comment Input */}\n <CommentInput\n avatarUrl={currentUser?.avatarUrl}\n avatarFallback={currentUser?.name?.charAt(0) || \"U\"}\n placeholder=\"Send a message\"\n buttonText=\"Send\"\n onSubmit={onComment}\n />\n\n {/* Comments */}\n {comments && comments.length > 0 && (\n <div\n className=\"flex flex-col\"\n style={{\n paddingLeft: \"var(--spacing-7xl)\",\n paddingTop: \"var(--spacing-xl)\",\n gap: \"var(--spacing-md)\",\n }}\n >\n {comments.map((comment) => (\n <CommentItem\n key={comment.id}\n comment={comment}\n currentUser={currentUser}\n onReply={(content) => onReply?.(comment.id, content)}\n onLike={() => onLike?.(comment.id)}\n />\n ))}\n </div>\n )}\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { TitleGroup } from \"./title-group\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Input } from \"../ui/input\";\nimport { Heart, Paperclip } from \"lucide-react\";\nimport { AVATAR_SARAH_CHEN, AVATAR_ETHAN_BROOKS, AVATAR_NICOLE_PALMER } from \"./demo-avatars\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface CommentAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface Comment {\n id: string;\n author: CommentAuthor;\n content: string;\n timestamp: string;\n likes: number;\n isLiked?: boolean;\n replies?: Comment[];\n}\n\nexport interface NestedCommentsTableProps {\n /** Section title */\n title?: string;\n /** Section subtitle */\n subtitle?: string;\n /** Comments data */\n comments?: Comment[];\n /** Current user for comment input avatars */\n currentUser?: CommentAuthor;\n /** Callback when main comment is submitted */\n onComment?: (content: string) => void;\n /** Callback when reply is submitted */\n onReply?: (commentId: string, content: string) => void;\n /** Callback when like is clicked */\n onLike?: (commentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: CommentAuthor = {\n id: \"current\",\n name: \"Sarah Chen\",\n avatarUrl: AVATAR_SARAH_CHEN,\n};\n\nconst defaultComments: Comment[] = [\n {\n id: \"c1\",\n author: {\n id: \"raj\",\n name: \"Ethan Brooks\",\n avatarUrl: AVATAR_ETHAN_BROOKS,\n },\n content: \"Wow, Paris looks absolutely stunning! The Eiffel Tower is such an iconic landmark. Hope you have an amazing time exploring the city and soaking in all its beauty. Safe travels!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 3,\n isLiked: true,\n replies: [\n {\n id: \"r1\",\n author: {\n id: \"mary\",\n name: \"Nicole Palmer\",\n avatarUrl: AVATAR_NICOLE_PALMER,\n },\n content: \"Paris is truly a dream destination! The Eiffel Tower never fails to impress. Enjoy every moment of your adventure and make unforgettable memories. Can't wait to see more of your journey!\",\n timestamp: \"Mar 8, 11:23 AM\",\n likes: 0,\n isLiked: false,\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface CommentInputProps {\n avatarUrl?: string;\n avatarFallback?: string;\n placeholder?: string;\n buttonText?: string;\n size?: \"default\" | \"small\";\n showAttachment?: boolean;\n onSubmit?: (content: string) => void;\n}\n\nfunction CommentInput({\n avatarUrl,\n avatarFallback = \"U\",\n placeholder = \"Send a message\",\n buttonText = \"Send\",\n size = \"default\",\n showAttachment = true,\n onSubmit,\n}: CommentInputProps) {\n const [value, setValue] = useState(\"\");\n const avatarSize = size === \"small\" ? 40 : 48;\n\n const handleSubmit = () => {\n if (value.trim() && onSubmit) {\n onSubmit(value.trim());\n setValue(\"\");\n }\n };\n\n return (\n <div\n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: avatarSize,\n height: avatarSize,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={avatarUrl} />\n <AvatarFallback>{avatarFallback}</AvatarFallback>\n </Avatar>\n <div className=\"flex-1 relative\">\n <Input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={placeholder}\n className=\"pr-10\"\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSubmit();\n }\n }}\n />\n {showAttachment && (\n <button\n type=\"button\"\n className=\"cursor-pointer absolute right-3 top-1/2 -translate-y-1/2\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Paperclip className=\"w-5 h-5\" />\n </button>\n )}\n </div>\n <Button variant=\"primary\" onClick={handleSubmit}>\n {buttonText}\n </Button>\n </div>\n );\n}\n\ninterface CommentActionsProps {\n likes: number;\n isLiked?: boolean;\n timestamp: string;\n onReply?: () => void;\n onLike?: () => void;\n}\n\nfunction CommentActions({\n likes,\n isLiked,\n timestamp,\n onReply,\n onLike,\n}: CommentActionsProps) {\n return (\n <div\n className=\"flex items-center\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-xs)\",\n }}\n >\n <button\n type=\"button\"\n onClick={onReply}\n className=\"cursor-pointer flex items-center\"\n style={{\n gap: \"var(--spacing-sm)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n Reply\n </button>\n <button\n type=\"button\"\n onClick={onLike}\n className=\"cursor-pointer flex items-center\"\n style={{\n gap: \"var(--spacing-sm)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <Heart\n className=\"w-5 h-5\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n stroke: isLiked ? \"var(--canvas-destructive)\" : \"currentColor\",\n }}\n />\n {likes > 0 ? `${likes} likes` : \"Like\"}\n </button>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {timestamp}\n </span>\n </div>\n );\n}\n\ninterface CommentItemProps {\n comment: Comment;\n currentUser?: CommentAuthor;\n depth?: number;\n onReply?: (content: string) => void;\n onLike?: () => void;\n}\n\nfunction CommentItem({\n comment,\n currentUser,\n depth = 0,\n onReply,\n onLike,\n}: CommentItemProps) {\n const [showReplyInput, setShowReplyInput] = useState(false);\n const [showReplies, setShowReplies] = useState(true);\n const hasReplies = comment.replies && comment.replies.length > 0;\n\n // Determine padding based on depth\n const getPaddingLeft = () => {\n if (depth === 0) return \"var(--spacing-7xl)\"; // 64px for first level\n return \"var(--spacing-10xl)\"; // 128px for deeper levels\n };\n\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Comment content */}\n <div\n className=\"flex w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={comment.author.avatarUrl} />\n <AvatarFallback>\n {comment.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex-1 flex flex-col\" style={{ gap: \"var(--spacing-sm)\" }}>\n {/* Author and timestamp */}\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {comment.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {comment.timestamp}\n </span>\n </div>\n {/* Comment text */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {comment.content}\n </p>\n {/* Actions */}\n <CommentActions\n likes={comment.likes}\n isLiked={comment.isLiked}\n timestamp={comment.timestamp}\n onReply={() => setShowReplyInput(!showReplyInput)}\n onLike={onLike}\n />\n </div>\n </div>\n\n {/* Reply input */}\n {showReplyInput && (\n <div style={{ paddingLeft: \"var(--spacing-7xl)\" }}>\n <CommentInput\n avatarUrl={currentUser?.avatarUrl}\n avatarFallback={currentUser?.name?.charAt(0) || \"U\"}\n placeholder=\"Send a message\"\n buttonText=\"Reply\"\n size=\"small\"\n showAttachment={false}\n onSubmit={(content) => {\n onReply?.(content);\n setShowReplyInput(false);\n }}\n />\n </div>\n )}\n\n {/* Hide replies toggle */}\n {hasReplies && (\n <div\n className=\"flex items-center\"\n style={{\n paddingLeft: \"var(--spacing-7xl)\",\n gap: \"var(--spacing-md)\",\n width: 196,\n }}\n >\n <div\n className=\"flex-1 h-px\"\n style={{ backgroundColor: \"var(--canvas-border)\" }}\n />\n <button\n type=\"button\"\n className=\"cursor-pointer\"\n onClick={() => setShowReplies(!showReplies)}\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {showReplies ? \"Hide replies\" : \"Show replies\"}\n </button>\n </div>\n )}\n\n {/* Nested replies */}\n {hasReplies && showReplies && (\n <div\n className=\"flex flex-col\"\n style={{\n paddingLeft: getPaddingLeft(),\n paddingTop: \"var(--spacing-xl)\",\n gap: \"var(--spacing-md)\",\n }}\n >\n {comment.replies!.map((reply) => (\n <CommentItem\n key={reply.id}\n comment={reply}\n currentUser={currentUser}\n depth={depth + 1}\n onReply={onReply}\n onLike={onLike}\n />\n ))}\n </div>\n )}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Nested Comments Table Block\n *\n * A threaded discussion component with nested comments, reply/like actions,\n * and collapsible threads. Perfect for discussion sections, comment threads,\n * or messaging interfaces.\n *\n * @example\n * ```tsx\n * <NestedCommentsTable\n * title=\"My discussions\"\n * subtitle=\"In the past year\"\n * comments={[...]}\n * onComment={(content) => console.log(\"Comment:\", content)}\n * />\n * ```\n */\nexport function NestedCommentsTable({\n title = \"My discussions\",\n subtitle = \"In the past year\",\n comments = defaultComments,\n currentUser = defaultCurrentUser,\n onComment,\n onReply,\n onLike,\n className,\n}: NestedCommentsTableProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <TitleGroup title={title} subtitle={subtitle} />\n\n {/* Comments List Shell */}\n <div\n className=\"flex flex-col w-full\"\n style={{\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingBottom: \"var(--spacing-5xl)\",\n }}\n >\n {/* Main Comment Input */}\n <CommentInput\n avatarUrl={currentUser?.avatarUrl}\n avatarFallback={currentUser?.name?.charAt(0) || \"U\"}\n placeholder=\"Send a message\"\n buttonText=\"Send\"\n onSubmit={onComment}\n />\n\n {/* Comments */}\n {comments && comments.length > 0 && (\n <div\n className=\"flex flex-col\"\n style={{\n paddingLeft: \"var(--spacing-7xl)\",\n paddingTop: \"var(--spacing-xl)\",\n gap: \"var(--spacing-md)\",\n }}\n >\n {comments.map((comment) => (\n <CommentItem\n key={comment.id}\n comment={comment}\n currentUser={currentUser}\n onReply={(content) => onReply?.(comment.id, content)}\n onLike={() => onLike?.(comment.id)}\n />\n ))}\n </div>\n )}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
13
13
|
"lucide-react"
|
|
14
14
|
],
|
|
15
15
|
"registryDependencies": [
|
|
16
|
-
"utils",
|
|
17
|
-
"button",
|
|
18
|
-
"title-group",
|
|
19
|
-
"avatar",
|
|
20
|
-
"input"
|
|
16
|
+
"lib/utils",
|
|
17
|
+
"ui/button",
|
|
18
|
+
"blocks/title-group",
|
|
19
|
+
"ui/avatar",
|
|
20
|
+
"ui/input",
|
|
21
|
+
"blocks/demo-avatars"
|
|
21
22
|
]
|
|
22
23
|
}
|