canvas-ui-sdk 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +326 -277
- package/dist/index.js.map +1 -1
- package/mcp/dist/index.js +14 -2
- package/package.json +1 -1
- package/registry/blocks/canvas-item.json +1 -1
- package/registry/blocks/chat-message.json +1 -1
- package/registry/blocks/component-palette.json +1 -1
- package/registry/blocks/component-search.json +1 -1
- package/registry/blocks/content-dropzone.json +1 -1
- package/registry/blocks/credit-card-display.json +1 -1
- package/registry/blocks/custom-component-helper.json +1 -1
- package/registry/blocks/empty-state.json +1 -1
- package/registry/blocks/faqs-table.json +1 -1
- package/registry/blocks/filter-popover.json +1 -1
- package/registry/blocks/fixed-column-data-table.json +1 -1
- package/registry/blocks/infinity-canvas.json +1 -1
- package/registry/blocks/menu-section.json +1 -1
- package/registry/blocks/messenger-sidebar.json +1 -1
- package/registry/blocks/mobile-bottom-nav.json +1 -1
- package/registry/blocks/monthly-calendar-widget.json +1 -1
- package/registry/blocks/page-header-section.json +1 -1
- package/registry/blocks/page-previews.json +1 -1
- package/registry/blocks/pagination.json +1 -1
- package/registry/blocks/persona-card.json +1 -1
- package/registry/blocks/pill-tabs.json +1 -1
- package/registry/blocks/pricing-cards.json +1 -1
- package/registry/blocks/profile-card.json +1 -1
- package/registry/blocks/profile-info-cards.json +1 -1
- package/registry/blocks/prompt-template.json +1 -1
- package/registry/blocks/screen-flowchart.json +1 -1
- package/registry/blocks/screen-prompt-builder.json +1 -1
- package/registry/blocks/screen-prompt-template.json +1 -1
- package/registry/blocks/search-bar.json +1 -1
- package/registry/blocks/sidebar-cards.json +1 -1
- package/registry/blocks/sidebar-profile-card.json +1 -1
- package/registry/blocks/slideshow-grid-tiles.json +1 -1
- package/registry/blocks/social-feed.json +1 -1
- package/registry/blocks/step-tracker.json +1 -1
- package/registry/blocks/upvoting-posts-table.json +1 -1
- package/registry/blocks/vertical-step-tracker.json +1 -1
- package/registry/blocks/video-chat-controls.json +1 -1
- package/registry/layout/account-settings-shell.json +1 -1
- package/registry/layout/dashboard-shell.json +1 -1
- package/registry/layout/double-sidebar-shell.json +1 -1
- package/registry/layout/double-sidebar.json +1 -1
- package/registry/layout/header.json +1 -1
- package/registry/layout/icon-sidebar-shell.json +1 -1
- package/registry/layout/icon-sidebar.json +1 -1
- package/registry/layout/mobile-menu-shell.json +1 -1
- package/registry/layout/multistep-progressbar-shell.json +1 -1
- package/registry/layout/multistep-shell.json +1 -1
- package/registry/layout/multistep-sidebar-shell.json +1 -1
- package/registry/layout/project-context-shell.json +1 -1
- package/registry/layout/search-bar-shell.json +1 -1
- package/registry/layout/sidebar-nav.json +1 -1
- package/registry/layout/sidebar.json +1 -1
- package/registry/layout/standard-page-shell.json +1 -1
- package/registry/layout/vertical-multistep-shell.json +1 -1
- package/registry/ui/avatar.json +1 -1
- package/registry/ui/button.json +1 -1
- package/registry/ui/checkbox.json +1 -1
- package/registry/ui/date-input.json +1 -1
- package/registry/ui/image-uploader.json +1 -1
- package/registry/ui/line-tabs.json +1 -1
- package/registry/ui/multiselect-checkbox-field.json +1 -1
- package/registry/ui/multiselect-tags.json +1 -1
- package/registry/ui/radio-group.json +1 -1
- package/registry/ui/searchbox.json +1 -1
- package/registry/ui/select.json +1 -1
- package/registry/ui/selectable-pills.json +1 -1
- package/registry/ui/slider.json +1 -1
- package/registry/ui/switch.json +1 -1
- package/registry/ui/tabs.json +1 -1
- package/registry/ui/text-input.json +1 -1
- package/registry/ui/textarea.json +1 -1
- package/styles/tokens.reference.css +9 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/screen-prompt-template.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Check, Copy, Sparkles, FileBox, GitBranch } from \"lucide-react\";\nimport { cn } from \"../../lib/utils\";\nimport { promptTemplates } from \"../../data/prompt-templates\";\n\ntype ScreenPromptType = \"single\" | \"flow\";\n\nconst promptTypeConfig = {\n single: {\n icon: FileBox,\n label: \"Single Screen\",\n description: \"Add one screen at a time\",\n prompt: promptTemplates.screens.single,\n },\n flow: {\n icon: GitBranch,\n label: \"Screen Flow\",\n description: \"Create a multi-screen user flow\",\n prompt: promptTemplates.screens.flow,\n },\n};\n\nexport function ScreenPromptTemplate() {\n const [promptType, setPromptType] = useState<ScreenPromptType>(\"single\");\n const [copied, setCopied] = useState(false);\n\n const currentConfig = promptTypeConfig[promptType];\n\n const handleCopy = async () => {\n await navigator.clipboard.writeText(currentConfig.prompt);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n };\n\n return (\n <div className=\"rounded-lg border border-dashed border-[var(--canvas-border)] bg-[var(--canvas-surface)] p-4\">\n {/* Type Toggle */}\n <div className=\"flex flex-wrap gap-2 mb-4\">\n {(Object.entries(promptTypeConfig) as [ScreenPromptType, (typeof promptTypeConfig)[ScreenPromptType]][]).map(\n ([type, config]) => {\n const Icon = config.icon;\n const isActive = promptType === type;\n \n return (\n <button\n key={type}\n onClick={() => setPromptType(type)}\n className={cn(\n \"flex items-center gap-2 px-4 py-2 rounded-lg
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Check, Copy, Sparkles, FileBox, GitBranch } from \"lucide-react\";\nimport { cn } from \"../../lib/utils\";\nimport { promptTemplates } from \"../../data/prompt-templates\";\n\ntype ScreenPromptType = \"single\" | \"flow\";\n\nconst promptTypeConfig = {\n single: {\n icon: FileBox,\n label: \"Single Screen\",\n description: \"Add one screen at a time\",\n prompt: promptTemplates.screens.single,\n },\n flow: {\n icon: GitBranch,\n label: \"Screen Flow\",\n description: \"Create a multi-screen user flow\",\n prompt: promptTemplates.screens.flow,\n },\n};\n\nexport function ScreenPromptTemplate() {\n const [promptType, setPromptType] = useState<ScreenPromptType>(\"single\");\n const [copied, setCopied] = useState(false);\n\n const currentConfig = promptTypeConfig[promptType];\n\n const handleCopy = async () => {\n await navigator.clipboard.writeText(currentConfig.prompt);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n };\n\n return (\n <div className=\"rounded-lg border border-dashed border-[var(--canvas-border)] bg-[var(--canvas-surface)] p-4\">\n {/* Type Toggle */}\n <div className=\"flex flex-wrap gap-2 mb-4\">\n {(Object.entries(promptTypeConfig) as [ScreenPromptType, (typeof promptTypeConfig)[ScreenPromptType]][]).map(\n ([type, config]) => {\n const Icon = config.icon;\n const isActive = promptType === type;\n \n return (\n <button\n key={type}\n onClick={() => setPromptType(type)}\n className={cn(\n \"cursor-pointer flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all\",\n isActive\n ? \"bg-[var(--canvas-primary)] text-[var(--canvas-primary-foreground)] shadow-sm\"\n : \"bg-[var(--canvas-background)] text-[var(--canvas-text-muted)] border border-[var(--canvas-border)] hover:border-[var(--canvas-primary)] hover:text-[var(--canvas-primary)]\"\n )}\n style={{ fontSize: \"var(--typo-body-s-size)\" }}\n >\n <Icon className=\"size-4\" />\n {config.label}\n </button>\n );\n }\n )}\n </div>\n\n {/* Description */}\n <p className=\"text-[var(--canvas-text-muted)] mb-3\" style={{ fontSize: \"var(--typo-body-xs-size)\" }}>\n {currentConfig.description}\n </p>\n\n {/* Divider */}\n <div className=\"border-t border-[var(--canvas-border)] my-4\" />\n\n {/* Header */}\n <div className=\"flex items-center justify-between mb-3\">\n <div className=\"flex items-center gap-2 font-medium text-[var(--canvas-text-muted)]\" style={{ fontSize: \"var(--typo-body-s-size)\" }}>\n <Sparkles className=\"size-4 text-[var(--canvas-primary)]\" />\n Generate with Cursor\n </div>\n <button\n onClick={handleCopy}\n className={cn(\n \"cursor-pointer flex items-center gap-1.5 px-2.5 py-1.5 rounded-md font-medium transition-all\",\n copied\n ? \"bg-[var(--canvas-success-surface)] text-[var(--canvas-success)]\"\n : \"bg-[var(--canvas-background)] text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] border border-[var(--canvas-border)] hover:border-[var(--canvas-primary)]\"\n )}\n style={{ fontSize: \"var(--typo-body-xs-size)\" }}\n >\n {copied ? (\n <>\n <Check className=\"size-3\" />\n Copied!\n </>\n ) : (\n <>\n <Copy className=\"size-3\" />\n Copy prompt\n </>\n )}\n </button>\n </div>\n\n {/* Prompt text */}\n <pre className=\"text-[var(--canvas-text)] leading-relaxed font-mono whitespace-pre-wrap bg-[var(--canvas-background)] rounded-md p-3 border border-[var(--canvas-border)] max-h-[300px] overflow-y-auto\" style={{ fontSize: \"var(--typo-body-s-size)\" }}>\n {currentConfig.prompt}\n </pre>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/search-bar.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Search } from \"lucide-react\";\nimport { Button } from \"../ui/button\";\n\ninterface SearchBarProps {\n /** Placeholder text for the input */\n placeholder?: string;\n /** Current search value */\n value?: string;\n /** Callback when the input value changes */\n onChange?: (value: string) => void;\n /** Callback when search is triggered (button click or Enter key) */\n onSearch?: () => void;\n /** Search button label */\n buttonLabel?: string;\n /** Additional class names */\n className?: string;\n}\n\n/**\n * Canvas Design System - Search Bar Component\n * \n * A search input with a search icon on the left and a conditional search button.\n * The button appears only when the input has content.\n * Uses standard input sizing variables for consistent styling.\n * \n * @example\n * ```tsx\n * <SearchBar\n * placeholder=\"Search\"\n * value={searchValue}\n * onChange={setSearchValue}\n * onSearch={handleSearch}\n * />\n * ```\n */\nexport function SearchBar({\n placeholder = \"Search\",\n value = \"\",\n onChange,\n onSearch,\n buttonLabel = \"Search\",\n className,\n}: SearchBarProps) {\n const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n onSearch?.();\n }\n };\n\n const hasValue = value.length > 0;\n\n return (\n <div\n className={cn(\n \"flex items-center gap-2\",\n \"bg-
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Search } from \"lucide-react\";\nimport { Button } from \"../ui/button\";\n\ninterface SearchBarProps {\n /** Placeholder text for the input */\n placeholder?: string;\n /** Current search value */\n value?: string;\n /** Callback when the input value changes */\n onChange?: (value: string) => void;\n /** Callback when search is triggered (button click or Enter key) */\n onSearch?: () => void;\n /** Search button label */\n buttonLabel?: string;\n /** Additional class names */\n className?: string;\n}\n\n/**\n * Canvas Design System - Search Bar Component\n * \n * A search input with a search icon on the left and a conditional search button.\n * The button appears only when the input has content.\n * Uses standard input sizing variables for consistent styling.\n * \n * @example\n * ```tsx\n * <SearchBar\n * placeholder=\"Search\"\n * value={searchValue}\n * onChange={setSearchValue}\n * onSearch={handleSearch}\n * />\n * ```\n */\nexport function SearchBar({\n placeholder = \"Search\",\n value = \"\",\n onChange,\n onSearch,\n buttonLabel = \"Search\",\n className,\n}: SearchBarProps) {\n const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (e.key === \"Enter\") {\n onSearch?.();\n }\n };\n\n const hasValue = value.length > 0;\n\n return (\n <div\n className={cn(\n \"flex items-center gap-2\",\n \"bg-[var(--canvas-background)] border border-[var(--canvas-border-input)]\",\n \"pr-2\",\n \"transition-colors\",\n \"focus-within:border-[var(--canvas-border-input-focus)] focus-within:ring-2 focus-within:ring-[var(--canvas-border-input-focus)] focus-within:ring-offset-2\",\n className\n )}\n style={{ \n height: \"var(--input-standard-height)\",\n paddingLeft: \"var(--input-standard-px)\",\n borderRadius: \"var(--input-standard-radius)\",\n }}\n >\n {/* Search Icon */}\n <Search className=\"size-4 shrink-0 text-[var(--canvas-text-muted)]\" />\n\n {/* Input */}\n <input\n type=\"text\"\n placeholder={placeholder}\n value={value}\n onChange={(e) => onChange?.(e.target.value)}\n onKeyDown={handleKeyDown}\n className={cn(\n \"flex-1 h-full bg-transparent\",\n \"text-[var(--canvas-text)]\",\n \"placeholder:text-[var(--canvas-text-placeholder)]\",\n \"outline-none border-none\"\n )}\n style={{ fontSize: \"var(--input-standard-font-size)\" }}\n />\n\n {/* Search Button - Only visible when input has content */}\n {hasValue && (\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onSearch}\n className=\"shrink-0\"\n style={{ \n height: \"calc(var(--input-standard-height) - 8px)\",\n borderRadius: \"var(--input-standard-radius)\",\n }}\n >\n {buttonLabel}\n </Button>\n )}\n </div>\n );\n}\n\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/sidebar-cards.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Mail, Phone, MessageCircle, User, LucideIcon } from \"lucide-react\";\n\n// Shared card styling\nconst cardClassName = cn(\n \"bg-
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Mail, Phone, MessageCircle, User, LucideIcon } from \"lucide-react\";\n\n// Shared card styling\nconst cardClassName = cn(\n \"bg-[var(--canvas-background)] border border-[var(--canvas-border)]\",\n \"rounded-xl p-8\"\n);\n\n// ============================================================================\n// InfoCard\n// ============================================================================\n\nexport interface InfoCardProps {\n /** Card title (displayed as uppercase header) */\n title?: string;\n /** Card description text */\n description?: string;\n /** Additional class name */\n className?: string;\n}\n\n/**\n * Canvas Design System - Info Card\n * \n * A sidebar card displaying a title and descriptive text.\n * Used for \"About Us\" style content.\n */\nexport function InfoCard({\n title = \"ABOUT US\",\n description = \"Canvas is a no-code framework built on top of Bubble that makes creating beautiful responsive web applications easy and fast.\\n\\nCanvas is developed and maintained by AirDev in San Francisco, based on our experience with hundreds of client engagements.\",\n className,\n}: InfoCardProps) {\n return (\n <div className={cn(cardClassName, \"w-80\", className)}>\n <h3 \n className=\"text-[var(--canvas-text-placeholder)] font-semibold mb-4\"\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n }}\n >\n {title}\n </h3>\n <p \n className=\"text-[var(--canvas-text-muted)] whitespace-pre-line\"\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n lineHeight: \"24px\",\n }}\n >\n {description}\n </p>\n </div>\n );\n}\n\n// ============================================================================\n// LinksCard\n// ============================================================================\n\nexport interface LinkItem {\n id: string;\n label: string;\n icon: LucideIcon;\n href?: string;\n onClick?: () => void;\n}\n\nexport interface LinksCardProps {\n /** Card title (displayed as uppercase header) */\n title?: string;\n /** Array of link items */\n links?: LinkItem[];\n /** Additional class name */\n className?: string;\n}\n\n/** Default support links */\nexport const defaultSupportLinks: LinkItem[] = [\n { id: \"email\", label: \"Email us\", icon: Mail },\n { id: \"phone\", label: \"Call us\", icon: Phone },\n { id: \"text\", label: \"Text us\", icon: MessageCircle },\n { id: \"chat\", label: \"Chat now\", icon: User },\n];\n\n/**\n * Canvas Design System - Links Card\n * \n * A sidebar card displaying a title and a list of icon links.\n * Used for \"Support\" style contact links.\n */\nexport function LinksCard({\n title = \"SUPPORT\",\n links = defaultSupportLinks,\n className,\n}: LinksCardProps) {\n return (\n <div className={cn(cardClassName, \"w-80\", className)}>\n <h3 \n className=\"text-[var(--canvas-text-placeholder)] font-semibold mb-4\"\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n }}\n >\n {title}\n </h3>\n <div className=\"flex flex-col gap-4\">\n {links.map((link) => {\n const Icon = link.icon;\n const content = (\n <>\n <Icon className=\"size-5 text-[var(--canvas-primary)]\" />\n <span \n className=\"text-[var(--canvas-primary)] font-medium\"\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n }}\n >\n {link.label}\n </span>\n </>\n );\n\n if (link.href) {\n return (\n <a\n key={link.id}\n href={link.href}\n className=\"cursor-pointer flex items-center gap-1.5 hover:opacity-80 transition-opacity\"\n >\n {content}\n </a>\n );\n }\n\n return (\n <button\n key={link.id}\n type=\"button\"\n onClick={link.onClick}\n className=\"cursor-pointer flex items-center gap-1.5 hover:opacity-80 transition-opacity\"\n >\n {content}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/sidebar-profile-card.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Typography } from \"../ui/typography\";\nimport { Button } from \"../ui/button\";\nimport {\n MapPin,\n BookOpen,\n Video,\n DollarSign,\n Star,\n Globe,\n Facebook,\n Twitter,\n Instagram,\n Eye,\n} from \"lucide-react\";\n\ninterface InfoRow {\n icon: \"location\" | \"education\" | \"sessions\" | \"earnings\";\n label: string;\n value: string;\n}\n\ninterface SidebarProfileCardProps {\n /** Profile avatar image URL */\n avatarUrl?: string;\n /** Avatar fallback initials */\n avatarFallback?: string;\n /** Whether to show online status indicator */\n showStatus?: boolean;\n /** User's display name */\n name: string;\n /** User's role/title */\n role?: string;\n /** Star rating value */\n rating?: number;\n /** Number of reviews */\n reviewCount?: string;\n /** Certification text */\n certification?: string;\n /** Info rows (location, education, sessions, earnings) */\n infoRows?: InfoRow[];\n /** Contact button click handler */\n onContactClick?: () => void;\n /** Hire button click handler */\n onHireClick?: () => void;\n /** Additional class names */\n className?: string;\n}\n\nconst infoIcons = {\n location: MapPin,\n education: BookOpen,\n sessions: Video,\n earnings: DollarSign,\n};\n\n/**\n * Canvas Design System - Sidebar Profile Card Component\n *\n * A profile card designed for sidebar placement with avatar,\n * name, rating, action buttons, info rows, and social icons.\n */\nexport function SidebarProfileCard({\n avatarUrl,\n avatarFallback = \"JC\",\n showStatus = true,\n name,\n role,\n rating = 4.8,\n reviewCount = \"(2.4k)\",\n certification,\n infoRows = [],\n onContactClick,\n onHireClick,\n className,\n}: SidebarProfileCardProps) {\n return (\n <div\n className={cn(\n \"bg-[var(--canvas-background)] border border-[var(--canvas-border)] rounded-[var(--radius-md)]\",\n \"flex flex-col w-full\",\n className\n )}\n >\n {/* Main Content Section */}\n <div className=\"flex flex-col items-center gap-[var(--spacing-2xl)] px-[var(--spacing-4xl)] pt-[var(--spacing-4xl)]\">\n {/* Avatar with Status */}\n <div className=\"flex flex-col items-center gap-[var(--radius-md)]\">\n <div className=\"relative\">\n <Avatar className=\"size-[120px]\">\n <AvatarImage src={avatarUrl} alt={name} />\n <AvatarFallback className=\"
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Typography } from \"../ui/typography\";\nimport { Button } from \"../ui/button\";\nimport {\n MapPin,\n BookOpen,\n Video,\n DollarSign,\n Star,\n Globe,\n Facebook,\n Twitter,\n Instagram,\n Eye,\n} from \"lucide-react\";\n\ninterface InfoRow {\n icon: \"location\" | \"education\" | \"sessions\" | \"earnings\";\n label: string;\n value: string;\n}\n\ninterface SidebarProfileCardProps {\n /** Profile avatar image URL */\n avatarUrl?: string;\n /** Avatar fallback initials */\n avatarFallback?: string;\n /** Whether to show online status indicator */\n showStatus?: boolean;\n /** User's display name */\n name: string;\n /** User's role/title */\n role?: string;\n /** Star rating value */\n rating?: number;\n /** Number of reviews */\n reviewCount?: string;\n /** Certification text */\n certification?: string;\n /** Info rows (location, education, sessions, earnings) */\n infoRows?: InfoRow[];\n /** Contact button click handler */\n onContactClick?: () => void;\n /** Hire button click handler */\n onHireClick?: () => void;\n /** Additional class names */\n className?: string;\n}\n\nconst infoIcons = {\n location: MapPin,\n education: BookOpen,\n sessions: Video,\n earnings: DollarSign,\n};\n\n/**\n * Canvas Design System - Sidebar Profile Card Component\n *\n * A profile card designed for sidebar placement with avatar,\n * name, rating, action buttons, info rows, and social icons.\n */\nexport function SidebarProfileCard({\n avatarUrl,\n avatarFallback = \"JC\",\n showStatus = true,\n name,\n role,\n rating = 4.8,\n reviewCount = \"(2.4k)\",\n certification,\n infoRows = [],\n onContactClick,\n onHireClick,\n className,\n}: SidebarProfileCardProps) {\n return (\n <div\n className={cn(\n \"bg-[var(--canvas-background)] border border-[var(--canvas-border)] rounded-[var(--radius-md)]\",\n \"flex flex-col w-full\",\n className\n )}\n >\n {/* Main Content Section */}\n <div className=\"flex flex-col items-center gap-[var(--spacing-2xl)] px-[var(--spacing-4xl)] pt-[var(--spacing-4xl)]\">\n {/* Avatar with Status */}\n <div className=\"flex flex-col items-center gap-[var(--radius-md)]\">\n <div className=\"relative\">\n <Avatar className=\"size-[120px]\">\n <AvatarImage src={avatarUrl} alt={name} />\n <AvatarFallback className=\"font-semibold bg-[var(--canvas-surface)] text-[var(--canvas-text-muted)]\" style={{ fontSize: \"var(--typo-h6-size)\" }}>\n {avatarFallback}\n </AvatarFallback>\n </Avatar>\n {showStatus && (\n <div className=\"absolute bottom-[10px] right-[10px] size-5 rounded-full bg-[var(--canvas-success)] border-[3px] border-[var(--canvas-background)]\" />\n )}\n </div>\n\n {/* Name & Role */}\n <div className=\"flex flex-col items-center gap-[var(--spacing-xs)]\">\n <Typography variant=\"body-xl\" className=\"text-center\" style={{ fontWeight: 600 }}>\n {name}\n </Typography>\n {role && (\n <Typography variant=\"body-s\" color=\"muted\" className=\"text-center\">\n {role}\n </Typography>\n )}\n </div>\n </div>\n\n {/* Star Rating */}\n <div className=\"flex items-center gap-1 justify-center\">\n <Star className=\"size-4 fill-[var(--canvas-primary)] text-[var(--canvas-primary)]\" />\n <Typography variant=\"body-xs\" className=\"font-semibold\">\n {rating}\n </Typography>\n <Typography variant=\"body-xs\" color=\"muted\">\n {reviewCount}\n </Typography>\n </div>\n\n {/* Action Buttons */}\n <div className=\"flex gap-[var(--spacing-xl)] w-full\">\n <Button\n variant=\"outline\"\n className=\"flex-1 h-10\"\n onClick={onContactClick}\n >\n Contact\n </Button>\n <Button\n variant=\"default\"\n className=\"flex-1 h-10\"\n onClick={onHireClick}\n >\n Hire me\n </Button>\n </div>\n\n {/* Divider */}\n <div className=\"w-full h-px bg-[var(--canvas-border)]\" />\n\n {/* Certification */}\n {certification && (\n <Typography variant=\"body-s\" color=\"muted\" className=\"text-center\">\n {certification}\n </Typography>\n )}\n\n {/* Info Rows */}\n {infoRows.length > 0 && (\n <div className=\"flex flex-col gap-[var(--spacing-lg)] w-full\">\n {infoRows.map((row, index) => {\n const IconComponent = infoIcons[row.icon];\n return (\n <div\n key={index}\n className=\"flex items-center justify-between w-full\"\n >\n <div className=\"flex items-center gap-[var(--spacing-sm)]\">\n <IconComponent className=\"size-4 text-[var(--canvas-text-muted)]\" />\n <Typography variant=\"body-s\" color=\"muted\">\n {row.label}\n </Typography>\n </div>\n <Typography variant=\"body-s\" className=\"font-semibold text-[var(--canvas-neutral-text)]\">\n {row.value}\n </Typography>\n </div>\n );\n })}\n </div>\n )}\n\n {/* Divider */}\n <div className=\"w-full h-px bg-[var(--canvas-border)]\" />\n\n {/* Social Icons */}\n <div className=\"flex gap-[var(--spacing-xl)] pb-[var(--spacing-3xl)]\">\n <Eye className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n <Facebook className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n <Twitter className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n <Instagram className=\"size-4 text-[var(--canvas-text-muted)] hover:text-[var(--canvas-text)] transition-colors cursor-pointer\" />\n </div>\n </div>\n </div>\n );\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=\"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=\"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=\"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-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"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/social-feed.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Button } from \"../ui/button\";\nimport { \n Heart, \n MessageCircle, \n RefreshCw, \n Send, \n Paperclip, \n Video, \n Link2, \n MoreHorizontal,\n Play\n} from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PostAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface LinkPreview {\n url: string;\n domain: string;\n title: string;\n description?: string;\n imageUrl?: string;\n}\n\nexport interface VideoMedia {\n thumbnailUrl: string;\n videoUrl?: string;\n}\n\nexport interface RepostContent {\n author: PostAuthor;\n date: string;\n content: string;\n images?: string[];\n}\n\nexport interface SocialFeedPost {\n id: string;\n author: PostAuthor;\n date: string;\n content: string;\n /** Image URLs for the post */\n images?: string[];\n /** Video media */\n video?: VideoMedia;\n /** Link preview card */\n linkPreview?: LinkPreview;\n /** Reposted/quoted content */\n repost?: RepostContent;\n likesCount: number;\n repliesCount: number;\n isLiked?: boolean;\n /** Nested replies */\n replies?: SocialFeedPost[];\n /** Whether this is a reply (for indentation) */\n isReply?: boolean;\n}\n\nexport interface SocialFeedProps {\n /** Section title */\n title?: string;\n /** Posts data */\n posts?: SocialFeedPost[];\n /** Current user for composer */\n currentUser?: PostAuthor;\n /** Placeholder text for composer */\n composerPlaceholder?: string;\n /** Image preview in composer */\n composerImagePreview?: string;\n /** Callback when post is submitted */\n onPost?: (content: string) => void;\n /** Callback when like is clicked */\n onLike?: (postId: string) => void;\n /** Callback when comment is clicked */\n onComment?: (postId: string) => void;\n /** Callback when repost is clicked */\n onRepost?: (postId: string) => void;\n /** Callback when share is clicked */\n onShare?: (postId: string) => void;\n /** Callback when menu is clicked */\n onMenuClick?: (postId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: PostAuthor = {\n id: \"current\",\n name: \"You\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n};\n\nconst defaultPosts: SocialFeedPost[] = [\n {\n id: \"1\",\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 date: \"Feb 23, 1:32 PM\",\n content: \"Thinking about traveling to Paris again!\",\n repost: {\n author: {\n id: \"jeffrey\",\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"Nov 23, 5:34 PM\",\n content: \"What a place, the history, architecture and culture is wonderful. So many sites to see, one more amazing then the next. A must see if you are going to visit the great cities of the world.\",\n images: [\n \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=320&h=320&fit=crop\",\n \"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=320&h=320&fit=crop\",\n ],\n },\n likesCount: 30,\n repliesCount: 10,\n isLiked: false,\n },\n {\n id: \"2\",\n author: {\n id: \"mary\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"Feb 23, 1:32 PM\",\n content: \"Learning how to Bubble\",\n video: {\n thumbnailUrl: \"https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=480&h=380&fit=crop\",\n },\n likesCount: 30,\n repliesCount: 10,\n isLiked: false,\n replies: [\n {\n id: \"2-reply-1\",\n author: {\n id: \"aya\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"Mar 12, 11:23 AM\",\n content: \"Check out these flight deals to Paris!\",\n linkPreview: {\n url: \"https://expedia.com/flights/paris\",\n domain: \"expedia.com\",\n title: \"Paris flights\",\n description: \"Your one-stop travel site for your dream vacation. Bundle your stay...\",\n imageUrl: \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=200&h=200&fit=crop\",\n },\n likesCount: 30,\n repliesCount: 10,\n isLiked: false,\n isReply: true,\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface PostComposerProps {\n placeholder?: string;\n imagePreview?: string;\n onPost?: (content: string) => void;\n}\n\nfunction PostComposer({ placeholder = \"What's on your mind?\", imagePreview, onPost }: PostComposerProps) {\n const [content, setContent] = useState(\"\");\n\n const handlePost = () => {\n if (content.trim() || imagePreview) {\n onPost?.(content);\n setContent(\"\");\n }\n };\n\n return (\n <div\n className=\"flex flex-col w-full overflow-hidden\"\n style={{\n border: \"1px solid var(--canvas-border)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n }}\n >\n {/* Text input area */}\n <div\n className=\"w-full\"\n style={{\n padding: \"var(--spacing-xl)\",\n background: \"var(--canvas-background)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <textarea\n value={content}\n onChange={(e) => setContent(e.target.value)}\n placeholder={placeholder}\n className=\"w-full resize-none border-0 bg-transparent outline-none\"\n rows={2}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: content ? \"var(--canvas-text)\" : \"var(--canvas-text-placeholder)\",\n }}\n />\n </div>\n\n {/* Image preview */}\n {imagePreview && (\n <div\n className=\"w-full\"\n style={{\n padding: \"0 var(--spacing-xl)\",\n background: \"var(--canvas-background)\",\n }}\n >\n <div\n className=\"overflow-hidden\"\n style={{\n width: 240,\n height: 180,\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img\n src={imagePreview}\n alt=\"Preview\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n )}\n\n {/* Action bar */}\n <div\n className=\"flex items-center justify-between w-full\"\n style={{\n padding: \"var(--spacing-xl)\",\n background: \"var(--canvas-background)\",\n borderTop: \"1px solid var(--canvas-border)\",\n }}\n >\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-lg)\" }}>\n <button type=\"button\" style={{ color: \"var(--canvas-text)\" }}>\n <Paperclip className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" style={{ color: \"var(--canvas-text)\" }}>\n <Video className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" style={{ color: \"var(--canvas-text)\" }}>\n <Link2 className=\"w-5 h-5\" />\n </button>\n </div>\n <Button variant=\"primary\" size=\"sm\" onClick={handlePost}>\n Post\n </Button>\n </div>\n </div>\n );\n}\n\ninterface ActionIconsRowProps {\n isLiked?: boolean;\n onLike?: () => void;\n onComment?: () => void;\n onRepost?: () => void;\n onShare?: () => void;\n}\n\nfunction ActionIconsRow({ isLiked, onLike, onComment, onRepost, onShare }: ActionIconsRowProps) {\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-lg)\", padding: \"var(--spacing-xxs) 0\" }}\n >\n <button\n type=\"button\"\n onClick={onLike}\n style={{ color: isLiked ? \"var(--canvas-destructive)\" : \"var(--canvas-text)\" }}\n >\n <Heart\n className=\"w-5 h-5\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n }}\n />\n </button>\n <button type=\"button\" onClick={onComment} style={{ color: \"var(--canvas-text)\" }}>\n <MessageCircle className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" onClick={onRepost} style={{ color: \"var(--canvas-text)\" }}>\n <RefreshCw className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" onClick={onShare} style={{ color: \"var(--canvas-text)\" }}>\n <Send className=\"w-5 h-5\" />\n </button>\n </div>\n );\n}\n\ninterface StatsRowProps {\n likesCount: number;\n repliesCount: number;\n}\n\nfunction StatsRow({ likesCount, repliesCount }: StatsRowProps) {\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xl)\", paddingTop: \"var(--spacing-xs)\" }}\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-placeholder)\",\n }}\n >\n {likesCount} likes\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: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {repliesCount} replies\n </span>\n </div>\n );\n}\n\ninterface VideoThumbnailProps {\n thumbnailUrl: string;\n onClick?: () => void;\n}\n\nfunction VideoThumbnail({ thumbnailUrl, onClick }: VideoThumbnailProps) {\n return (\n <div\n className=\"relative overflow-hidden cursor-pointer\"\n style={{\n width: 480,\n height: 380,\n maxWidth: \"100%\",\n borderRadius: \"var(--radius-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n onClick={onClick}\n >\n <img\n src={thumbnailUrl}\n alt=\"Video thumbnail\"\n className=\"w-full h-full object-cover\"\n />\n {/* Play button */}\n <div\n className=\"absolute inset-0 flex items-center justify-center\"\n >\n <div\n className=\"flex items-center justify-center\"\n style={{\n width: 128,\n height: 80,\n background: \"var(--canvas-destructive)\",\n borderRadius: \"var(--radius-2xl)\",\n }}\n >\n <Play className=\"w-12 h-12 text-white fill-white\" />\n </div>\n </div>\n </div>\n );\n}\n\ninterface LinkPreviewCardProps {\n linkPreview: LinkPreview;\n onClick?: () => void;\n}\n\nfunction LinkPreviewCard({ linkPreview, onClick }: LinkPreviewCardProps) {\n return (\n <div\n className=\"flex overflow-hidden cursor-pointer\"\n style={{\n width: 580,\n maxWidth: \"100%\",\n background: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-2xl)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n }}\n onClick={onClick}\n >\n {linkPreview.imageUrl && (\n <div\n className=\"shrink-0 self-stretch overflow-hidden\"\n style={{\n width: 200,\n borderRight: \"1px solid var(--canvas-border)\",\n borderTopLeftRadius: \"var(--radius-md)\",\n borderBottomLeftRadius: \"var(--radius-md)\",\n }}\n >\n <img\n src={linkPreview.imageUrl}\n alt={linkPreview.title}\n className=\"w-full h-full object-cover\"\n />\n </div>\n )}\n <div\n className=\"flex flex-col flex-1\"\n style={{ padding: \"var(--spacing-4xl)\", gap: \"var(--spacing-lg)\" }}\n >\n <div className=\"flex flex-col\" style={{ gap: 0 }}>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {linkPreview.domain}\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: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {linkPreview.title}\n </span>\n </div>\n {linkPreview.description && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {linkPreview.description}\n </p>\n )}\n </div>\n </div>\n );\n}\n\ninterface RepostCardProps {\n repost: RepostContent;\n onLike?: () => void;\n onComment?: () => void;\n onRepost?: () => void;\n onShare?: () => void;\n}\n\nfunction RepostCard({ repost, onLike, onComment, onRepost, onShare }: RepostCardProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n padding: \"var(--spacing-4xl)\",\n background: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n gap: \"var(--spacing-xl)\",\n }}\n >\n {/* Author row */}\n <div className=\"flex items-start w-full\" style={{ gap: \"var(--spacing-xl)\" }}>\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={repost.author.avatarUrl} />\n <AvatarFallback>\n {repost.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col flex-1\" style={{ gap: \"var(--spacing-lg)\" }}>\n <div className=\"flex items-center justify-between w-full\">\n <div className=\"flex flex-col\" style={{ gap: 0 }}>\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 {repost.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 lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {repost.date}\n </span>\n </div>\n <button\n type=\"button\"\n className=\"flex items-center justify-center\"\n style={{\n width: 32,\n height: 32,\n borderRadius: \"var(--spacing-6xl)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <MoreHorizontal className=\"w-5 h-5\" />\n </button>\n </div>\n {/* Content */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {repost.content}\n </p>\n {/* Images */}\n {repost.images && repost.images.length > 0 && (\n <div className=\"flex\" style={{ gap: \"var(--spacing-xl)\" }}>\n {repost.images.map((img, idx) => (\n <div\n key={idx}\n className=\"overflow-hidden\"\n style={{\n width: 320,\n height: 320,\n borderRadius: \"var(--radius-2xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img src={img} alt=\"\" className=\"w-full h-full object-cover\" />\n </div>\n ))}\n </div>\n )}\n {/* Actions */}\n <ActionIconsRow\n isLiked={false}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n <StatsRow likesCount={30} repliesCount={10} />\n </div>\n </div>\n </div>\n );\n}\n\ninterface PostCellProps {\n post: SocialFeedPost;\n onLike?: () => void;\n onComment?: () => void;\n onRepost?: () => void;\n onShare?: () => void;\n onMenuClick?: () => void;\n}\n\nfunction PostCell({ post, onLike, onComment, onRepost, onShare, onMenuClick }: PostCellProps) {\n return (\n <div\n className=\"flex w-full\"\n style={{\n paddingLeft: post.isReply ? \"var(--spacing-7xl)\" : 0,\n paddingTop: post.isReply ? \"var(--spacing-3xl)\" : \"var(--spacing-xl)\",\n paddingBottom: post.isReply ? 0 : \"var(--spacing-3xl)\",\n borderBottom: post.isReply ? \"none\" : \"1px solid var(--canvas-border)\",\n gap: \"var(--spacing-xl)\",\n }}\n >\n {/* Avatar column with reply line */}\n <div className=\"flex flex-col items-center shrink-0\" style={{ gap: \"var(--spacing-md)\" }}>\n <Avatar\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={post.author.avatarUrl} />\n <AvatarFallback>\n {post.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n {/* Reply line */}\n {post.replies && post.replies.length > 0 && (\n <div\n className=\"flex-1 w-px\"\n style={{ background: \"var(--canvas-border)\", minHeight: 20 }}\n />\n )}\n </div>\n\n {/* Content column */}\n <div className=\"flex flex-col flex-1 min-w-0\" style={{ gap: \"var(--spacing-lg)\" }}>\n {/* Header */}\n <div className=\"flex items-center justify-between w-full\">\n <div className=\"flex flex-col\" style={{ gap: 0 }}>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {post.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {post.date}\n </span>\n </div>\n <button\n type=\"button\"\n onClick={onMenuClick}\n className=\"flex items-center justify-center\"\n style={{\n width: 32,\n height: 32,\n borderRadius: \"var(--spacing-6xl)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <MoreHorizontal className=\"w-5 h-5\" />\n </button>\n </div>\n\n {/* Content text */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {post.content}\n </p>\n\n {/* Repost card */}\n {post.repost && (\n <RepostCard\n repost={post.repost}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n )}\n\n {/* Images */}\n {post.images && post.images.length > 0 && (\n <div className=\"flex\" style={{ gap: \"var(--spacing-xl)\" }}>\n {post.images.map((img, idx) => (\n <div\n key={idx}\n className=\"overflow-hidden\"\n style={{\n width: 320,\n height: 320,\n borderRadius: \"var(--radius-2xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img src={img} alt=\"\" className=\"w-full h-full object-cover\" />\n </div>\n ))}\n </div>\n )}\n\n {/* Video */}\n {post.video && (\n <VideoThumbnail thumbnailUrl={post.video.thumbnailUrl} />\n )}\n\n {/* Link preview */}\n {post.linkPreview && (\n <LinkPreviewCard linkPreview={post.linkPreview} />\n )}\n\n {/* Actions */}\n <ActionIconsRow\n isLiked={post.isLiked}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n\n {/* Stats */}\n <StatsRow likesCount={post.likesCount} repliesCount={post.repliesCount} />\n\n {/* Nested replies */}\n {post.replies && post.replies.length > 0 && (\n <div className=\"flex flex-col w-full\">\n {post.replies.map((reply) => (\n <PostCell\n key={reply.id}\n post={reply}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n ))}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Social Feed Block\n *\n * A social media-style feed component with post composer, posts with various\n * content types (text, images, video, reposts, link cards), social interactions,\n * and threaded replies.\n *\n * @example\n * ```tsx\n * <SocialFeed\n * title=\"Social Feed\"\n * posts={[...]}\n * onLike={(postId) => console.log(\"Liked\", postId)}\n * onPost={(content) => console.log(\"Posted\", content)}\n * />\n * ```\n */\nexport function SocialFeed({\n title = \"Social Feed\",\n posts = defaultPosts,\n currentUser = defaultCurrentUser,\n composerPlaceholder = \"What's on your mind?\",\n composerImagePreview,\n onPost,\n onLike,\n onComment,\n onRepost,\n onShare,\n onMenuClick,\n className,\n}: SocialFeedProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n {title && (\n <div className=\"flex flex-wrap items-start w-full\" style={{ gap: \"var(--spacing-xl)\" }}>\n <div className=\"flex flex-col flex-1 min-w-[200px]\" style={{ gap: \"var(--spacing-xs)\" }}>\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 </div>\n </div>\n )}\n\n {/* Feed content */}\n <div className=\"flex flex-col w-full overflow-hidden\">\n {/* First section: Composer + first set of posts */}\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 {/* Post Composer */}\n <PostComposer\n placeholder={composerPlaceholder}\n imagePreview={composerImagePreview}\n onPost={onPost}\n />\n\n {/* Posts */}\n <div className=\"flex flex-col w-full\" style={{ paddingTop: \"var(--spacing-xl)\" }}>\n {posts.map((post) => (\n <PostCell\n key={post.id}\n post={post}\n onLike={() => onLike?.(post.id)}\n onComment={() => onComment?.(post.id)}\n onRepost={() => onRepost?.(post.id)}\n onShare={() => onShare?.(post.id)}\n onMenuClick={() => onMenuClick?.(post.id)}\n />\n ))}\n </div>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { Button } from \"../ui/button\";\nimport { \n Heart, \n MessageCircle, \n RefreshCw, \n Send, \n Paperclip, \n Video, \n Link2, \n MoreHorizontal,\n Play\n} from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PostAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface LinkPreview {\n url: string;\n domain: string;\n title: string;\n description?: string;\n imageUrl?: string;\n}\n\nexport interface VideoMedia {\n thumbnailUrl: string;\n videoUrl?: string;\n}\n\nexport interface RepostContent {\n author: PostAuthor;\n date: string;\n content: string;\n images?: string[];\n}\n\nexport interface SocialFeedPost {\n id: string;\n author: PostAuthor;\n date: string;\n content: string;\n /** Image URLs for the post */\n images?: string[];\n /** Video media */\n video?: VideoMedia;\n /** Link preview card */\n linkPreview?: LinkPreview;\n /** Reposted/quoted content */\n repost?: RepostContent;\n likesCount: number;\n repliesCount: number;\n isLiked?: boolean;\n /** Nested replies */\n replies?: SocialFeedPost[];\n /** Whether this is a reply (for indentation) */\n isReply?: boolean;\n}\n\nexport interface SocialFeedProps {\n /** Section title */\n title?: string;\n /** Posts data */\n posts?: SocialFeedPost[];\n /** Current user for composer */\n currentUser?: PostAuthor;\n /** Placeholder text for composer */\n composerPlaceholder?: string;\n /** Image preview in composer */\n composerImagePreview?: string;\n /** Callback when post is submitted */\n onPost?: (content: string) => void;\n /** Callback when like is clicked */\n onLike?: (postId: string) => void;\n /** Callback when comment is clicked */\n onComment?: (postId: string) => void;\n /** Callback when repost is clicked */\n onRepost?: (postId: string) => void;\n /** Callback when share is clicked */\n onShare?: (postId: string) => void;\n /** Callback when menu is clicked */\n onMenuClick?: (postId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: PostAuthor = {\n id: \"current\",\n name: \"You\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n};\n\nconst defaultPosts: SocialFeedPost[] = [\n {\n id: \"1\",\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 date: \"Feb 23, 1:32 PM\",\n content: \"Thinking about traveling to Paris again!\",\n repost: {\n author: {\n id: \"jeffrey\",\n name: \"Jeffrey Connor\",\n avatarUrl: \"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"Nov 23, 5:34 PM\",\n content: \"What a place, the history, architecture and culture is wonderful. So many sites to see, one more amazing then the next. A must see if you are going to visit the great cities of the world.\",\n images: [\n \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=320&h=320&fit=crop\",\n \"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=320&h=320&fit=crop\",\n ],\n },\n likesCount: 30,\n repliesCount: 10,\n isLiked: false,\n },\n {\n id: \"2\",\n author: {\n id: \"mary\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"Feb 23, 1:32 PM\",\n content: \"Learning how to Bubble\",\n video: {\n thumbnailUrl: \"https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=480&h=380&fit=crop\",\n },\n likesCount: 30,\n repliesCount: 10,\n isLiked: false,\n replies: [\n {\n id: \"2-reply-1\",\n author: {\n id: \"aya\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"Mar 12, 11:23 AM\",\n content: \"Check out these flight deals to Paris!\",\n linkPreview: {\n url: \"https://expedia.com/flights/paris\",\n domain: \"expedia.com\",\n title: \"Paris flights\",\n description: \"Your one-stop travel site for your dream vacation. Bundle your stay...\",\n imageUrl: \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=200&h=200&fit=crop\",\n },\n likesCount: 30,\n repliesCount: 10,\n isLiked: false,\n isReply: true,\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface PostComposerProps {\n placeholder?: string;\n imagePreview?: string;\n onPost?: (content: string) => void;\n}\n\nfunction PostComposer({ placeholder = \"What's on your mind?\", imagePreview, onPost }: PostComposerProps) {\n const [content, setContent] = useState(\"\");\n\n const handlePost = () => {\n if (content.trim() || imagePreview) {\n onPost?.(content);\n setContent(\"\");\n }\n };\n\n return (\n <div\n className=\"flex flex-col w-full overflow-hidden\"\n style={{\n border: \"1px solid var(--canvas-border)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n }}\n >\n {/* Text input area */}\n <div\n className=\"w-full\"\n style={{\n padding: \"var(--spacing-xl)\",\n background: \"var(--canvas-background)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <textarea\n value={content}\n onChange={(e) => setContent(e.target.value)}\n placeholder={placeholder}\n className=\"w-full resize-none border-0 bg-transparent outline-none\"\n rows={2}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: content ? \"var(--canvas-text)\" : \"var(--canvas-text-placeholder)\",\n }}\n />\n </div>\n\n {/* Image preview */}\n {imagePreview && (\n <div\n className=\"w-full\"\n style={{\n padding: \"0 var(--spacing-xl)\",\n background: \"var(--canvas-background)\",\n }}\n >\n <div\n className=\"overflow-hidden\"\n style={{\n width: 240,\n height: 180,\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img\n src={imagePreview}\n alt=\"Preview\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n )}\n\n {/* Action bar */}\n <div\n className=\"flex items-center justify-between w-full\"\n style={{\n padding: \"var(--spacing-xl)\",\n background: \"var(--canvas-background)\",\n borderTop: \"1px solid var(--canvas-border)\",\n }}\n >\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-lg)\" }}>\n <button type=\"button\" className=\"cursor-pointer\" style={{ color: \"var(--canvas-text)\" }}>\n <Paperclip className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" className=\"cursor-pointer\" style={{ color: \"var(--canvas-text)\" }}>\n <Video className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" className=\"cursor-pointer\" style={{ color: \"var(--canvas-text)\" }}>\n <Link2 className=\"w-5 h-5\" />\n </button>\n </div>\n <Button variant=\"primary\" size=\"sm\" onClick={handlePost}>\n Post\n </Button>\n </div>\n </div>\n );\n}\n\ninterface ActionIconsRowProps {\n isLiked?: boolean;\n onLike?: () => void;\n onComment?: () => void;\n onRepost?: () => void;\n onShare?: () => void;\n}\n\nfunction ActionIconsRow({ isLiked, onLike, onComment, onRepost, onShare }: ActionIconsRowProps) {\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-lg)\", padding: \"var(--spacing-xxs) 0\" }}\n >\n <button\n type=\"button\"\n onClick={onLike}\n className=\"cursor-pointer\"\n style={{ color: isLiked ? \"var(--canvas-destructive)\" : \"var(--canvas-text)\" }}\n >\n <Heart\n className=\"w-5 h-5\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n }}\n />\n </button>\n <button type=\"button\" onClick={onComment} className=\"cursor-pointer\" style={{ color: \"var(--canvas-text)\" }}>\n <MessageCircle className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" onClick={onRepost} className=\"cursor-pointer\" style={{ color: \"var(--canvas-text)\" }}>\n <RefreshCw className=\"w-5 h-5\" />\n </button>\n <button type=\"button\" onClick={onShare} className=\"cursor-pointer\" style={{ color: \"var(--canvas-text)\" }}>\n <Send className=\"w-5 h-5\" />\n </button>\n </div>\n );\n}\n\ninterface StatsRowProps {\n likesCount: number;\n repliesCount: number;\n}\n\nfunction StatsRow({ likesCount, repliesCount }: StatsRowProps) {\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xl)\", paddingTop: \"var(--spacing-xs)\" }}\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-placeholder)\",\n }}\n >\n {likesCount} likes\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: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {repliesCount} replies\n </span>\n </div>\n );\n}\n\ninterface VideoThumbnailProps {\n thumbnailUrl: string;\n onClick?: () => void;\n}\n\nfunction VideoThumbnail({ thumbnailUrl, onClick }: VideoThumbnailProps) {\n return (\n <div\n className=\"relative overflow-hidden cursor-pointer\"\n style={{\n width: 480,\n height: 380,\n maxWidth: \"100%\",\n borderRadius: \"var(--radius-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n onClick={onClick}\n >\n <img\n src={thumbnailUrl}\n alt=\"Video thumbnail\"\n className=\"w-full h-full object-cover\"\n />\n {/* Play button */}\n <div\n className=\"absolute inset-0 flex items-center justify-center\"\n >\n <div\n className=\"flex items-center justify-center\"\n style={{\n width: 128,\n height: 80,\n background: \"var(--canvas-destructive)\",\n borderRadius: \"var(--radius-2xl)\",\n }}\n >\n <Play className=\"w-12 h-12 text-white fill-white\" />\n </div>\n </div>\n </div>\n );\n}\n\ninterface LinkPreviewCardProps {\n linkPreview: LinkPreview;\n onClick?: () => void;\n}\n\nfunction LinkPreviewCard({ linkPreview, onClick }: LinkPreviewCardProps) {\n return (\n <div\n className=\"flex overflow-hidden cursor-pointer\"\n style={{\n width: 580,\n maxWidth: \"100%\",\n background: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-2xl)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n }}\n onClick={onClick}\n >\n {linkPreview.imageUrl && (\n <div\n className=\"shrink-0 self-stretch overflow-hidden\"\n style={{\n width: 200,\n borderRight: \"1px solid var(--canvas-border)\",\n borderTopLeftRadius: \"var(--radius-md)\",\n borderBottomLeftRadius: \"var(--radius-md)\",\n }}\n >\n <img\n src={linkPreview.imageUrl}\n alt={linkPreview.title}\n className=\"w-full h-full object-cover\"\n />\n </div>\n )}\n <div\n className=\"flex flex-col flex-1\"\n style={{ padding: \"var(--spacing-4xl)\", gap: \"var(--spacing-lg)\" }}\n >\n <div className=\"flex flex-col\" style={{ gap: 0 }}>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {linkPreview.domain}\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: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {linkPreview.title}\n </span>\n </div>\n {linkPreview.description && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {linkPreview.description}\n </p>\n )}\n </div>\n </div>\n );\n}\n\ninterface RepostCardProps {\n repost: RepostContent;\n onLike?: () => void;\n onComment?: () => void;\n onRepost?: () => void;\n onShare?: () => void;\n}\n\nfunction RepostCard({ repost, onLike, onComment, onRepost, onShare }: RepostCardProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n padding: \"var(--spacing-4xl)\",\n background: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-md)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n gap: \"var(--spacing-xl)\",\n }}\n >\n {/* Author row */}\n <div className=\"flex items-start w-full\" style={{ gap: \"var(--spacing-xl)\" }}>\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={repost.author.avatarUrl} />\n <AvatarFallback>\n {repost.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col flex-1\" style={{ gap: \"var(--spacing-lg)\" }}>\n <div className=\"flex items-center justify-between w-full\">\n <div className=\"flex flex-col\" style={{ gap: 0 }}>\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 {repost.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 lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {repost.date}\n </span>\n </div>\n <button\n type=\"button\"\n className=\"cursor-pointer flex items-center justify-center\"\n style={{\n width: 32,\n height: 32,\n borderRadius: \"var(--spacing-6xl)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <MoreHorizontal className=\"w-5 h-5\" />\n </button>\n </div>\n {/* Content */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {repost.content}\n </p>\n {/* Images */}\n {repost.images && repost.images.length > 0 && (\n <div className=\"flex\" style={{ gap: \"var(--spacing-xl)\" }}>\n {repost.images.map((img, idx) => (\n <div\n key={idx}\n className=\"overflow-hidden\"\n style={{\n width: 320,\n height: 320,\n borderRadius: \"var(--radius-2xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img src={img} alt=\"\" className=\"w-full h-full object-cover\" />\n </div>\n ))}\n </div>\n )}\n {/* Actions */}\n <ActionIconsRow\n isLiked={false}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n <StatsRow likesCount={30} repliesCount={10} />\n </div>\n </div>\n </div>\n );\n}\n\ninterface PostCellProps {\n post: SocialFeedPost;\n onLike?: () => void;\n onComment?: () => void;\n onRepost?: () => void;\n onShare?: () => void;\n onMenuClick?: () => void;\n}\n\nfunction PostCell({ post, onLike, onComment, onRepost, onShare, onMenuClick }: PostCellProps) {\n return (\n <div\n className=\"flex w-full\"\n style={{\n paddingLeft: post.isReply ? \"var(--spacing-7xl)\" : 0,\n paddingTop: post.isReply ? \"var(--spacing-3xl)\" : \"var(--spacing-xl)\",\n paddingBottom: post.isReply ? 0 : \"var(--spacing-3xl)\",\n borderBottom: post.isReply ? \"none\" : \"1px solid var(--canvas-border)\",\n gap: \"var(--spacing-xl)\",\n }}\n >\n {/* Avatar column with reply line */}\n <div className=\"flex flex-col items-center shrink-0\" style={{ gap: \"var(--spacing-md)\" }}>\n <Avatar\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={post.author.avatarUrl} />\n <AvatarFallback>\n {post.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n {/* Reply line */}\n {post.replies && post.replies.length > 0 && (\n <div\n className=\"flex-1 w-px\"\n style={{ background: \"var(--canvas-border)\", minHeight: 20 }}\n />\n )}\n </div>\n\n {/* Content column */}\n <div className=\"flex flex-col flex-1 min-w-0\" style={{ gap: \"var(--spacing-lg)\" }}>\n {/* Header */}\n <div className=\"flex items-center justify-between w-full\">\n <div className=\"flex flex-col\" style={{ gap: 0 }}>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {post.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {post.date}\n </span>\n </div>\n <button\n type=\"button\"\n onClick={onMenuClick}\n className=\"cursor-pointer flex items-center justify-center\"\n style={{\n width: 32,\n height: 32,\n borderRadius: \"var(--spacing-6xl)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <MoreHorizontal className=\"w-5 h-5\" />\n </button>\n </div>\n\n {/* Content text */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {post.content}\n </p>\n\n {/* Repost card */}\n {post.repost && (\n <RepostCard\n repost={post.repost}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n )}\n\n {/* Images */}\n {post.images && post.images.length > 0 && (\n <div className=\"flex\" style={{ gap: \"var(--spacing-xl)\" }}>\n {post.images.map((img, idx) => (\n <div\n key={idx}\n className=\"overflow-hidden\"\n style={{\n width: 320,\n height: 320,\n borderRadius: \"var(--radius-2xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img src={img} alt=\"\" className=\"w-full h-full object-cover\" />\n </div>\n ))}\n </div>\n )}\n\n {/* Video */}\n {post.video && (\n <VideoThumbnail thumbnailUrl={post.video.thumbnailUrl} />\n )}\n\n {/* Link preview */}\n {post.linkPreview && (\n <LinkPreviewCard linkPreview={post.linkPreview} />\n )}\n\n {/* Actions */}\n <ActionIconsRow\n isLiked={post.isLiked}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n\n {/* Stats */}\n <StatsRow likesCount={post.likesCount} repliesCount={post.repliesCount} />\n\n {/* Nested replies */}\n {post.replies && post.replies.length > 0 && (\n <div className=\"flex flex-col w-full\">\n {post.replies.map((reply) => (\n <PostCell\n key={reply.id}\n post={reply}\n onLike={onLike}\n onComment={onComment}\n onRepost={onRepost}\n onShare={onShare}\n />\n ))}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Social Feed Block\n *\n * A social media-style feed component with post composer, posts with various\n * content types (text, images, video, reposts, link cards), social interactions,\n * and threaded replies.\n *\n * @example\n * ```tsx\n * <SocialFeed\n * title=\"Social Feed\"\n * posts={[...]}\n * onLike={(postId) => console.log(\"Liked\", postId)}\n * onPost={(content) => console.log(\"Posted\", content)}\n * />\n * ```\n */\nexport function SocialFeed({\n title = \"Social Feed\",\n posts = defaultPosts,\n currentUser = defaultCurrentUser,\n composerPlaceholder = \"What's on your mind?\",\n composerImagePreview,\n onPost,\n onLike,\n onComment,\n onRepost,\n onShare,\n onMenuClick,\n className,\n}: SocialFeedProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n {title && (\n <div className=\"flex flex-wrap items-start w-full\" style={{ gap: \"var(--spacing-xl)\" }}>\n <div className=\"flex flex-col flex-1 min-w-[200px]\" style={{ gap: \"var(--spacing-xs)\" }}>\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 </div>\n </div>\n )}\n\n {/* Feed content */}\n <div className=\"flex flex-col w-full overflow-hidden\">\n {/* First section: Composer + first set of posts */}\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 {/* Post Composer */}\n <PostComposer\n placeholder={composerPlaceholder}\n imagePreview={composerImagePreview}\n onPost={onPost}\n />\n\n {/* Posts */}\n <div className=\"flex flex-col w-full\" style={{ paddingTop: \"var(--spacing-xl)\" }}>\n {posts.map((post) => (\n <PostCell\n key={post.id}\n post={post}\n onLike={() => onLike?.(post.id)}\n onComment={() => onComment?.(post.id)}\n onRepost={() => onRepost?.(post.id)}\n onShare={() => onShare?.(post.id)}\n onMenuClick={() => onMenuClick?.(post.id)}\n />\n ))}\n </div>\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/step-tracker.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\n\nexport interface Step {\n id: string;\n label: string;\n /** Optional description for the step content area */\n description?: string;\n}\n\nexport interface StepTrackerProps {\n /** Array of step objects with id and label */\n steps: Step[];\n /** Current active step (0-indexed) */\n currentStep: number;\n /** Optional callback when a step is clicked */\n onStepClick?: (stepIndex: number) => void;\n /** Additional class name */\n className?: string;\n}\n\n// Constants for layout calculations\nconst STEP_WIDTH = 112; // px (w-28)\nconst GAP = 16; // px (gap-4)\nconst CIRCLE_SIZE = 36; // px (size-9)\n\n/**\n * Canvas Design System - Step Tracker\n * \n * A horizontal multi-step progress tracker showing:\n * - Numbered circles for each step (36px)\n * - Connecting lines between steps\n * - Completed steps show checkmark\n * - Active step has primary border\n * - Inactive steps have gray background\n */\nexport function StepTracker({\n steps,\n currentStep,\n onStepClick,\n className,\n}: StepTrackerProps) {\n // Calculate progress line width\n const totalWidth = steps.length * STEP_WIDTH + (steps.length - 1) * GAP;\n const progressWidth = `calc(50% - ${totalWidth / 2}px + ${currentStep * (STEP_WIDTH + GAP) + STEP_WIDTH / 2}px)`;\n\n return (\n <div \n className={cn(\n \"w-full py-4\",\n className\n )}\n >\n {/* Container with relative positioning for the background line */}\n <div className=\"relative flex justify-center\">\n {/* Background line - spans full width, positioned at circle center (18px = half of 36px) */}\n <div \n className=\"absolute left-0 right-0 h-1 bg-[var(--canvas-border)]\"\n style={{ top: `${CIRCLE_SIZE / 2}px` }} \n />\n \n {/* Progress line - colored portion up to center of current step */}\n <div \n className=\"absolute left-0 h-1 bg-[var(--canvas-primary)]\"\n style={{ \n top: `${CIRCLE_SIZE / 2}px`,\n width: progressWidth\n }} \n />\n\n {/* Steps */}\n <div className=\"relative flex gap-4\">\n {steps.map((step, index) => {\n const isCompleted = index < currentStep;\n const isActive = index === currentStep;\n const isUpcoming = index > currentStep;\n\n return (\n <div\n key={step.id}\n className=\"flex flex-col items-center w-28\"\n >\n {/* Step circle - 36px with 16px text */}\n <button\n type=\"button\"\n onClick={() => onStepClick?.(index)}\n disabled={!onStepClick}\n className={cn(\n \"relative z-10 flex items-center justify-center size-9 rounded-full shrink-0\",\n \"border-2 transition-colors\",\n onStepClick && \"cursor-pointer\",\n !onStepClick && \"cursor-default\",\n isCompleted && \"bg-[var(--canvas-primary)] border-[var(--canvas-primary)] text-[var(--canvas-primary-foreground)]\",\n isActive && \"bg-
|
|
9
|
+
"content": "\"use client\";\n\nimport { cn } from \"../../lib/utils\";\n\nexport interface Step {\n id: string;\n label: string;\n /** Optional description for the step content area */\n description?: string;\n}\n\nexport interface StepTrackerProps {\n /** Array of step objects with id and label */\n steps: Step[];\n /** Current active step (0-indexed) */\n currentStep: number;\n /** Optional callback when a step is clicked */\n onStepClick?: (stepIndex: number) => void;\n /** Additional class name */\n className?: string;\n}\n\n// Constants for layout calculations\nconst STEP_WIDTH = 112; // px (w-28)\nconst GAP = 16; // px (gap-4)\nconst CIRCLE_SIZE = 36; // px (size-9)\n\n/**\n * Canvas Design System - Step Tracker\n * \n * A horizontal multi-step progress tracker showing:\n * - Numbered circles for each step (36px)\n * - Connecting lines between steps\n * - Completed steps show checkmark\n * - Active step has primary border\n * - Inactive steps have gray background\n */\nexport function StepTracker({\n steps,\n currentStep,\n onStepClick,\n className,\n}: StepTrackerProps) {\n // Calculate progress line width\n const totalWidth = steps.length * STEP_WIDTH + (steps.length - 1) * GAP;\n const progressWidth = `calc(50% - ${totalWidth / 2}px + ${currentStep * (STEP_WIDTH + GAP) + STEP_WIDTH / 2}px)`;\n\n return (\n <div \n className={cn(\n \"w-full py-4\",\n className\n )}\n >\n {/* Container with relative positioning for the background line */}\n <div className=\"relative flex justify-center\">\n {/* Background line - spans full width, positioned at circle center (18px = half of 36px) */}\n <div \n className=\"absolute left-0 right-0 h-1 bg-[var(--canvas-border)]\"\n style={{ top: `${CIRCLE_SIZE / 2}px` }} \n />\n \n {/* Progress line - colored portion up to center of current step */}\n <div \n className=\"absolute left-0 h-1 bg-[var(--canvas-primary)]\"\n style={{ \n top: `${CIRCLE_SIZE / 2}px`,\n width: progressWidth\n }} \n />\n\n {/* Steps */}\n <div className=\"relative flex gap-4\">\n {steps.map((step, index) => {\n const isCompleted = index < currentStep;\n const isActive = index === currentStep;\n const isUpcoming = index > currentStep;\n\n return (\n <div\n key={step.id}\n className=\"flex flex-col items-center w-28\"\n >\n {/* Step circle - 36px with 16px text */}\n <button\n type=\"button\"\n onClick={() => onStepClick?.(index)}\n disabled={!onStepClick}\n className={cn(\n \"relative z-10 flex items-center justify-center size-9 rounded-full shrink-0\",\n \"border-2 transition-colors\",\n onStepClick && \"cursor-pointer\",\n !onStepClick && \"cursor-default\",\n isCompleted && \"bg-[var(--canvas-primary)] border-[var(--canvas-primary)] text-[var(--canvas-primary-foreground)]\",\n isActive && \"bg-[var(--canvas-background)] border-[var(--canvas-primary)] text-[var(--canvas-primary)]\",\n isUpcoming && \"bg-[var(--canvas-border)] border-[var(--canvas-border)] text-[var(--canvas-text-placeholder)]\"\n )}\n style={{\n fontFamily: \"var(--typo-body-m-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-m-size)\",\n fontWeight: 500,\n }}\n >\n <span>{index + 1}</span>\n </button>\n\n {/* Step label */}\n <span \n className={cn(\n \"text-center w-full mt-1\",\n (isCompleted || isActive) && \"text-[var(--canvas-primary)]\",\n isUpcoming && \"text-[var(--canvas-text-placeholder)]\"\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n letterSpacing: \"var(--typo-body-s-spacing)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n }}\n >\n {step.label}\n </span>\n </div>\n );\n })}\n </div>\n </div>\n </div>\n );\n}\n\n/** Default steps for demo/placeholder purposes */\nexport const defaultSteps: Step[] = [\n { id: \"basic-info\", label: \"Basic info\", description: \"Enter your basic information\" },\n { id: \"more-details\", label: \"More details\", description: \"Provide additional details\" },\n { id: \"shipping\", label: \"Shipping\", description: \"Review and confirm shipping\" },\n];\n\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/upvoting-posts-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 { Input } from \"../ui/input\";\nimport { ArrowUp, MessageCircle, Heart, Paperclip } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PostAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface PostComment {\n id: string;\n author: PostAuthor;\n content: string;\n timestamp: string;\n likes: number;\n isLiked?: boolean;\n replies?: PostComment[];\n}\n\nexport interface Post {\n id: string;\n author: PostAuthor;\n date: string;\n title: string;\n content: string;\n imageUrl?: string;\n upvotes: number;\n isUpvoted?: boolean;\n comments?: PostComment[];\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 UpvotingPostsTableProps {\n /** Table title */\n title?: string;\n /** Subtitle text */\n subtitle?: string;\n /** Posts data */\n posts?: Post[];\n /** Current user for comment input avatars */\n currentUser?: PostAuthor;\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 upvote is clicked */\n onUpvote?: (postId: string) => void;\n /** Callback when comment is submitted */\n onComment?: (postId: string, content: string) => void;\n /** Callback when reply is submitted */\n onReply?: (postId: string, commentId: string, content: string) => void;\n /** Callback when like is clicked */\n onLike?: (postId: string, commentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: PostAuthor = {\n id: \"current\",\n name: \"Current User\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n};\n\nconst defaultPosts: Post[] = [\n {\n id: \"1\",\n author: {\n id: \"aya\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"May 23, 2024\",\n title: \"Latest travels\",\n content: \"Just touched down in the City of Lights! 🇫🇷 There's something truly magical about Paris - the cobblestone streets, the aroma of freshly baked croissants, and of course, the iconic Eiffel Tower piercing the sky. It's one of those moments where you realize dreams do come true. Can't wait to explore every corner of this enchanting city! #Paris #EiffelTower #TravelGoals\",\n imageUrl: \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=500&fit=crop\",\n upvotes: 8,\n isUpvoted: false,\n comments: [\n {\n id: \"c1\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Wow, Paris looks absolutely stunning! The Eiffel Tower is such an iconic landmark. Hope you have an amazing time exploring the city and soaking in all its beauty. Safe travels!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 3,\n isLiked: true,\n replies: [\n {\n id: \"r1\",\n author: {\n id: \"raj\",\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\nconst defaultSortOptions: SortOption[] = [\n { id: \"newest\", label: \"Newest first\" },\n { id: \"oldest\", label: \"Oldest first\" },\n { id: \"most-upvoted\", label: \"Most upvoted\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All posts\" },\n { id: \"my-posts\", label: \"My posts\" },\n { id: \"following\", label: \"Following\" },\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 PostCommentItemProps {\n comment: PostComment;\n currentUser?: PostAuthor;\n depth?: number;\n onReply?: (content: string) => void;\n onLike?: () => void;\n}\n\nfunction PostCommentItem({\n comment,\n currentUser,\n depth = 0,\n onReply,\n onLike,\n}: PostCommentItemProps) {\n const [showReplyInput, setShowReplyInput] = useState(false);\n const [showReplies, setShowReplies] = useState(true);\n const hasReplies = comment.replies && comment.replies.length > 0;\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 {/* Nested replies */}\n {hasReplies && showReplies && (\n <div\n className=\"flex flex-col\"\n style={{\n paddingLeft: \"var(--spacing-7xl)\",\n gap: \"var(--spacing-xl)\",\n }}\n >\n {comment.replies!.map((reply) => (\n <PostCommentItem\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\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 }}\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 }}\n >\n {showReplies ? \"Hide replies\" : \"Show replies\"}\n </button>\n </div>\n )}\n </div>\n );\n}\n\ninterface PostCardProps {\n post: Post;\n currentUser?: PostAuthor;\n onUpvote?: () => void;\n onComment?: (content: string) => void;\n onReply?: (commentId: string, content: string) => void;\n onLike?: (commentId: string) => void;\n}\n\nfunction PostCard({\n post,\n currentUser,\n onUpvote,\n onComment,\n onReply,\n onLike,\n}: PostCardProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n gap: \"var(--spacing-xl)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingBottom: \"var(--spacing-5xl)\",\n }}\n >\n {/* Post content */}\n <div className=\"flex w-full\" style={{ gap: \"var(--spacing-3xl)\" }}>\n {/* Upvote button */}\n <div\n className=\"flex flex-col items-center shrink-0\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <button\n type=\"button\"\n onClick={onUpvote}\n className=\"flex items-center justify-center\"\n style={{\n width: 40,\n height: 40,\n borderRadius: \"var(--radius-xs)\",\n border: \"1px solid var(--canvas-border)\",\n backgroundColor: \"var(--canvas-background)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n color: post.isUpvoted ? \"var(--canvas-primary)\" : \"var(--canvas-text-muted)\",\n }}\n >\n <ArrowUp className=\"w-6 h-6\" />\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: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {post.upvotes}\n </span>\n </div>\n\n {/* Post main content */}\n <div\n className=\"flex-1 flex flex-col\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Author row */}\n <div className=\"flex items-start\" style={{ gap: \"var(--spacing-xl)\" }}>\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={post.author.avatarUrl} />\n <AvatarFallback>\n {post.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {post.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {post.date}\n </span>\n </div>\n </div>\n\n {/* Title and content */}\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xs)\" }}>\n <h3\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {post.title}\n </h3>\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 {post.content}\n </p>\n </div>\n\n {/* Post image */}\n {post.imageUrl && (\n <div\n className=\"w-full overflow-hidden\"\n style={{\n maxWidth: 576,\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img\n src={post.imageUrl}\n alt={post.title}\n className=\"w-full h-auto object-cover\"\n style={{ maxHeight: 375 }}\n />\n </div>\n )}\n\n {/* Comment button */}\n <div\n className=\"flex items-center justify-center w-full\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n paddingTop: \"var(--spacing-lg)\",\n paddingBottom: \"var(--spacing-lg)\",\n }}\n >\n <button\n type=\"button\"\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 <MessageCircle className=\"w-5 h-5\" />\n Comment\n </button>\n </div>\n </div>\n </div>\n\n {/* 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 section */}\n {post.comments && post.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 {post.comments.map((comment) => (\n <PostCommentItem\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 );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Upvoting Posts Table Block\n *\n * A social feed component with upvoting, comments, and nested replies.\n * Features header with sort/filter controls and action button.\n *\n * @example\n * ```tsx\n * <UpvotingPostsTable\n * title=\"My posts\"\n * subtitle=\"In the past year\"\n * posts={[...]}\n * onUpvote={(postId) => console.log(\"Upvoted\", postId)}\n * />\n * ```\n */\nexport function UpvotingPostsTable({\n title = \"My posts\",\n subtitle = \"In the past year\",\n posts = defaultPosts,\n currentUser = defaultCurrentUser,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onUpvote,\n onComment,\n onReply,\n onLike,\n className,\n}: UpvotingPostsTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\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 {subtitle}\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 {/* Posts List */}\n <div className=\"flex flex-col w-full\">\n {posts.map((post) => (\n <PostCard\n key={post.id}\n post={post}\n currentUser={currentUser}\n onUpvote={() => onUpvote?.(post.id)}\n onComment={(content) => onComment?.(post.id, content)}\n onReply={(commentId, content) => onReply?.(post.id, commentId, content)}\n onLike={(commentId) => onLike?.(post.id, commentId)}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { 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 { Input } from \"../ui/input\";\nimport { ArrowUp, MessageCircle, Heart, Paperclip } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PostAuthor {\n id: string;\n name: string;\n avatarUrl?: string;\n}\n\nexport interface PostComment {\n id: string;\n author: PostAuthor;\n content: string;\n timestamp: string;\n likes: number;\n isLiked?: boolean;\n replies?: PostComment[];\n}\n\nexport interface Post {\n id: string;\n author: PostAuthor;\n date: string;\n title: string;\n content: string;\n imageUrl?: string;\n upvotes: number;\n isUpvoted?: boolean;\n comments?: PostComment[];\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 UpvotingPostsTableProps {\n /** Table title */\n title?: string;\n /** Subtitle text */\n subtitle?: string;\n /** Posts data */\n posts?: Post[];\n /** Current user for comment input avatars */\n currentUser?: PostAuthor;\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 upvote is clicked */\n onUpvote?: (postId: string) => void;\n /** Callback when comment is submitted */\n onComment?: (postId: string, content: string) => void;\n /** Callback when reply is submitted */\n onReply?: (postId: string, commentId: string, content: string) => void;\n /** Callback when like is clicked */\n onLike?: (postId: string, commentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: PostAuthor = {\n id: \"current\",\n name: \"Current User\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n};\n\nconst defaultPosts: Post[] = [\n {\n id: \"1\",\n author: {\n id: \"aya\",\n name: \"Aya Williams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"May 23, 2024\",\n title: \"Latest travels\",\n content: \"Just touched down in the City of Lights! 🇫🇷 There's something truly magical about Paris - the cobblestone streets, the aroma of freshly baked croissants, and of course, the iconic Eiffel Tower piercing the sky. It's one of those moments where you realize dreams do come true. Can't wait to explore every corner of this enchanting city! #Paris #EiffelTower #TravelGoals\",\n imageUrl: \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&h=500&fit=crop\",\n upvotes: 8,\n isUpvoted: false,\n comments: [\n {\n id: \"c1\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Wow, Paris looks absolutely stunning! The Eiffel Tower is such an iconic landmark. Hope you have an amazing time exploring the city and soaking in all its beauty. Safe travels!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 3,\n isLiked: true,\n replies: [\n {\n id: \"r1\",\n author: {\n id: \"raj\",\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\nconst defaultSortOptions: SortOption[] = [\n { id: \"newest\", label: \"Newest first\" },\n { id: \"oldest\", label: \"Oldest first\" },\n { id: \"most-upvoted\", label: \"Most upvoted\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All posts\" },\n { id: \"my-posts\", label: \"My posts\" },\n { id: \"following\", label: \"Following\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface CommentInputProps {\n avatarUrl?: string;\n avatarFallback?: string;\n placeholder?: string;\n buttonText?: string;\n size?: \"default\" | \"small\";\n showAttachment?: boolean;\n onSubmit?: (content: string) => void;\n}\n\nfunction CommentInput({\n avatarUrl,\n avatarFallback = \"U\",\n placeholder = \"Send a message\",\n buttonText = \"Send\",\n size = \"default\",\n showAttachment = true,\n onSubmit,\n}: CommentInputProps) {\n const [value, setValue] = useState(\"\");\n const avatarSize = size === \"small\" ? 40 : 48;\n\n const handleSubmit = () => {\n if (value.trim() && onSubmit) {\n onSubmit(value.trim());\n setValue(\"\");\n }\n };\n\n return (\n <div\n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: avatarSize,\n height: avatarSize,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={avatarUrl} />\n <AvatarFallback>{avatarFallback}</AvatarFallback>\n </Avatar>\n <div className=\"flex-1 relative\">\n <Input\n value={value}\n onChange={(e) => setValue(e.target.value)}\n placeholder={placeholder}\n className=\"pr-10\"\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey) {\n e.preventDefault();\n handleSubmit();\n }\n }}\n />\n {showAttachment && (\n <button\n type=\"button\"\n className=\"cursor-pointer absolute right-3 top-1/2 -translate-y-1/2\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Paperclip className=\"w-5 h-5\" />\n </button>\n )}\n </div>\n <Button variant=\"primary\" onClick={handleSubmit}>\n {buttonText}\n </Button>\n </div>\n );\n}\n\ninterface CommentActionsProps {\n likes: number;\n isLiked?: boolean;\n timestamp: string;\n onReply?: () => void;\n onLike?: () => void;\n}\n\nfunction CommentActions({\n likes,\n isLiked,\n timestamp,\n onReply,\n onLike,\n}: CommentActionsProps) {\n return (\n <div\n className=\"flex items-center\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingTop: \"var(--spacing-xs)\",\n }}\n >\n <button\n type=\"button\"\n onClick={onReply}\n className=\"cursor-pointer flex items-center\"\n style={{\n gap: \"var(--spacing-sm)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n Reply\n </button>\n <button\n type=\"button\"\n onClick={onLike}\n className=\"cursor-pointer flex items-center\"\n style={{\n gap: \"var(--spacing-sm)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <Heart\n className=\"w-5 h-5\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n stroke: isLiked ? \"var(--canvas-destructive)\" : \"currentColor\",\n }}\n />\n {likes > 0 ? `${likes} likes` : \"Like\"}\n </button>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {timestamp}\n </span>\n </div>\n );\n}\n\ninterface PostCommentItemProps {\n comment: PostComment;\n currentUser?: PostAuthor;\n depth?: number;\n onReply?: (content: string) => void;\n onLike?: () => void;\n}\n\nfunction PostCommentItem({\n comment,\n currentUser,\n depth = 0,\n onReply,\n onLike,\n}: PostCommentItemProps) {\n const [showReplyInput, setShowReplyInput] = useState(false);\n const [showReplies, setShowReplies] = useState(true);\n const hasReplies = comment.replies && comment.replies.length > 0;\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 {/* Nested replies */}\n {hasReplies && showReplies && (\n <div\n className=\"flex flex-col\"\n style={{\n paddingLeft: \"var(--spacing-7xl)\",\n gap: \"var(--spacing-xl)\",\n }}\n >\n {comment.replies!.map((reply) => (\n <PostCommentItem\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\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 }}\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 className=\"cursor-pointer\"\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 }}\n >\n {showReplies ? \"Hide replies\" : \"Show replies\"}\n </button>\n </div>\n )}\n </div>\n );\n}\n\ninterface PostCardProps {\n post: Post;\n currentUser?: PostAuthor;\n onUpvote?: () => void;\n onComment?: (content: string) => void;\n onReply?: (commentId: string, content: string) => void;\n onLike?: (commentId: string) => void;\n}\n\nfunction PostCard({\n post,\n currentUser,\n onUpvote,\n onComment,\n onReply,\n onLike,\n}: PostCardProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n gap: \"var(--spacing-xl)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n paddingBottom: \"var(--spacing-5xl)\",\n }}\n >\n {/* Post content */}\n <div className=\"flex w-full\" style={{ gap: \"var(--spacing-3xl)\" }}>\n {/* Upvote button */}\n <div\n className=\"flex flex-col items-center shrink-0\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <button\n type=\"button\"\n onClick={onUpvote}\n className=\"cursor-pointer flex items-center justify-center\"\n style={{\n width: 40,\n height: 40,\n borderRadius: \"var(--radius-xs)\",\n border: \"1px solid var(--canvas-border)\",\n backgroundColor: \"var(--canvas-background)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\n color: post.isUpvoted ? \"var(--canvas-primary)\" : \"var(--canvas-text-muted)\",\n }}\n >\n <ArrowUp className=\"w-6 h-6\" />\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: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {post.upvotes}\n </span>\n </div>\n\n {/* Post main content */}\n <div\n className=\"flex-1 flex flex-col\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Author row */}\n <div className=\"flex items-start\" style={{ gap: \"var(--spacing-xl)\" }}>\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={post.author.avatarUrl} />\n <AvatarFallback>\n {post.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {post.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {post.date}\n </span>\n </div>\n </div>\n\n {/* Title and content */}\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xs)\" }}>\n <h3\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {post.title}\n </h3>\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 {post.content}\n </p>\n </div>\n\n {/* Post image */}\n {post.imageUrl && (\n <div\n className=\"w-full overflow-hidden\"\n style={{\n maxWidth: 576,\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <img\n src={post.imageUrl}\n alt={post.title}\n className=\"w-full h-auto object-cover\"\n style={{ maxHeight: 375 }}\n />\n </div>\n )}\n\n {/* Comment button */}\n <div\n className=\"flex items-center justify-center w-full\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n paddingTop: \"var(--spacing-lg)\",\n paddingBottom: \"var(--spacing-lg)\",\n }}\n >\n <button\n type=\"button\"\n className=\"cursor-pointer flex items-center\"\n style={{\n gap: \"var(--spacing-sm)\",\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n <MessageCircle className=\"w-5 h-5\" />\n Comment\n </button>\n </div>\n </div>\n </div>\n\n {/* 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 section */}\n {post.comments && post.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 {post.comments.map((comment) => (\n <PostCommentItem\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 );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Upvoting Posts Table Block\n *\n * A social feed component with upvoting, comments, and nested replies.\n * Features header with sort/filter controls and action button.\n *\n * @example\n * ```tsx\n * <UpvotingPostsTable\n * title=\"My posts\"\n * subtitle=\"In the past year\"\n * posts={[...]}\n * onUpvote={(postId) => console.log(\"Upvoted\", postId)}\n * />\n * ```\n */\nexport function UpvotingPostsTable({\n title = \"My posts\",\n subtitle = \"In the past year\",\n posts = defaultPosts,\n currentUser = defaultCurrentUser,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onUpvote,\n onComment,\n onReply,\n onLike,\n className,\n}: UpvotingPostsTableProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\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 {subtitle}\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 {/* Posts List */}\n <div className=\"flex flex-col w-full\">\n {posts.map((post) => (\n <PostCard\n key={post.id}\n post={post}\n currentUser={currentUser}\n onUpvote={() => onUpvote?.(post.id)}\n onComment={(content) => onComment?.(post.id, content)}\n onReply={(commentId, content) => onReply?.(post.id, commentId, content)}\n onLike={(commentId) => onLike?.(post.id, commentId)}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|