canvas-ui-sdk 0.3.13 → 0.3.15
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/README.md +58 -71
- package/dist/index.js +60 -60
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/prompts/CLAUDE.md +85 -0
- package/prompts/bake-theme.md +194 -0
- package/registry/blocks/activity-feed.json +1 -1
- package/registry/blocks/circular-progress-bar-list.json +1 -1
- package/registry/blocks/faqs-table.json +1 -1
- package/registry/blocks/fixed-column-data-table.json +1 -1
- package/registry/blocks/form-group.json +1 -1
- package/registry/blocks/grid-tiles-list.json +1 -1
- package/registry/blocks/image-feed-with-nested-comments.json +1 -1
- package/registry/blocks/large-image-labels-list.json +1 -1
- package/registry/blocks/monthly-calendar-widget.json +1 -1
- package/registry/blocks/nested-comments-table.json +1 -1
- package/registry/blocks/nested-data-table.json +1 -1
- package/registry/blocks/profile-grid-tiles-list.json +1 -1
- package/registry/blocks/reviews-table.json +1 -1
- package/registry/blocks/slideshow-grid-tiles.json +1 -1
- package/registry/blocks/social-feed.json +1 -1
- package/registry/blocks/standard-data-table.json +1 -1
- package/registry/blocks/standard-list-with-image.json +1 -1
- package/registry/blocks/upvoting-posts-table.json +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
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 { 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=\"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=\"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=\"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 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 <div\n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Subtitle */}\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\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 { 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=\"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=\"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=\"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 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 <div\n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Subtitle */}\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\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": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/nested-data-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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\nimport { ChevronRight, ChevronDown, Eye } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ChildRow {\n id: string;\n name: string;\n avatarUrl?: string;\n email: string;\n phone: string;\n}\n\nexport interface ParentRow {\n id: string;\n location: string;\n ftes: number;\n contractors: number;\n hrContact: {\n name: string;\n phone: string;\n };\n children: ChildRow[];\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface NestedDataTableProps {\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?: ParentRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\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 parent row action is clicked */\n onRowAction?: (action: string, row: ParentRow) => void;\n /** Callback when child row action is clicked */\n onChildAction?: (action: string, child: ChildRow, parent: ParentRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: ParentRow[] = [\n {\n id: \"1\",\n location: \"San Francisco, CA\",\n ftes: 320,\n contractors: 66,\n hrContact: {\n name: \"Mary Trott\",\n phone: \"415-232-3434\",\n },\n children: [\n {\n id: \"1-1\",\n name: \"Jeff Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n email: \"jconner@gmail.com\",\n phone: \"508-343-5334\",\n },\n {\n id: \"1-2\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n email: \"eperez@gmail.com\",\n phone: \"234-989-6675\",\n },\n {\n id: \"1-3\",\n name: \"Lily Sun\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n email: \"rmishra@gmail.com\",\n phone: \"205-443-4324\",\n },\n ],\n },\n {\n id: \"2\",\n location: \"New York, NY\",\n ftes: 80,\n contractors: 8,\n hrContact: {\n name: \"Raj Mishra\",\n phone: \"206-646-9834\",\n },\n children: [],\n },\n {\n id: \"3\",\n location: \"Seattle, WA\",\n ftes: 98,\n contractors: 5,\n hrContact: {\n name: \"James Clayton\",\n phone: \"312-687-8675\",\n },\n children: [],\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"location-asc\", label: \"Location (A-Z)\" },\n { id: \"location-desc\", label: \"Location (Z-A)\" },\n { id: \"ftes-high\", label: \"FTEs (High-Low)\" },\n { id: \"ftes-low\", label: \"FTEs (Low-High)\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All locations\" },\n { id: \"us-west\", label: \"US West\" },\n { id: \"us-east\", label: \"US East\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ExpandButtonProps {\n expanded: boolean;\n onClick: () => void;\n disabled?: boolean;\n}\n\nfunction ExpandButton({ expanded, onClick, disabled }: ExpandButtonProps) {\n return (\n <button\n onClick={onClick}\n disabled={disabled}\n className={cn(\n \"flex items-center justify-center shrink-0 transition-colors\",\n disabled ? \"opacity-40 cursor-not-allowed\" : \"cursor-pointer hover:bg-[var(--canvas-surface)]\"\n )}\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n border: \"1px solid var(--canvas-border)\",\n backgroundColor: \"var(--canvas-background)\",\n }}\n aria-label={expanded ? \"Collapse row\" : \"Expand row\"}\n >\n {expanded ? (\n <ChevronDown \n size={20} \n style={{ color: \"var(--canvas-text)\" }}\n />\n ) : (\n <ChevronRight \n size={20} \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n )}\n </button>\n );\n}\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableHeaderCell({ children, className }: TableHeaderCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableCell({ children, className }: TableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction NestedTableCell({ children, className }: NestedTableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableProps {\n children: ChildRow[];\n onChildAction?: (action: string, child: ChildRow) => void;\n}\n\nfunction NestedTable({ children, onChildAction }: NestedTableProps) {\n if (children.length === 0) return null;\n\n return (\n <div\n className=\"w-full overflow-hidden\"\n style={{\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Nested Table Header */}\n <div\n className=\"grid items-center\"\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Name</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Email</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Phone number</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Nested Table Rows */}\n {children.map((child, index) => (\n <div\n key={child.id}\n className=\"grid items-center\"\n style={{\n borderBottom: index < children.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n padding: \"var(--spacing-md) 0\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={child.avatarUrl} alt={child.name} />\n <AvatarFallback>\n {child.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{child.name}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.email}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.phone}</span>\n </NestedTableCell>\n </div>\n <div className=\"flex justify-center\" style={{ padding: \"0 var(--spacing-lg)\" }}>\n <button\n onClick={() => onChildAction?.(\"view\", child)}\n className=\"flex items-center justify-center size-8 rounded-full hover:bg-[var(--canvas-surface)] transition-colors\"\n aria-label={`View ${child.name}`}\n >\n <Eye size={20} style={{ color: \"var(--canvas-text-muted)\" }} />\n </button>\n </div>\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Nested Data Table Block\n * \n * An expandable data table with parent rows that reveal nested child tables.\n * Ideal for displaying hierarchical data like locations with employees,\n * departments with team members, or categories with items.\n * \n * @example\n * ```tsx\n * <NestedDataTable\n * title=\"FTEs & Contractors by Location\"\n * data={locationData}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function NestedDataTable({\n title = \"FTEs & Contractors by Location\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onRowAction,\n onChildAction,\n className,\n}: NestedDataTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set([\"1\"])); // First row expanded by default\n\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n const toggleRow = (rowId: string) => {\n setExpandedRows(prev => {\n const next = new Set(prev);\n if (next.has(rowId)) {\n next.delete(rowId);\n } else {\n next.add(rowId);\n }\n return next;\n });\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-start justify-end shrink-0 gap-3\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Table Section */}\n <div className=\"w-full overflow-x-auto\">\n <div className=\"min-w-[800px]\">\n {/* Table Header */}\n <div\n className=\"grid items-center\"\n style={{ \n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n <div style={{ paddingLeft: \"var(--spacing-6xl)\" }}>\n <TableHeaderCell>Location</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>FTEs</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>Contractors</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>HR Contact</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Table Rows */}\n {data.map((row, index) => {\n const isExpanded = expandedRows.has(row.id);\n const hasChildren = row.children && row.children.length > 0;\n\n return (\n <div\n key={row.id}\n className=\"flex flex-col\"\n style={{\n borderBottom: index < data.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n paddingBottom: isExpanded ? \"var(--spacing-xl)\" : \"0\",\n }}\n >\n {/* Parent Row */}\n <div\n className=\"grid items-center\"\n style={{\n padding: \"var(--spacing-md) 0\",\n minHeight: \"64px\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n {/* Location with Expand Button */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xl)\" }}>\n <ExpandButton\n expanded={isExpanded}\n onClick={() => toggleRow(row.id)}\n disabled={!hasChildren}\n />\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.location}</span>\n </TableCell>\n </div>\n\n {/* FTEs */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.ftes}</span>\n </TableCell>\n </div>\n\n {/* Contractors */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.contractors}</span>\n </TableCell>\n </div>\n\n {/* HR Contact */}\n <div>\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xxs, 2px)\" }}>\n <span\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 whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.phone}\n </span>\n </div>\n </div>\n\n {/* Actions */}\n <div className=\"flex justify-center\">\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: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </div>\n </div>\n\n {/* Nested Table */}\n {isExpanded && hasChildren && (\n <div\n style={{\n paddingLeft: \"var(--spacing-6xl)\",\n paddingRight: \"var(--spacing-lg)\",\n }}\n >\n <NestedTable\n children={row.children}\n onChildAction={(action, child) => onChildAction?.(action, child, row)}\n />\n </div>\n )}\n </div>\n );\n })}\n </div>\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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MenufocusTemplate } from \"./menufocus-template\";\nimport { ChevronRight, ChevronDown, Eye } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ChildRow {\n id: string;\n name: string;\n avatarUrl?: string;\n email: string;\n phone: string;\n}\n\nexport interface ParentRow {\n id: string;\n location: string;\n ftes: number;\n contractors: number;\n hrContact: {\n name: string;\n phone: string;\n };\n children: ChildRow[];\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface NestedDataTableProps {\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?: ParentRow[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\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 parent row action is clicked */\n onRowAction?: (action: string, row: ParentRow) => void;\n /** Callback when child row action is clicked */\n onChildAction?: (action: string, child: ChildRow, parent: ParentRow) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultData: ParentRow[] = [\n {\n id: \"1\",\n location: \"San Francisco, CA\",\n ftes: 320,\n contractors: 66,\n hrContact: {\n name: \"Mary Trott\",\n phone: \"415-232-3434\",\n },\n children: [\n {\n id: \"1-1\",\n name: \"Jeff Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n email: \"jconner@gmail.com\",\n phone: \"508-343-5334\",\n },\n {\n id: \"1-2\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n email: \"eperez@gmail.com\",\n phone: \"234-989-6675\",\n },\n {\n id: \"1-3\",\n name: \"Lily Sun\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n email: \"rmishra@gmail.com\",\n phone: \"205-443-4324\",\n },\n ],\n },\n {\n id: \"2\",\n location: \"New York, NY\",\n ftes: 80,\n contractors: 8,\n hrContact: {\n name: \"Raj Mishra\",\n phone: \"206-646-9834\",\n },\n children: [],\n },\n {\n id: \"3\",\n location: \"Seattle, WA\",\n ftes: 98,\n contractors: 5,\n hrContact: {\n name: \"James Clayton\",\n phone: \"312-687-8675\",\n },\n children: [],\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"location-asc\", label: \"Location (A-Z)\" },\n { id: \"location-desc\", label: \"Location (Z-A)\" },\n { id: \"ftes-high\", label: \"FTEs (High-Low)\" },\n { id: \"ftes-low\", label: \"FTEs (Low-High)\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All locations\" },\n { id: \"us-west\", label: \"US West\" },\n { id: \"us-east\", label: \"US East\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ExpandButtonProps {\n expanded: boolean;\n onClick: () => void;\n disabled?: boolean;\n}\n\nfunction ExpandButton({ expanded, onClick, disabled }: ExpandButtonProps) {\n return (\n <button\n onClick={onClick}\n disabled={disabled}\n className={cn(\n \"flex items-center justify-center shrink-0 transition-colors\",\n disabled ? \"opacity-40 cursor-not-allowed\" : \"cursor-pointer hover:bg-[var(--canvas-surface)]\"\n )}\n style={{\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-xs)\",\n border: \"1px solid var(--canvas-border)\",\n backgroundColor: \"var(--canvas-background)\",\n }}\n aria-label={expanded ? \"Collapse row\" : \"Expand row\"}\n >\n {expanded ? (\n <ChevronDown \n size={20} \n style={{ color: \"var(--canvas-text)\" }}\n />\n ) : (\n <ChevronRight \n size={20} \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n )}\n </button>\n );\n}\n\ninterface TableHeaderCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableHeaderCell({ children, className }: TableHeaderCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface TableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction TableCell({ children, className }: TableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableCellProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction NestedTableCell({ children, className }: NestedTableCellProps) {\n return (\n <div\n className={cn(\n \"flex items-center gap-2 h-8 overflow-hidden\",\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 }}\n >\n {children}\n </div>\n );\n}\n\ninterface NestedTableProps {\n children: ChildRow[];\n onChildAction?: (action: string, child: ChildRow) => void;\n}\n\nfunction NestedTable({ children, onChildAction }: NestedTableProps) {\n if (children.length === 0) return null;\n\n return (\n <div\n className=\"w-full overflow-hidden\"\n style={{\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Nested Table Header */}\n <div\n className=\"grid items-center\"\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Name</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Email</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell>Phone number</TableHeaderCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Nested Table Rows */}\n {children.map((child, index) => (\n <div\n key={child.id}\n className=\"grid items-center\"\n style={{\n borderBottom: index < children.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n padding: \"var(--spacing-md) 0\",\n gridTemplateColumns: \"minmax(160px, 1fr) minmax(160px, 1fr) minmax(120px, 1fr) 56px\",\n }}\n >\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <Avatar className=\"size-8 border border-[var(--canvas-border)]\">\n <AvatarImage src={child.avatarUrl} alt={child.name} />\n <AvatarFallback>\n {child.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <span className=\"whitespace-nowrap\">{child.name}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.email}</span>\n </NestedTableCell>\n </div>\n <div style={{ padding: \"0 var(--spacing-lg)\" }}>\n <NestedTableCell>\n <span className=\"whitespace-nowrap\">{child.phone}</span>\n </NestedTableCell>\n </div>\n <div className=\"flex justify-center\" style={{ padding: \"0 var(--spacing-lg)\" }}>\n <button\n onClick={() => onChildAction?.(\"view\", child)}\n className=\"flex items-center justify-center size-8 rounded-full hover:bg-[var(--canvas-surface)] transition-colors\"\n aria-label={`View ${child.name}`}\n >\n <Eye size={20} style={{ color: \"var(--canvas-text-muted)\" }} />\n </button>\n </div>\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Nested Data Table Block\n * \n * An expandable data table with parent rows that reveal nested child tables.\n * Ideal for displaying hierarchical data like locations with employees,\n * departments with team members, or categories with items.\n * \n * @example\n * ```tsx\n * <NestedDataTable\n * title=\"FTEs & Contractors by Location\"\n * data={locationData}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function NestedDataTable({\n title = \"FTEs & Contractors by Location\",\n resultCount,\n resultCountText,\n data = defaultData,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onRowAction,\n onChildAction,\n className,\n}: NestedDataTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set([\"1\"])); // First row expanded by default\n\n const displayResultCount = resultCount ?? data.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n const toggleRow = (rowId: string) => {\n setExpandedRows(prev => {\n const next = new Set(prev);\n if (next.has(rowId)) {\n next.delete(rowId);\n } else {\n next.add(rowId);\n }\n return next;\n });\n };\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 <div \n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-end justify-end shrink-0 gap-3 max-sm:w-full max-sm:flex-wrap\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Table Section */}\n <div className=\"w-full overflow-x-auto\">\n <div className=\"min-w-[800px]\">\n {/* Table Header */}\n <div\n className=\"grid items-center\"\n style={{ \n borderBottom: \"1px solid var(--canvas-border)\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n <div style={{ paddingLeft: \"var(--spacing-6xl)\" }}>\n <TableHeaderCell>Location</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>FTEs</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>Contractors</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell>HR Contact</TableHeaderCell>\n </div>\n <div>\n <TableHeaderCell> </TableHeaderCell>\n </div>\n </div>\n\n {/* Table Rows */}\n {data.map((row, index) => {\n const isExpanded = expandedRows.has(row.id);\n const hasChildren = row.children && row.children.length > 0;\n\n return (\n <div\n key={row.id}\n className=\"flex flex-col\"\n style={{\n borderBottom: index < data.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n paddingBottom: isExpanded ? \"var(--spacing-xl)\" : \"0\",\n }}\n >\n {/* Parent Row */}\n <div\n className=\"grid items-center\"\n style={{\n padding: \"var(--spacing-md) 0\",\n minHeight: \"64px\",\n gridTemplateColumns: \"minmax(220px, 1.5fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(160px, 1.2fr) 40px\",\n }}\n >\n {/* Location with Expand Button */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xl)\" }}>\n <ExpandButton\n expanded={isExpanded}\n onClick={() => toggleRow(row.id)}\n disabled={!hasChildren}\n />\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.location}</span>\n </TableCell>\n </div>\n\n {/* FTEs */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.ftes}</span>\n </TableCell>\n </div>\n\n {/* Contractors */}\n <div>\n <TableCell>\n <span className=\"whitespace-nowrap\">{row.contractors}</span>\n </TableCell>\n </div>\n\n {/* HR Contact */}\n <div>\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xxs, 2px)\" }}>\n <span\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 whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {row.hrContact.phone}\n </span>\n </div>\n </div>\n\n {/* Actions */}\n <div className=\"flex justify-center\">\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: \"delete\", label: \"Delete\", variant: \"destructive\", onClick: () => onRowAction?.(\"delete\", row) },\n ]}\n />\n </div>\n </div>\n\n {/* Nested Table */}\n {isExpanded && hasChildren && (\n <div\n style={{\n paddingLeft: \"var(--spacing-6xl)\",\n paddingRight: \"var(--spacing-lg)\",\n }}\n >\n <NestedTable\n children={row.children}\n onChildAction={(action, child) => onChildAction?.(action, child, row)}\n />\n </div>\n )}\n </div>\n );\n })}\n </div>\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/profile-grid-tiles-list.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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MapPin, BookOpen, Video, DollarSign, Star } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ProfileTileItem {\n id: string;\n name: string;\n avatarUrl?: string;\n isOnline?: boolean;\n subject: string;\n pricePerHour: number;\n rating: number;\n reviewCount: string;\n certification?: string;\n location: string;\n education: string;\n sessionsCount: string;\n earnings: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface ProfileGridTilesListProps {\n /** Block title */\n title?: string;\n /** Subtitle text (e.g., \"23 english tutors near you\") */\n subtitle?: string;\n /** Array of profile tile items */\n items?: ProfileTileItem[];\n /** Number of columns in the grid (2-5) */\n columns?: 2 | 3 | 4 | 5;\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when 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 a profile is clicked */\n onItemClick?: (item: ProfileTileItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ProfileTileItem[] = [\n {\n id: \"1\",\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 80,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"San Francisco\",\n education: \"UCLA\",\n sessionsCount: \"105 sessions\",\n earnings: \"5.2k earned\",\n },\n {\n id: \"2\",\n name: \"Stacy Jones\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"Native speaker\",\n location: \"New York\",\n education: \"Columbia\",\n sessionsCount: \"23 sessions\",\n earnings: \"2k earned\",\n },\n {\n id: \"3\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Newark\",\n education: \"Rutgers\",\n sessionsCount: \"34 sessions\",\n earnings: \"2.1k earned\",\n },\n {\n id: \"4\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Connecticut\",\n education: \"Yale\",\n sessionsCount: \"75 sessions\",\n earnings: \"91k earned\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"rating\", label: \"Highest Rated\" },\n { id: \"price-low\", label: \"Price: Low to High\" },\n { id: \"price-high\", label: \"Price: High to Low\" },\n { id: \"sessions\", label: \"Most Sessions\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All Tutors\" },\n { id: \"online\", label: \"Online Now\" },\n { id: \"native\", label: \"Native Speaker\" },\n { id: \"certified\", label: \"Certified\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ProfileTileCardProps {\n item: ProfileTileItem;\n onClick?: (item: ProfileTileItem) => void;\n}\n\nfunction ProfileTileCard({ item, onClick }: ProfileTileCardProps) {\n return (\n <div\n className=\"flex flex-col\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md, 8px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n overflow: \"hidden\",\n }}\n >\n {/* Main Content */}\n <div\n className=\"flex flex-col items-center w-full\"\n style={{\n padding: \"var(--spacing-4xl) var(--spacing-4xl) 0\",\n gap: \"var(--spacing-2xl)\",\n }}\n >\n {/* Avatar Section */}\n <div className=\"flex flex-col items-center w-full\" style={{ gap: \"var(--radius-md, 8px)\" }}>\n {/* Avatar with Online Indicator */}\n <div className=\"relative shrink-0\" style={{ width: \"120px\", height: \"120px\" }}>\n <Avatar\n className=\"w-full h-full\"\n style={{\n width: \"120px\",\n height: \"120px\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={item.avatarUrl} alt={item.name} />\n <AvatarFallback style={{ fontSize: \"var(--typo-h4-size)\" }}>\n {item.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n {item.isOnline && (\n <div\n className=\"absolute\"\n style={{\n width: \"20px\",\n height: \"20px\",\n right: \"4px\",\n bottom: \"7px\",\n backgroundColor: \"var(--canvas-success)\",\n borderRadius: \"var(--radius-full, 50%)\",\n border: \"2px solid var(--canvas-background)\",\n }}\n />\n )}\n </div>\n\n {/* Name, Subject, Price */}\n <div\n className=\"flex flex-col items-center text-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\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 {item.name}\n </p>\n <p\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 margin: 0,\n }}\n >\n {item.subject}\n </p>\n <p\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 margin: 0,\n }}\n >\n <span style={{ fontWeight: 600 }}>${item.pricePerHour}</span> / hour\n </p>\n </div>\n </div>\n\n {/* Rating Row */}\n <div\n className=\"flex items-center justify-center w-full\"\n style={{ gap: \"4px\" }}\n >\n <Star\n className=\"w-5 h-5\"\n style={{ color: \"var(--canvas-primary)\", fill: \"var(--canvas-primary)\" }}\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 {item.rating}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n ({item.reviewCount})\n </span>\n </div>\n\n {/* Divider */}\n <div\n className=\"w-full\"\n style={{\n height: \"1px\",\n backgroundColor: \"var(--canvas-border)\",\n }}\n />\n\n {/* Certification Badge */}\n {item.certification && (\n <p\n className=\"text-center\"\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-muted)\",\n margin: 0,\n }}\n >\n {item.certification}\n </p>\n )}\n\n {/* Metadata Rows */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Location */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <MapPin\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.location}\n </span>\n </div>\n\n {/* Education */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <BookOpen\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.education}\n </span>\n </div>\n\n {/* Sessions */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <Video\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.sessionsCount}\n </span>\n </div>\n\n {/* Earnings */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <DollarSign\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.earnings}\n </span>\n </div>\n </div>\n </div>\n\n {/* Footer - View Profile Button */}\n <div\n className=\"flex items-center justify-center w-full cursor-pointer\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-xl)\",\n marginTop: \"var(--spacing-2xl)\",\n }}\n onClick={() => onClick?.(item)}\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-primary)\",\n }}\n >\n View profile >\n </span>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Grid Constants\n// ============================================\n\nconst CARD_MIN_WIDTH = 300;\nconst GRID_GAP_PX = 32; // --spacing-4xl = 32px\n\n/**\n * Get inline styles for the grid based on columns configuration\n * Uses CSS Grid with auto-fill and minmax for:\n * - Minimum card width of 300px\n * - Equal width for all cards (via 1fr)\n * - Automatic wrapping when container is narrower\n * - Maximum columns limited by the columns prop\n */\nfunction getGridStyle(columns: 2 | 3 | 4 | 5): React.CSSProperties {\n // Calculate max-width to limit the number of columns\n // Formula: columns * minWidth + (columns - 1) * gap\n const maxGridWidth = columns * CARD_MIN_WIDTH + (columns - 1) * GRID_GAP_PX;\n \n return {\n display: 'grid',\n gridTemplateColumns: `repeat(auto-fill, minmax(${CARD_MIN_WIDTH}px, 1fr))`,\n gap: 'var(--spacing-4xl)',\n width: '100%',\n maxWidth: `${maxGridWidth}px`,\n };\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Profile Grid Tiles List Block\n *\n * A responsive grid of profile cards with avatar, ratings, certifications,\n * and metadata. Supports 2-5 column layouts that adapt to screen size.\n *\n * @example\n * ```tsx\n * <ProfileGridTilesList\n * title=\"Tutors near you\"\n * subtitle=\"23 english tutors near you\"\n * columns={4}\n * onItemClick={(item) => console.log(\"Clicked\", item)}\n * />\n * ```\n */\nexport function ProfileGridTilesList({\n title = \"Tutors near you\",\n subtitle,\n items = defaultItems,\n columns = 4,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onItemClick,\n className,\n}: ProfileGridTilesListProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displaySubtitle = subtitle ?? `${items.length} tutors available`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\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 <div\n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Subtitle */}\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displaySubtitle}\n </p>\n </div>\n\n {/* Controls */}\n <div\n className=\"flex flex-wrap items-start justify-end shrink-0\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Grid Section */}\n <div style={getGridStyle(columns)}>\n {items.map((item) => (\n <ProfileTileCard\n key={item.id}\n item={item}\n onClick={onItemClick}\n />\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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { MapPin, BookOpen, Video, DollarSign, Star } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ProfileTileItem {\n id: string;\n name: string;\n avatarUrl?: string;\n isOnline?: boolean;\n subject: string;\n pricePerHour: number;\n rating: number;\n reviewCount: string;\n certification?: string;\n location: string;\n education: string;\n sessionsCount: string;\n earnings: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface ProfileGridTilesListProps {\n /** Block title */\n title?: string;\n /** Subtitle text (e.g., \"23 english tutors near you\") */\n subtitle?: string;\n /** Array of profile tile items */\n items?: ProfileTileItem[];\n /** Number of columns in the grid (2-5) */\n columns?: 2 | 3 | 4 | 5;\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when 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 a profile is clicked */\n onItemClick?: (item: ProfileTileItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ProfileTileItem[] = [\n {\n id: \"1\",\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 80,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"San Francisco\",\n education: \"UCLA\",\n sessionsCount: \"105 sessions\",\n earnings: \"5.2k earned\",\n },\n {\n id: \"2\",\n name: \"Stacy Jones\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"Native speaker\",\n location: \"New York\",\n education: \"Columbia\",\n sessionsCount: \"23 sessions\",\n earnings: \"2k earned\",\n },\n {\n id: \"3\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Newark\",\n education: \"Rutgers\",\n sessionsCount: \"34 sessions\",\n earnings: \"2.1k earned\",\n },\n {\n id: \"4\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n isOnline: true,\n subject: \"English\",\n pricePerHour: 75,\n rating: 4.3,\n reviewCount: \"2.4k\",\n certification: \"TEFL certification\",\n location: \"Connecticut\",\n education: \"Yale\",\n sessionsCount: \"75 sessions\",\n earnings: \"91k earned\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"rating\", label: \"Highest Rated\" },\n { id: \"price-low\", label: \"Price: Low to High\" },\n { id: \"price-high\", label: \"Price: High to Low\" },\n { id: \"sessions\", label: \"Most Sessions\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All Tutors\" },\n { id: \"online\", label: \"Online Now\" },\n { id: \"native\", label: \"Native Speaker\" },\n { id: \"certified\", label: \"Certified\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ProfileTileCardProps {\n item: ProfileTileItem;\n onClick?: (item: ProfileTileItem) => void;\n}\n\nfunction ProfileTileCard({ item, onClick }: ProfileTileCardProps) {\n return (\n <div\n className=\"flex flex-col\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md, 8px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n overflow: \"hidden\",\n }}\n >\n {/* Main Content */}\n <div\n className=\"flex flex-col items-center w-full\"\n style={{\n padding: \"var(--spacing-4xl) var(--spacing-4xl) 0\",\n gap: \"var(--spacing-2xl)\",\n }}\n >\n {/* Avatar Section */}\n <div className=\"flex flex-col items-center w-full\" style={{ gap: \"var(--radius-md, 8px)\" }}>\n {/* Avatar with Online Indicator */}\n <div className=\"relative shrink-0\" style={{ width: \"120px\", height: \"120px\" }}>\n <Avatar\n className=\"w-full h-full\"\n style={{\n width: \"120px\",\n height: \"120px\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={item.avatarUrl} alt={item.name} />\n <AvatarFallback style={{ fontSize: \"var(--typo-h4-size)\" }}>\n {item.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n {item.isOnline && (\n <div\n className=\"absolute\"\n style={{\n width: \"20px\",\n height: \"20px\",\n right: \"4px\",\n bottom: \"7px\",\n backgroundColor: \"var(--canvas-success)\",\n borderRadius: \"var(--radius-full, 50%)\",\n border: \"2px solid var(--canvas-background)\",\n }}\n />\n )}\n </div>\n\n {/* Name, Subject, Price */}\n <div\n className=\"flex flex-col items-center text-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\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 {item.name}\n </p>\n <p\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 margin: 0,\n }}\n >\n {item.subject}\n </p>\n <p\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 margin: 0,\n }}\n >\n <span style={{ fontWeight: 600 }}>${item.pricePerHour}</span> / hour\n </p>\n </div>\n </div>\n\n {/* Rating Row */}\n <div\n className=\"flex items-center justify-center w-full\"\n style={{ gap: \"4px\" }}\n >\n <Star\n className=\"w-5 h-5\"\n style={{ color: \"var(--canvas-primary)\", fill: \"var(--canvas-primary)\" }}\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 {item.rating}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n ({item.reviewCount})\n </span>\n </div>\n\n {/* Divider */}\n <div\n className=\"w-full\"\n style={{\n height: \"1px\",\n backgroundColor: \"var(--canvas-border)\",\n }}\n />\n\n {/* Certification Badge */}\n {item.certification && (\n <p\n className=\"text-center\"\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-muted)\",\n margin: 0,\n }}\n >\n {item.certification}\n </p>\n )}\n\n {/* Metadata Rows */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-lg)\" }}\n >\n {/* Location */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <MapPin\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.location}\n </span>\n </div>\n\n {/* Education */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <BookOpen\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.education}\n </span>\n </div>\n\n {/* Sessions */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <Video\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.sessionsCount}\n </span>\n </div>\n\n {/* Earnings */}\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-sm)\" }}>\n <DollarSign\n className=\"w-5 h-5 shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.earnings}\n </span>\n </div>\n </div>\n </div>\n\n {/* Footer - View Profile Button */}\n <div\n className=\"flex items-center justify-center w-full cursor-pointer\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-xl)\",\n marginTop: \"var(--spacing-2xl)\",\n }}\n onClick={() => onClick?.(item)}\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-primary)\",\n }}\n >\n View profile >\n </span>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Grid Constants\n// ============================================\n\nconst CARD_MIN_WIDTH = 300;\nconst GRID_GAP_PX = 32; // --spacing-4xl = 32px\n\n/**\n * Get inline styles for the grid based on columns configuration\n * Uses CSS Grid with auto-fill and minmax for:\n * - Minimum card width of 300px\n * - Equal width for all cards (via 1fr)\n * - Automatic wrapping when container is narrower\n * - Maximum columns limited by the columns prop\n */\nfunction getGridStyle(columns: 2 | 3 | 4 | 5): React.CSSProperties {\n // Calculate max-width to limit the number of columns\n // Formula: columns * minWidth + (columns - 1) * gap\n const maxGridWidth = columns * CARD_MIN_WIDTH + (columns - 1) * GRID_GAP_PX;\n \n return {\n display: 'grid',\n gridTemplateColumns: `repeat(auto-fill, minmax(${CARD_MIN_WIDTH}px, 1fr))`,\n gap: 'var(--spacing-4xl)',\n width: '100%',\n maxWidth: `${maxGridWidth}px`,\n };\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Profile Grid Tiles List Block\n *\n * A responsive grid of profile cards with avatar, ratings, certifications,\n * and metadata. Supports 2-5 column layouts that adapt to screen size.\n *\n * @example\n * ```tsx\n * <ProfileGridTilesList\n * title=\"Tutors near you\"\n * subtitle=\"23 english tutors near you\"\n * columns={4}\n * onItemClick={(item) => console.log(\"Clicked\", item)}\n * />\n * ```\n */\nexport function ProfileGridTilesList({\n title = \"Tutors near you\",\n subtitle,\n items = defaultItems,\n columns = 4,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onItemClick,\n className,\n}: ProfileGridTilesListProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displaySubtitle = subtitle ?? `${items.length} tutors available`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\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 <div\n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Subtitle */}\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displaySubtitle}\n </p>\n </div>\n\n {/* Controls */}\n <div\n className=\"flex flex-wrap items-end justify-end shrink-0 max-sm:w-full max-sm:flex-wrap\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Grid Section */}\n <div style={getGridStyle(columns)}>\n {items.map((item) => (\n <ProfileTileCard\n key={item.id}\n item={item}\n onClick={onItemClick}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/reviews-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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ReviewItem {\n id: string;\n name: string;\n avatarUrl?: string;\n rating: number; // 1-5\n date: string;\n reviewText: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface ReviewsTableProps {\n /** Table title */\n title?: string;\n /** Number of reviews to display in subtitle */\n reviewCount?: number;\n /** Custom review count text (overrides default \"{count} customer reviews\") */\n reviewCountText?: string;\n /** Review data */\n reviews?: ReviewItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\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 \"More\" is clicked on a review */\n onReadMore?: (review: ReviewItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultReviews: ReviewItem[] = [\n {\n id: \"1\",\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n rating: 5,\n date: \"Jul 22, 2024\",\n reviewText: \"Absolutely loved my experience at Sushi Ro! The ambiance was cozy, and the staff were incredibly attentive. The food was outstanding, with a diverse menu offering authentic flavors. Highly recommend trying the omakase...\",\n },\n {\n id: \"2\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n rating: 5,\n date: \"Jul 12, 2024\",\n reviewText: \"From the moment we walked in, we were greeted warmly and seated promptly. The omakase menu offered a delightful range of dishes, each bursting with flavor and beautifully presented...\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n { id: \"rating-high\", label: \"Highest rated\" },\n { id: \"rating-low\", label: \"Lowest rated\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All ratings\" },\n { id: \"5-star\", label: \"5 stars\" },\n { id: \"4-star\", label: \"4 stars\" },\n { id: \"3-star\", label: \"3 stars\" },\n { id: \"2-star\", label: \"2 stars\" },\n { id: \"1-star\", label: \"1 star\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface StarRatingProps {\n rating: number;\n maxRating?: number;\n}\n\nfunction StarRating({ rating, maxRating = 5 }: StarRatingProps) {\n return (\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {Array.from({ length: maxRating }, (_, i) => (\n <svg\n key={i}\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M10 1.66667L12.575 6.88334L18.3333 7.725L14.1667 11.7833L15.15 17.5167L10 14.8083L4.85 17.5167L5.83333 11.7833L1.66667 7.725L7.425 6.88334L10 1.66667Z\"\n fill={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n stroke={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n ))}\n </div>\n );\n}\n\ninterface ReviewListItemProps {\n review: ReviewItem;\n isFirst?: boolean;\n isLast?: boolean;\n onReadMore?: (review: ReviewItem) => void;\n}\n\nfunction ReviewListItem({ review, isFirst, isLast, onReadMore }: ReviewListItemProps) {\n return (\n <div\n className=\"w-full\"\n style={{\n borderTop: isFirst ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingTop: \"var(--spacing-3xl)\",\n paddingBottom: \"var(--spacing-3xl)\",\n }}\n >\n <div \n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Top Row: Avatar + Name/Rating + Date */}\n <div \n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Avatar */}\n <Avatar \n className=\"shrink-0\"\n style={{ \n width: \"48px\", \n height: \"48px\",\n borderRadius: \"var(--spacing-3xl)\",\n }}\n >\n <AvatarImage src={review.avatarUrl} alt={review.name} />\n <AvatarFallback\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {review.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n\n {/* Name and Rating */}\n <div \n className=\"flex flex-col flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <p\n className=\"m-0\"\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 {review.name}\n </p>\n <StarRating rating={review.rating} />\n </div>\n\n {/* Date */}\n <p\n className=\"m-0 shrink-0\"\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-muted)\",\n }}\n >\n {review.date}\n </p>\n </div>\n\n {/* Review Text */}\n <p\n className=\"m-0 w-full\"\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-muted)\",\n }}\n >\n {review.reviewText}\n </p>\n\n {/* More Link */}\n <button\n onClick={() => onReadMore?.(review)}\n className=\"p-0 border-none bg-transparent cursor-pointer text-left\"\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-primary)\",\n }}\n >\n More\n </button>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Reviews Table Block\n * \n * A configurable reviews listing with header section including title,\n * sort/filter dropdowns, and action button. Displays review items\n * with avatar, star rating, date, and expandable text.\n * \n * @example\n * ```tsx\n * <ReviewsTable\n * title=\"Reviews\"\n * reviews={[\n * { id: \"1\", name: \"John Doe\", rating: 5, date: \"Jul 22, 2024\", reviewText: \"Great experience!\" }\n * ]}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function ReviewsTable({\n title = \"Reviews\",\n reviewCount,\n reviewCountText,\n reviews = defaultReviews,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onReadMore,\n className,\n}: ReviewsTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayReviewCount = reviewCount ?? reviews.length;\n const displayReviewText = reviewCountText ?? `${displayReviewCount} customer reviews`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\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 <div \n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displayReviewText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-start justify-end shrink-0 gap-3\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Reviews List */}\n <div className=\"flex flex-col w-full\">\n {reviews.map((review, index) => (\n <ReviewListItem\n key={review.id}\n review={review}\n isFirst={index === 0}\n isLast={index === reviews.length - 1}\n onReadMore={onReadMore}\n />\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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface ReviewItem {\n id: string;\n name: string;\n avatarUrl?: string;\n rating: number; // 1-5\n date: string;\n reviewText: string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface ReviewsTableProps {\n /** Table title */\n title?: string;\n /** Number of reviews to display in subtitle */\n reviewCount?: number;\n /** Custom review count text (overrides default \"{count} customer reviews\") */\n reviewCountText?: string;\n /** Review data */\n reviews?: ReviewItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\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 \"More\" is clicked on a review */\n onReadMore?: (review: ReviewItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultReviews: ReviewItem[] = [\n {\n id: \"1\",\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n rating: 5,\n date: \"Jul 22, 2024\",\n reviewText: \"Absolutely loved my experience at Sushi Ro! The ambiance was cozy, and the staff were incredibly attentive. The food was outstanding, with a diverse menu offering authentic flavors. Highly recommend trying the omakase...\",\n },\n {\n id: \"2\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n rating: 5,\n date: \"Jul 12, 2024\",\n reviewText: \"From the moment we walked in, we were greeted warmly and seated promptly. The omakase menu offered a delightful range of dishes, each bursting with flavor and beautifully presented...\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n { id: \"rating-high\", label: \"Highest rated\" },\n { id: \"rating-low\", label: \"Lowest rated\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All ratings\" },\n { id: \"5-star\", label: \"5 stars\" },\n { id: \"4-star\", label: \"4 stars\" },\n { id: \"3-star\", label: \"3 stars\" },\n { id: \"2-star\", label: \"2 stars\" },\n { id: \"1-star\", label: \"1 star\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface StarRatingProps {\n rating: number;\n maxRating?: number;\n}\n\nfunction StarRating({ rating, maxRating = 5 }: StarRatingProps) {\n return (\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {Array.from({ length: maxRating }, (_, i) => (\n <svg\n key={i}\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M10 1.66667L12.575 6.88334L18.3333 7.725L14.1667 11.7833L15.15 17.5167L10 14.8083L4.85 17.5167L5.83333 11.7833L1.66667 7.725L7.425 6.88334L10 1.66667Z\"\n fill={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n stroke={i < rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n ))}\n </div>\n );\n}\n\ninterface ReviewListItemProps {\n review: ReviewItem;\n isFirst?: boolean;\n isLast?: boolean;\n onReadMore?: (review: ReviewItem) => void;\n}\n\nfunction ReviewListItem({ review, isFirst, isLast, onReadMore }: ReviewListItemProps) {\n return (\n <div\n className=\"w-full\"\n style={{\n borderTop: isFirst ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingTop: \"var(--spacing-3xl)\",\n paddingBottom: \"var(--spacing-3xl)\",\n }}\n >\n <div \n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Top Row: Avatar + Name/Rating + Date */}\n <div \n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Avatar */}\n <Avatar \n className=\"shrink-0\"\n style={{ \n width: \"48px\", \n height: \"48px\",\n borderRadius: \"var(--spacing-3xl)\",\n }}\n >\n <AvatarImage src={review.avatarUrl} alt={review.name} />\n <AvatarFallback\n style={{\n backgroundColor: \"var(--canvas-surface)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {review.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n\n {/* Name and Rating */}\n <div \n className=\"flex flex-col flex-1 min-w-0\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <p\n className=\"m-0\"\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 {review.name}\n </p>\n <StarRating rating={review.rating} />\n </div>\n\n {/* Date */}\n <p\n className=\"m-0 shrink-0\"\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-muted)\",\n }}\n >\n {review.date}\n </p>\n </div>\n\n {/* Review Text */}\n <p\n className=\"m-0 w-full\"\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-muted)\",\n }}\n >\n {review.reviewText}\n </p>\n\n {/* More Link */}\n <button\n onClick={() => onReadMore?.(review)}\n className=\"p-0 border-none bg-transparent cursor-pointer text-left\"\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-primary)\",\n }}\n >\n More\n </button>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Reviews Table Block\n * \n * A configurable reviews listing with header section including title,\n * sort/filter dropdowns, and action button. Displays review items\n * with avatar, star rating, date, and expandable text.\n * \n * @example\n * ```tsx\n * <ReviewsTable\n * title=\"Reviews\"\n * reviews={[\n * { id: \"1\", name: \"John Doe\", rating: 5, date: \"Jul 22, 2024\", reviewText: \"Great experience!\" }\n * ]}\n * onAddNew={() => console.log(\"Add new\")}\n * />\n * ```\n */\nexport function ReviewsTable({\n title = \"Reviews\",\n reviewCount,\n reviewCountText,\n reviews = defaultReviews,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onReadMore,\n className,\n}: ReviewsTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayReviewCount = reviewCount ?? reviews.length;\n const displayReviewText = reviewCountText ?? `${displayReviewCount} customer reviews`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\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 <div \n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displayReviewText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-end justify-end shrink-0 gap-3 max-sm:w-full max-sm:flex-wrap\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Reviews List */}\n <div className=\"flex flex-col w-full\">\n {reviews.map((review, index) => (\n <ReviewListItem\n key={review.id}\n review={review}\n isFirst={index === 0}\n isLast={index === reviews.length - 1}\n onReadMore={onReadMore}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/slideshow-grid-tiles.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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { ChevronLeft, ChevronRight, FolderPlus, ThumbsUp, Eye } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface SlideshowTileItem {\n id: string;\n /** Array of image URLs for the slideshow */\n images: string[];\n /** User/creator info */\n user: {\n name: string;\n avatarUrl?: string;\n location: string;\n };\n /** Like/upvote count */\n likes: number | string;\n /** View count */\n views: number | string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface SlideshowGridTilesProps {\n /** Block title */\n title?: string;\n /** Subtitle text (e.g., \"Showing 20 designs\") */\n subtitle?: string;\n /** Array of tile items */\n items?: SlideshowTileItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when 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 save button is clicked on a tile */\n onSave?: (item: SlideshowTileItem) => void;\n /** Callback when a tile is clicked */\n onItemClick?: (item: SlideshowTileItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: SlideshowTileItem[] = [\n {\n id: \"1\",\n images: [\n \"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1558591710-4b4a1ae0f04d?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n location: \"Copenhagen, Denmark\",\n },\n likes: \"25k\",\n views: \"6.5k\",\n },\n {\n id: \"2\",\n images: [\n \"https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n location: \"San Francisco, CA\",\n },\n likes: \"11k\",\n views: \"2.5k\",\n },\n {\n id: \"3\",\n images: [\n \"https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Gabi Del Rosario\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n location: \"Honolulu, HI\",\n },\n likes: \"8k\",\n views: \"520\",\n },\n {\n id: \"4\",\n images: [\n \"https://images.unsplash.com/photo-1561998338-13ad7883b20f?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Stacy Jones\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n location: \"New York, NY\",\n },\n likes: \"9.5k\",\n views: \"12k\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"popular\", label: \"Most Popular\" },\n { id: \"recent\", label: \"Most Recent\" },\n { id: \"likes\", label: \"Most Liked\" },\n { id: \"views\", label: \"Most Viewed\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All Categories\" },\n { id: \"illustration\", label: \"Illustration\" },\n { id: \"photography\", label: \"Photography\" },\n { id: \"ui-design\", label: \"UI Design\" },\n { id: \"3d\", label: \"3D Art\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface TileCardProps {\n item: SlideshowTileItem;\n onSave?: (item: SlideshowTileItem) => void;\n onClick?: (item: SlideshowTileItem) => void;\n}\n\nfunction TileCard({ item, onSave, onClick }: TileCardProps) {\n const [currentImageIndex, setCurrentImageIndex] = useState(0);\n\n const handlePrevImage = (e: React.MouseEvent) => {\n e.stopPropagation();\n setCurrentImageIndex((prev) => \n prev === 0 ? item.images.length - 1 : prev - 1\n );\n };\n\n const handleNextImage = (e: React.MouseEvent) => {\n e.stopPropagation();\n setCurrentImageIndex((prev) => \n prev === item.images.length - 1 ? 0 : prev + 1\n );\n };\n\n const handleSave = (e: React.MouseEvent) => {\n e.stopPropagation();\n onSave?.(item);\n };\n\n return (\n <div \n className=\"flex flex-col cursor-pointer\"\n style={{ gap: \"var(--spacing-xl)\" }}\n onClick={() => onClick?.(item)}\n >\n {/* Image Container */}\n <div \n className=\"group relative w-full overflow-hidden\"\n style={{ \n height: \"240px\",\n borderRadius: \"var(--radius-md, 8px)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Main Image */}\n <img\n src={item.images[currentImageIndex]}\n alt={`${item.user.name}'s portfolio`}\n className=\"absolute inset-0 w-full h-full object-cover transition-opacity duration-300\"\n style={{ borderRadius: \"var(--radius-md, 8px)\" }}\n />\n\n {/* Hover Overlay - Navigation Arrows */}\n <div className=\"absolute inset-0 flex items-center justify-between px-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200\">\n {/* Left Arrow */}\n <button\n onClick={handlePrevImage}\n className=\"cursor-pointer flex items-center justify-center shrink-0 transition-transform hover:scale-105\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full, 24px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <ChevronLeft \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n\n {/* Right Arrow */}\n <button\n onClick={handleNextImage}\n className=\"cursor-pointer flex items-center justify-center shrink-0 transition-transform hover:scale-105\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full, 24px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <ChevronRight \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n </div>\n\n {/* Hover Overlay - Save Badge */}\n <div className=\"absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200\">\n <button\n onClick={handleSave}\n className=\"cursor-pointer flex items-center transition-transform hover:scale-105\"\n style={{\n height: \"var(--spacing-5xl, 40px)\",\n paddingLeft: \"var(--spacing-xl, 16px)\",\n paddingRight: \"var(--spacing-xl, 16px)\",\n gap: \"var(--spacing-md, 8px)\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full, 40px)\",\n }}\n >\n <FolderPlus \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text)\" }}\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: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n Save\n </span>\n </button>\n </div>\n </div>\n\n {/* User Info Row */}\n <div \n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Avatar */}\n <Avatar \n className=\"shrink-0\"\n style={{ \n width: \"48px\", \n height: \"48px\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={item.user.avatarUrl} alt={item.user.name} />\n <AvatarFallback>\n {item.user.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n\n {/* Name and Location */}\n <div className=\"flex flex-col flex-1 min-w-0 justify-center\">\n <p\n className=\"truncate\"\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 {item.user.name}\n </p>\n <p\n className=\"truncate\"\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-muted)\",\n margin: 0,\n }}\n >\n {item.user.location}\n </p>\n </div>\n\n {/* Stats */}\n <div \n className=\"flex items-center shrink-0\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {/* Likes */}\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <ThumbsUp \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {item.likes}\n </span>\n </div>\n\n {/* Views */}\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <Eye \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {item.views}\n </span>\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Slideshow Grid Tiles Block\n * \n * A 2-column grid of portfolio/slideshow tiles with hover states\n * showing navigation arrows and save button. Each tile displays\n * a large image, user info, and engagement stats.\n * \n * @example\n * ```tsx\n * <SlideshowGridTiles\n * title=\"Portfolios\"\n * subtitle=\"Showing 20 designs\"\n * onSave={(item) => console.log(\"Saved\", item)}\n * />\n * ```\n */\nexport function SlideshowGridTiles({\n title = \"Portfolios\",\n subtitle,\n items = defaultItems,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onSave,\n onItemClick,\n className,\n}: SlideshowGridTilesProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displaySubtitle = subtitle ?? `Showing ${items.length} designs`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Subtitle */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displaySubtitle}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-start justify-end shrink-0\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Grid Section */}\n <div \n className=\"grid grid-cols-1 md:grid-cols-2 w-full\"\n style={{ gap: \"var(--spacing-4xl)\" }}\n >\n {items.map((item) => (\n <TileCard\n key={item.id}\n item={item}\n onSave={onSave}\n onClick={onItemClick}\n />\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 {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { ChevronLeft, ChevronRight, FolderPlus, ThumbsUp, Eye } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface SlideshowTileItem {\n id: string;\n /** Array of image URLs for the slideshow */\n images: string[];\n /** User/creator info */\n user: {\n name: string;\n avatarUrl?: string;\n location: string;\n };\n /** Like/upvote count */\n likes: number | string;\n /** View count */\n views: number | string;\n}\n\nexport interface SortOption {\n id: string;\n label: string;\n}\n\nexport interface FilterOption {\n id: string;\n label: string;\n}\n\nexport interface SlideshowGridTilesProps {\n /** Block title */\n title?: string;\n /** Subtitle text (e.g., \"Showing 20 designs\") */\n subtitle?: string;\n /** Array of tile items */\n items?: SlideshowTileItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when 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 save button is clicked on a tile */\n onSave?: (item: SlideshowTileItem) => void;\n /** Callback when a tile is clicked */\n onItemClick?: (item: SlideshowTileItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: SlideshowTileItem[] = [\n {\n id: \"1\",\n images: [\n \"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1558591710-4b4a1ae0f04d?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n location: \"Copenhagen, Denmark\",\n },\n likes: \"25k\",\n views: \"6.5k\",\n },\n {\n id: \"2\",\n images: [\n \"https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n location: \"San Francisco, CA\",\n },\n likes: \"11k\",\n views: \"2.5k\",\n },\n {\n id: \"3\",\n images: [\n \"https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Gabi Del Rosario\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n location: \"Honolulu, HI\",\n },\n likes: \"8k\",\n views: \"520\",\n },\n {\n id: \"4\",\n images: [\n \"https://images.unsplash.com/photo-1561998338-13ad7883b20f?w=800&h=600&fit=crop\",\n \"https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=800&h=600&fit=crop\",\n ],\n user: {\n name: \"Stacy Jones\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n location: \"New York, NY\",\n },\n likes: \"9.5k\",\n views: \"12k\",\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"popular\", label: \"Most Popular\" },\n { id: \"recent\", label: \"Most Recent\" },\n { id: \"likes\", label: \"Most Liked\" },\n { id: \"views\", label: \"Most Viewed\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All Categories\" },\n { id: \"illustration\", label: \"Illustration\" },\n { id: \"photography\", label: \"Photography\" },\n { id: \"ui-design\", label: \"UI Design\" },\n { id: \"3d\", label: \"3D Art\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface TileCardProps {\n item: SlideshowTileItem;\n onSave?: (item: SlideshowTileItem) => void;\n onClick?: (item: SlideshowTileItem) => void;\n}\n\nfunction TileCard({ item, onSave, onClick }: TileCardProps) {\n const [currentImageIndex, setCurrentImageIndex] = useState(0);\n\n const handlePrevImage = (e: React.MouseEvent) => {\n e.stopPropagation();\n setCurrentImageIndex((prev) => \n prev === 0 ? item.images.length - 1 : prev - 1\n );\n };\n\n const handleNextImage = (e: React.MouseEvent) => {\n e.stopPropagation();\n setCurrentImageIndex((prev) => \n prev === item.images.length - 1 ? 0 : prev + 1\n );\n };\n\n const handleSave = (e: React.MouseEvent) => {\n e.stopPropagation();\n onSave?.(item);\n };\n\n return (\n <div \n className=\"flex flex-col cursor-pointer\"\n style={{ gap: \"var(--spacing-xl)\" }}\n onClick={() => onClick?.(item)}\n >\n {/* Image Container */}\n <div \n className=\"group relative w-full overflow-hidden\"\n style={{ \n height: \"240px\",\n borderRadius: \"var(--radius-md, 8px)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Main Image */}\n <img\n src={item.images[currentImageIndex]}\n alt={`${item.user.name}'s portfolio`}\n className=\"absolute inset-0 w-full h-full object-cover transition-opacity duration-300\"\n style={{ borderRadius: \"var(--radius-md, 8px)\" }}\n />\n\n {/* Hover Overlay - Navigation Arrows */}\n <div className=\"absolute inset-0 flex items-center justify-between px-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200\">\n {/* Left Arrow */}\n <button\n onClick={handlePrevImage}\n className=\"cursor-pointer flex items-center justify-center shrink-0 transition-transform hover:scale-105\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full, 24px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <ChevronLeft \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n\n {/* Right Arrow */}\n <button\n onClick={handleNextImage}\n className=\"cursor-pointer flex items-center justify-center shrink-0 transition-transform hover:scale-105\"\n style={{\n width: \"32px\",\n height: \"32px\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full, 24px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <ChevronRight \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n </div>\n\n {/* Hover Overlay - Save Badge */}\n <div className=\"absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200\">\n <button\n onClick={handleSave}\n className=\"cursor-pointer flex items-center transition-transform hover:scale-105\"\n style={{\n height: \"var(--spacing-5xl, 40px)\",\n paddingLeft: \"var(--spacing-xl, 16px)\",\n paddingRight: \"var(--spacing-xl, 16px)\",\n gap: \"var(--spacing-md, 8px)\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-full, 40px)\",\n }}\n >\n <FolderPlus \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text)\" }}\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: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n Save\n </span>\n </button>\n </div>\n </div>\n\n {/* User Info Row */}\n <div \n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Avatar */}\n <Avatar \n className=\"shrink-0\"\n style={{ \n width: \"48px\", \n height: \"48px\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={item.user.avatarUrl} alt={item.user.name} />\n <AvatarFallback>\n {item.user.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n\n {/* Name and Location */}\n <div className=\"flex flex-col flex-1 min-w-0 justify-center\">\n <p\n className=\"truncate\"\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 {item.user.name}\n </p>\n <p\n className=\"truncate\"\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-muted)\",\n margin: 0,\n }}\n >\n {item.user.location}\n </p>\n </div>\n\n {/* Stats */}\n <div \n className=\"flex items-center shrink-0\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {/* Likes */}\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <ThumbsUp \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {item.likes}\n </span>\n </div>\n\n {/* Views */}\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <Eye \n className=\"w-5 h-5\" \n style={{ color: \"var(--canvas-text-muted)\" }}\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: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {item.views}\n </span>\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Slideshow Grid Tiles Block\n * \n * A 2-column grid of portfolio/slideshow tiles with hover states\n * showing navigation arrows and save button. Each tile displays\n * a large image, user info, and engagement stats.\n * \n * @example\n * ```tsx\n * <SlideshowGridTiles\n * title=\"Portfolios\"\n * subtitle=\"Showing 20 designs\"\n * onSave={(item) => console.log(\"Saved\", item)}\n * />\n * ```\n */\nexport function SlideshowGridTiles({\n title = \"Portfolios\",\n subtitle,\n items = defaultItems,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onSave,\n onItemClick,\n className,\n}: SlideshowGridTilesProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displaySubtitle = subtitle ?? `Showing ${items.length} designs`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\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 <div \n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Subtitle */}\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\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-muted)\",\n margin: 0,\n }}\n >\n {displaySubtitle}\n </p>\n </div>\n\n {/* Controls */}\n <div\n className=\"flex items-end justify-end shrink-0 max-sm:w-full max-sm:flex-wrap\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Grid Section */}\n <div \n className=\"grid grid-cols-1 md:grid-cols-2 w-full\"\n style={{ gap: \"var(--spacing-4xl)\" }}\n >\n {items.map((item) => (\n <TileCard\n key={item.id}\n item={item}\n onSave={onSave}\n onClick={onItemClick}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|