canvas-ui-sdk 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +516 -0
- package/dist/index.d.ts +67 -3
- package/dist/index.js +2588 -301
- package/dist/index.js.map +1 -1
- package/mcp/dist/index.js +5 -1
- package/package.json +18 -2
- package/registry/blocks/activity-feed.json +19 -0
- package/registry/blocks/blog-cards.json +16 -0
- package/registry/blocks/bottom-input-chat-widget.json +19 -0
- package/registry/blocks/canvas-item.json +18 -0
- package/registry/blocks/category-grid.json +16 -0
- package/registry/blocks/centered-hero.json +14 -0
- package/registry/blocks/chat-message.json +18 -0
- package/registry/blocks/circular-progress-bar-list.json +18 -0
- package/registry/blocks/component-palette.json +21 -0
- package/registry/blocks/component-search.json +19 -0
- package/registry/blocks/content-dropzone.json +16 -0
- package/registry/blocks/content-with-image.json +14 -0
- package/registry/blocks/core-values-grid.json +16 -0
- package/registry/blocks/credit-card-display.json +16 -0
- package/registry/blocks/cta-banner.json +14 -0
- package/registry/blocks/custom-component-helper.json +19 -0
- package/registry/blocks/destination-cards.json +16 -0
- package/registry/blocks/empty-state.json +16 -0
- package/registry/blocks/faq-accordion.json +16 -0
- package/registry/blocks/faqs-table.json +18 -0
- package/registry/blocks/feature-with-image.json +16 -0
- package/registry/blocks/featured-news-cards.json +16 -0
- package/registry/blocks/featured-places.json +16 -0
- package/registry/blocks/features-comparison.json +16 -0
- package/registry/blocks/filter-popover.json +28 -0
- package/registry/blocks/fixed-column-data-table.json +20 -0
- package/registry/blocks/flair-banner.json +16 -0
- package/registry/blocks/footer-navbar.json +17 -0
- package/registry/blocks/form-group.json +29 -0
- package/registry/blocks/gallery-section.json +14 -0
- package/registry/blocks/gradient-banner.json +16 -0
- package/registry/blocks/graph-metric-tiles.json +20 -0
- package/registry/blocks/grid-tiles-list.json +20 -0
- package/registry/blocks/hero-dark-centered.json +16 -0
- package/registry/blocks/hero-dark-with-image.json +16 -0
- package/registry/blocks/hero-fullwidth-image.json +16 -0
- package/registry/blocks/hero-section.json +16 -0
- package/registry/blocks/how-it-works.json +16 -0
- package/registry/blocks/image-feed-with-nested-comments.json +20 -0
- package/registry/blocks/infinity-canvas.json +58 -0
- package/registry/blocks/large-image-labels-list.json +19 -0
- package/registry/blocks/loader.json +19 -0
- package/registry/blocks/login-branding-panel.json +16 -0
- package/registry/blocks/menu-section.json +18 -0
- package/registry/blocks/menufocus-template.json +19 -0
- package/registry/blocks/messenger-sidebar.json +19 -0
- package/registry/blocks/metrics-section.json +14 -0
- package/registry/blocks/mobile-bottom-nav.json +18 -0
- package/registry/blocks/monthly-calendar-widget.json +20 -0
- package/registry/blocks/nested-comments-table.json +21 -0
- package/registry/blocks/nested-data-table.json +22 -0
- package/registry/blocks/office-locations.json +14 -0
- package/registry/blocks/page-header-section.json +17 -0
- package/registry/blocks/page-previews.json +29 -0
- package/registry/blocks/pagination.json +20 -0
- package/registry/blocks/participant-list.json +17 -0
- package/registry/blocks/persona-card.json +18 -0
- package/registry/blocks/pill-tabs.json +19 -0
- package/registry/blocks/pricing-cards.json +16 -0
- package/registry/blocks/pricing-cta.json +14 -0
- package/registry/blocks/profile-card.json +20 -0
- package/registry/blocks/profile-grid-tiles-list.json +21 -0
- package/registry/blocks/profile-image-uploader.json +19 -0
- package/registry/blocks/profile-info-cards.json +19 -0
- package/registry/blocks/progress-bar.json +16 -0
- package/registry/blocks/prompt-template.json +18 -0
- package/registry/blocks/reviews-grid.json +14 -0
- package/registry/blocks/reviews-table.json +19 -0
- package/registry/blocks/screen-flowchart.json +19 -0
- package/registry/blocks/screen-prompt-builder.json +19 -0
- package/registry/blocks/screen-prompt-template.json +18 -0
- package/registry/blocks/search-bar.json +19 -0
- package/registry/blocks/search-sidebar.json +25 -0
- package/registry/blocks/settings-list-row.json +20 -0
- package/registry/blocks/sidebar-cards.json +18 -0
- package/registry/blocks/sidebar-profile-card.json +21 -0
- package/registry/blocks/slideshow-grid-tiles.json +21 -0
- package/registry/blocks/social-feed.json +20 -0
- package/registry/blocks/social-proof.json +14 -0
- package/registry/blocks/standard-data-table.json +20 -0
- package/registry/blocks/standard-list-with-image.json +17 -0
- package/registry/blocks/step-tracker.json +16 -0
- package/registry/blocks/team-cards-grid.json +16 -0
- package/registry/blocks/team-circular-grid.json +16 -0
- package/registry/blocks/testimonial-carousel.json +16 -0
- package/registry/blocks/upvoting-posts-table.json +22 -0
- package/registry/blocks/vertical-how-it-works.json +16 -0
- package/registry/blocks/vertical-step-tracker.json +17 -0
- package/registry/blocks/video-chat-controls.json +18 -0
- package/registry/blocks/video-content-section.json +16 -0
- package/registry/blocks/video-playlist.json +18 -0
- package/registry/blocks/webcam-preview.json +18 -0
- package/registry/blocks/youtube-player.json +16 -0
- package/registry/hooks/use-css-variable-sync.json +14 -0
- package/registry/hooks/use-mobile.json +14 -0
- package/registry/index.json +730 -0
- package/registry/layout/account-settings-shell.json +20 -0
- package/registry/layout/dashboard-shell.json +23 -0
- package/registry/layout/double-sidebar-shell.json +23 -0
- package/registry/layout/double-sidebar.json +20 -0
- package/registry/layout/header.json +22 -0
- package/registry/layout/icon-sidebar-shell.json +23 -0
- package/registry/layout/icon-sidebar.json +19 -0
- package/registry/layout/mobile-menu-shell.json +19 -0
- package/registry/layout/multistep-progressbar-shell.json +23 -0
- package/registry/layout/multistep-shell.json +21 -0
- package/registry/layout/multistep-sidebar-shell.json +22 -0
- package/registry/layout/project-context-shell.json +20 -0
- package/registry/layout/search-bar-shell.json +22 -0
- package/registry/layout/sidebar-nav.json +18 -0
- package/registry/layout/sidebar.json +20 -0
- package/registry/layout/standard-page-shell.json +21 -0
- package/registry/layout/vertical-multistep-shell.json +23 -0
- package/registry/lib/utils.json +17 -0
- package/registry/ui/avatar.json +18 -0
- package/registry/ui/button.json +19 -0
- package/registry/ui/calendar.json +20 -0
- package/registry/ui/checkbox.json +19 -0
- package/registry/ui/date-input.json +18 -0
- package/registry/ui/dialog.json +19 -0
- package/registry/ui/dropdown-menu.json +19 -0
- package/registry/ui/file-uploader.json +18 -0
- package/registry/ui/image-uploader.json +18 -0
- package/registry/ui/input.json +16 -0
- package/registry/ui/label.json +18 -0
- package/registry/ui/line-tabs.json +16 -0
- package/registry/ui/multiselect-checkbox-field.json +18 -0
- package/registry/ui/multiselect-tags.json +18 -0
- package/registry/ui/popover.json +18 -0
- package/registry/ui/radio-group.json +19 -0
- package/registry/ui/range-input.json +17 -0
- package/registry/ui/scroll-area.json +18 -0
- package/registry/ui/searchbox.json +18 -0
- package/registry/ui/select.json +20 -0
- package/registry/ui/selectable-pills.json +16 -0
- package/registry/ui/separator.json +18 -0
- package/registry/ui/sheet.json +19 -0
- package/registry/ui/sidebar.json +27 -0
- package/registry/ui/skeleton.json +16 -0
- package/registry/ui/slider.json +18 -0
- package/registry/ui/switch.json +18 -0
- package/registry/ui/tabs.json +18 -0
- package/registry/ui/text-input.json +16 -0
- package/registry/ui/textarea.json +18 -0
- package/registry/ui/tooltip.json +18 -0
- package/registry/ui/typography.json +16 -0
- package/styles/tokens.reference.css +35 -3
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "form-group",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Comprehensive form layout with header, responsive 2-column rows, and footer. Supports text inputs, selects, date pickers, checkboxes, radio buttons, multiselect tags, image/file uploaders, and sliders.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/form-group.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { Label } from \"../ui/label\";\nimport { TextInput } from \"../ui/text-input\";\nimport { Textarea } from \"../ui/textarea\";\nimport { DateInput } from \"../ui/date-input\";\nimport { Slider } from \"../ui/slider\";\nimport { RadioGroup, RadioGroupItem } from \"../ui/radio-group\";\nimport { CheckboxWithLabel } from \"../ui/checkbox\";\nimport { MultiselectTags } from \"../ui/multiselect-tags\";\nimport { MultiselectCheckboxField, type CheckboxOption } from \"../ui/multiselect-checkbox-field\";\nimport { ImageUploader, type UploadedImage } from \"../ui/image-uploader\";\nimport { FileUploader, type UploadedFile } from \"../ui/file-uploader\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface FormFieldConfig {\n id: string;\n label: string;\n type:\n | \"text\"\n | \"textarea\"\n | \"select\"\n | \"date\"\n | \"multiselect-checkbox\"\n | \"checkbox-group\"\n | \"radio-group\"\n | \"multiselect-tags\"\n | \"image-uploader\"\n | \"file-uploader\"\n | \"slider\";\n placeholder?: string;\n options?: { id: string; label: string }[];\n value?: string | string[] | number[] | UploadedImage[] | UploadedFile[];\n min?: number;\n max?: number;\n step?: number;\n fullWidth?: boolean;\n disabled?: boolean;\n}\n\nexport interface FormRowConfig {\n id: string;\n fields: FormFieldConfig[];\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 FormGroupProps {\n /** Form title */\n title?: string;\n /** Form description */\n description?: string;\n /** Row configurations containing field definitions */\n rows?: FormRowConfig[];\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 /** Cancel button text */\n cancelButtonText?: string;\n /** Save button text */\n saveButtonText?: string;\n /** Input size variant */\n inputSize?: \"sm\" | \"default\" | \"lg\";\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 cancel button is clicked */\n onCancel?: () => void;\n /** Callback when save button is clicked */\n onSave?: () => void;\n /** Callback when a field value changes */\n onFieldChange?: (fieldId: string, value: unknown) => void;\n /** Show header section */\n showHeader?: boolean;\n /** Show footer section */\n showFooter?: boolean;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"name-asc\", label: \"Name (A-Z)\" },\n { id: \"name-desc\", label: \"Name (Z-A)\" },\n { id: \"date-newest\", label: \"Newest first\" },\n { id: \"date-oldest\", label: \"Oldest first\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All\" },\n { id: \"active\", label: \"Active\" },\n { id: \"inactive\", label: \"Inactive\" },\n];\n\nconst defaultCheckboxOptions: CheckboxOption[] = [\n { id: \"option-1\", label: \"Option 1\" },\n { id: \"option-2\", label: \"Option 1\" },\n { id: \"option-3\", label: \"Option 1\" },\n { id: \"option-4\", label: \"Option 1\" },\n { id: \"option-5\", label: \"Option 1\" },\n];\n\nconst defaultRadioOptions = [\n { id: \"option-a\", label: \"Option\" },\n { id: \"option-b\", label: \"Option\" },\n { id: \"option-c\", label: \"Option\" },\n { id: \"option-d\", label: \"Option\" },\n];\n\nconst defaultSelectOptions = [\n { id: \"placeholder\", label: \"Placeholder\" },\n { id: \"option-1\", label: \"Option 1\" },\n { id: \"option-2\", label: \"Option 2\" },\n { id: \"option-3\", label: \"Option 3\" },\n];\n\nconst defaultRows: FormRowConfig[] = [\n {\n id: \"row-1\",\n fields: [\n { id: \"field-1\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\" },\n { id: \"field-2\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\" },\n ],\n },\n {\n id: \"row-2\",\n fields: [\n { id: \"field-3\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\" },\n { id: \"field-4\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\" },\n ],\n },\n {\n id: \"row-3\",\n fields: [\n { id: \"field-5\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\" },\n { id: \"field-6\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\" },\n ],\n },\n {\n id: \"row-4\",\n fields: [\n { id: \"field-7\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\", fullWidth: true },\n ],\n },\n {\n id: \"row-5\",\n fields: [\n { id: \"field-8\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\", fullWidth: true },\n ],\n },\n {\n id: \"row-6\",\n fields: [\n {\n id: \"field-9\",\n label: \"Label\",\n type: \"select\",\n placeholder: \"Placeholder\",\n options: defaultSelectOptions,\n fullWidth: true,\n },\n ],\n },\n {\n id: \"row-7\",\n fields: [\n {\n id: \"field-10\",\n label: \"Label\",\n type: \"multiselect-checkbox\",\n options: defaultCheckboxOptions,\n value: [\"option-1\"],\n fullWidth: true,\n },\n ],\n },\n {\n id: \"row-8\",\n fields: [\n { id: \"field-11\", label: \"Label\", type: \"date\", placeholder: \"2/21/2024\", fullWidth: true },\n ],\n },\n {\n id: \"row-9\",\n fields: [\n {\n id: \"field-12\",\n label: \"Label\",\n type: \"checkbox-group\",\n options: [\n { id: \"cb-1\", label: \"Label\" },\n { id: \"cb-2\", label: \"Label\" },\n { id: \"cb-3\", label: \"Label\" },\n { id: \"cb-4\", label: \"Label\" },\n { id: \"cb-5\", label: \"Label\" },\n { id: \"cb-6\", label: \"Label\" },\n ],\n fullWidth: true,\n },\n ],\n },\n {\n id: \"row-10\",\n fields: [\n {\n id: \"field-13\",\n label: \"Label\",\n type: \"radio-group\",\n options: defaultRadioOptions,\n fullWidth: true,\n },\n ],\n },\n {\n id: \"row-11\",\n fields: [\n { id: \"field-14\", label: \"Label\", type: \"text\", placeholder: \"Placeholder\", fullWidth: true },\n ],\n },\n {\n id: \"row-12\",\n fields: [\n {\n id: \"field-15\",\n label: \"Label\",\n type: \"multiselect-tags\",\n value: [\"Choice A\", \"Choice B\"],\n fullWidth: true,\n },\n ],\n },\n {\n id: \"row-13\",\n fields: [\n { id: \"field-16\", label: \"Label\", type: \"image-uploader\", fullWidth: true },\n ],\n },\n {\n id: \"row-14\",\n fields: [\n { id: \"field-17\", label: \"Label\", type: \"file-uploader\", fullWidth: true },\n ],\n },\n {\n id: \"row-15\",\n fields: [\n {\n id: \"field-18\",\n label: \"Label\",\n type: \"slider\",\n value: [0],\n min: 0,\n max: 1000,\n fullWidth: true,\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface FormFieldProps {\n field: FormFieldConfig;\n inputSize?: \"sm\" | \"default\" | \"lg\";\n onChange?: (fieldId: string, value: unknown) => void;\n}\n\nfunction FormField({ field, inputSize = \"default\", onChange }: FormFieldProps) {\n const [localValue, setLocalValue] = React.useState<unknown>(field.value);\n \n // State for multiselect tags\n const [tags, setTags] = React.useState<string[]>(\n Array.isArray(field.value) && field.type === \"multiselect-tags\"\n ? (field.value as string[])\n : []\n );\n\n // State for checkbox group\n const [checkedItems, setCheckedItems] = React.useState<string[]>(\n Array.isArray(field.value) && field.type === \"checkbox-group\"\n ? (field.value as string[])\n : []\n );\n\n // State for images and files\n const [images, setImages] = React.useState<UploadedImage[]>(\n Array.isArray(field.value) && field.type === \"image-uploader\"\n ? (field.value as UploadedImage[])\n : []\n );\n const [files, setFiles] = React.useState<UploadedFile[]>(\n Array.isArray(field.value) && field.type === \"file-uploader\"\n ? (field.value as UploadedFile[])\n : []\n );\n\n // State for slider\n const [sliderValue, setSliderValue] = React.useState<number[]>(\n Array.isArray(field.value) && field.type === \"slider\"\n ? (field.value as number[])\n : [0]\n );\n\n const handleChange = (value: unknown) => {\n setLocalValue(value);\n onChange?.(field.id, value);\n };\n\n const renderField = () => {\n switch (field.type) {\n case \"text\":\n return (\n <TextInput\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(e) => handleChange(e.target.value)}\n disabled={field.disabled}\n />\n );\n\n case \"textarea\":\n return (\n <Textarea\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(e) => handleChange(e.target.value)}\n disabled={field.disabled}\n />\n );\n\n case \"select\":\n return (\n <Select\n value={typeof localValue === \"string\" ? localValue : undefined}\n onValueChange={(val) => handleChange(val)}\n disabled={field.disabled}\n >\n <SelectTrigger inputSize={inputSize}>\n <SelectValue placeholder={field.placeholder || \"Select...\"} />\n </SelectTrigger>\n <SelectContent>\n {field.options?.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n );\n\n case \"date\":\n return (\n <DateInput\n inputSize={inputSize}\n placeholder={field.placeholder}\n value={typeof localValue === \"string\" ? localValue : \"\"}\n onChange={(val) => handleChange(val)}\n disabled={field.disabled}\n />\n );\n\n case \"multiselect-checkbox\":\n return (\n <MultiselectCheckboxField\n options={field.options}\n selectedValues={\n Array.isArray(localValue) ? (localValue as string[]) : []\n }\n onChange={(vals) => handleChange(vals)}\n inputSize={inputSize}\n disabled={field.disabled}\n />\n );\n\n case \"checkbox-group\":\n return (\n <div\n className=\"flex flex-wrap items-center\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {field.options?.map((option) => (\n <CheckboxWithLabel\n key={option.id}\n checked={checkedItems.includes(option.id)}\n onCheckedChange={(checked) => {\n const newChecked = checked\n ? [...checkedItems, option.id]\n : checkedItems.filter((id) => id !== option.id);\n setCheckedItems(newChecked);\n onChange?.(field.id, newChecked);\n }}\n disabled={field.disabled}\n >\n {option.label}\n </CheckboxWithLabel>\n ))}\n </div>\n );\n\n case \"radio-group\":\n return (\n <RadioGroup\n value={typeof localValue === \"string\" ? localValue : undefined}\n onValueChange={(val) => handleChange(val)}\n disabled={field.disabled}\n className=\"flex flex-wrap items-center\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {field.options?.map((option) => (\n <div\n key={option.id}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <RadioGroupItem value={option.id} id={`${field.id}-${option.id}`} />\n <label\n htmlFor={`${field.id}-${option.id}`}\n className=\"text-[var(--canvas-text-muted)] cursor-pointer\"\n style={{\n fontFamily: \"var(--typo-body-s-font)\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n }}\n >\n {option.label}\n </label>\n </div>\n ))}\n </RadioGroup>\n );\n\n case \"multiselect-tags\":\n return (\n <MultiselectTags\n tags={tags}\n inputSize={inputSize}\n onAdd={(tag) => {\n const newTags = [...tags, tag];\n setTags(newTags);\n onChange?.(field.id, newTags);\n }}\n onRemove={(tag) => {\n const newTags = tags.filter((t) => t !== tag);\n setTags(newTags);\n onChange?.(field.id, newTags);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"image-uploader\":\n return (\n <ImageUploader\n images={images}\n onImagesChange={(newImages) => {\n setImages(newImages);\n onChange?.(field.id, newImages);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"file-uploader\":\n return (\n <FileUploader\n files={files}\n onFilesChange={(newFiles) => {\n setFiles(newFiles);\n onChange?.(field.id, newFiles);\n }}\n disabled={field.disabled}\n />\n );\n\n case \"slider\":\n return (\n <Slider\n inputSize={inputSize}\n value={sliderValue}\n onValueChange={(vals) => {\n setSliderValue(vals);\n onChange?.(field.id, vals);\n }}\n min={field.min ?? 0}\n max={field.max ?? 100}\n step={field.step ?? 1}\n showLabel\n labelFormatter={(vals) => `$${vals[0]}`}\n disabled={field.disabled}\n />\n );\n\n default:\n return null;\n }\n };\n\n return (\n <div\n className={cn(\n \"flex flex-col w-full\",\n field.fullWidth ? \"col-span-full\" : \"\"\n )}\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <Label>{field.label}</Label>\n {renderField()}\n </div>\n );\n}\n\ninterface FormRowProps {\n row: FormRowConfig;\n inputSize?: \"sm\" | \"default\" | \"lg\";\n onChange?: (fieldId: string, value: unknown) => void;\n}\n\nfunction FormRow({ row, inputSize, onChange }: FormRowProps) {\n // Check if all fields in the row are full width\n const allFullWidth = row.fields.every((f) => f.fullWidth);\n \n if (allFullWidth || row.fields.length === 1) {\n // Render as single column\n return (\n <div className=\"flex flex-col w-full\" style={{ gap: \"var(--spacing-3xl)\" }}>\n {row.fields.map((field) => (\n <FormField\n key={field.id}\n field={field}\n inputSize={inputSize}\n onChange={onChange}\n />\n ))}\n </div>\n );\n }\n\n // Render as responsive 2-column grid\n return (\n <div\n className=\"grid grid-cols-1 sm:grid-cols-2 w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {row.fields.map((field) => (\n <FormField\n key={field.id}\n field={field}\n inputSize={inputSize}\n onChange={onChange}\n />\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Form Group Block\n *\n * A comprehensive form layout block with various input types arranged\n * in responsive rows. Includes header section with title, description,\n * sort/filter controls, and footer with action buttons.\n *\n * @example\n * ```tsx\n * <FormGroup\n * title=\"Create Entry\"\n * description=\"Fill in the details below\"\n * onSave={() => console.log(\"Save\")}\n * onCancel={() => console.log(\"Cancel\")}\n * />\n * ```\n */\nexport function FormGroup({\n title = \"Title\",\n description = \"Description\",\n rows = defaultRows,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n cancelButtonText = \"Cancel\",\n saveButtonText = \"Save changes\",\n inputSize = \"default\",\n onAddNew,\n onSort,\n onFilter,\n onCancel,\n onSave,\n onFieldChange,\n showHeader = true,\n showFooter = true,\n className,\n}: FormGroupProps) {\n const [sortValue, setSortValue] = React.useState<string>(\"\");\n const [filterValue, setFilterValue] = React.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 bg-[var(--canvas-background)]\", className)}\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header Section */}\n {showHeader && (\n <div\n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Description */}\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 {description}\n </p>\n </div>\n\n {/* Controls */}\n <div className=\"flex items-start justify-end shrink-0 gap-3\">\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\n value={filterValue || undefined}\n onValueChange={handleFilterChange}\n >\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 variant=\"primary\" size=\"sm\" onClick={onAddNew}>\n {actionButtonText}\n </Button>\n </div>\n </div>\n )}\n\n {/* Form Rows */}\n {rows.map((row) => (\n <FormRow\n key={row.id}\n row={row}\n inputSize={inputSize}\n onChange={onFieldChange}\n />\n ))}\n\n {/* Footer Section */}\n {showFooter && (\n <div\n className=\"flex items-center justify-end w-full overflow-hidden\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"outline\" size=\"default\" onClick={onCancel}>\n {cancelButtonText}\n </Button>\n <Button variant=\"primary\" size=\"default\" onClick={onSave}>\n {saveButtonText}\n </Button>\n </div>\n )}\n </div>\n );\n}\n\n// Export sub-components for advanced usage\nexport { FormField, FormRow };\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"utils",
|
|
15
|
+
"button",
|
|
16
|
+
"label",
|
|
17
|
+
"text-input",
|
|
18
|
+
"textarea",
|
|
19
|
+
"date-input",
|
|
20
|
+
"slider",
|
|
21
|
+
"radio-group",
|
|
22
|
+
"checkbox",
|
|
23
|
+
"multiselect-tags",
|
|
24
|
+
"multiselect-checkbox-field",
|
|
25
|
+
"image-uploader",
|
|
26
|
+
"file-uploader",
|
|
27
|
+
"select"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gallery-section",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Image gallery with title and grid layout.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/marketing/gallery-section.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { Typography } from \"../../ui/typography\";\n\ninterface GallerySectionProps {\n variant?: \"light\" | \"dark\";\n subtitle?: string;\n title?: string;\n description1?: string;\n description2?: string;\n images?: string[];\n}\n\nconst defaultImages = [\n \"https://images.unsplash.com/photo-1499856871958-5b9627545d1a?w=400&h=350&fit=crop\",\n \"https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=400&h=405&fit=crop\",\n \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=400&h=453&fit=crop\",\n \"https://images.unsplash.com/photo-1534430480872-3498386e7856?w=400&h=380&fit=crop\",\n \"https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=400&h=300&fit=crop\",\n \"https://images.unsplash.com/photo-1530841377377-3ff06c0ca713?w=400&h=489&fit=crop\",\n];\n\nexport function GallerySection({ \n variant = \"light\",\n subtitle = \"GALLERY\",\n title = \"Discover the best places\",\n description1 = \"Traveling is an exciting way to discover new places, cultures, and experiences. Whether you're a seasoned traveler or new to exploring, there are countless destinations waiting to be discovered.\",\n description2 = \"From bustling cities to secluded beaches, from ancient ruins to modern landmarks, there's something for everyone. Get inspired by pictures from our trusted community below.\",\n images = defaultImages,\n}: GallerySectionProps) {\n const isDark = variant === \"dark\";\n \n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-20 py-16 md:py-24\"\n style={{\n backgroundColor: isDark ? \"var(--canvas-dark-section-bg)\" : \"var(--canvas-background)\",\n }}\n >\n <div className=\"w-full max-w-[1240px] mx-auto flex flex-col gap-12\">\n {/* Header */}\n <div className=\"flex flex-col gap-6\">\n <div className=\"flex flex-col gap-3\">\n <Typography variant=\"body-s\" as=\"p\" color=\"muted\">\n {subtitle}\n </Typography>\n <Typography \n variant=\"h3\" \n as=\"h2\"\n {...(isDark && { style: { color: \"white\" } })}\n >\n {title}\n </Typography>\n </div>\n <div className=\"flex flex-col md:flex-row gap-8 md:gap-16\">\n <Typography \n variant=\"body-l\" \n color=\"muted\"\n className=\"flex-1\"\n {...(isDark && { style: { color: \"var(--canvas-text-placeholder)\" } })}\n >\n {description1}\n </Typography>\n <Typography \n variant=\"body-l\" \n color=\"muted\"\n className=\"flex-1\"\n {...(isDark && { style: { color: \"var(--canvas-text-placeholder)\" } })}\n >\n {description2}\n </Typography>\n </div>\n </div>\n\n {/* Images Grid - 3 columns with varying heights */}\n <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8\">\n {/* Column 1 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[350px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[0]} \n alt=\"Gallery image 1\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[405px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[1]} \n alt=\"Gallery image 2\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n \n {/* Column 2 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[453px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[2]} \n alt=\"Gallery image 3\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[380px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[3]} \n alt=\"Gallery image 4\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n \n {/* Column 3 */}\n <div className=\"flex flex-col gap-8\">\n <div \n className=\"w-full h-[300px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[4]} \n alt=\"Gallery image 5\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n <div \n className=\"w-full h-[489px] overflow-hidden\"\n style={{ borderRadius: \"var(--radius-md)\" }}\n >\n <img \n src={images[5]} \n alt=\"Gallery image 6\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n </div>\n </div>\n </section>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"registryDependencies": []
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gradient-banner",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Banner with gradient background. Alternative to FlairBanner.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/gradient-banner.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\n\ninterface GradientBannerProps {\n /** Height of the banner */\n height?: string;\n /** Additional class names */\n className?: string;\n /** Children to render inside the banner */\n children?: React.ReactNode;\n}\n\n/**\n * Convert hex color to HSL\n */\nfunction hexToHsl(hex: string): { h: number; s: number; l: number } {\n // Remove # if present\n hex = hex.replace(/^#/, \"\");\n\n // Parse hex values\n const r = parseInt(hex.substring(0, 2), 16) / 255;\n const g = parseInt(hex.substring(2, 4), 16) / 255;\n const b = parseInt(hex.substring(4, 6), 16) / 255;\n\n const max = Math.max(r, g, b);\n const min = Math.min(r, g, b);\n let h = 0;\n let s = 0;\n const l = (max + min) / 2;\n\n if (max !== min) {\n const d = max - min;\n s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n\n switch (max) {\n case r:\n h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n break;\n case g:\n h = ((b - r) / d + 2) / 6;\n break;\n case b:\n h = ((r - g) / d + 4) / 6;\n break;\n }\n }\n\n return { h: h * 360, s: s * 100, l: l * 100 };\n}\n\n/**\n * Convert HSL to hex color\n */\nfunction hslToHex(h: number, s: number, l: number): string {\n s /= 100;\n l /= 100;\n\n const c = (1 - Math.abs(2 * l - 1)) * s;\n const x = c * (1 - Math.abs(((h / 60) % 2) - 1));\n const m = l - c / 2;\n\n let r = 0, g = 0, b = 0;\n\n if (h >= 0 && h < 60) {\n r = c; g = x; b = 0;\n } else if (h >= 60 && h < 120) {\n r = x; g = c; b = 0;\n } else if (h >= 120 && h < 180) {\n r = 0; g = c; b = x;\n } else if (h >= 180 && h < 240) {\n r = 0; g = x; b = c;\n } else if (h >= 240 && h < 300) {\n r = x; g = 0; b = c;\n } else if (h >= 300 && h < 360) {\n r = c; g = 0; b = x;\n }\n\n const toHex = (n: number) => {\n const hex = Math.round((n + m) * 255).toString(16);\n return hex.length === 1 ? \"0\" + hex : hex;\n };\n\n return `#${toHex(r)}${toHex(g)}${toHex(b)}`;\n}\n\n/**\n * Generate analogous colors based on a primary color\n * Creates a gradient with colors adjacent on the color wheel\n * Uses darker lightness values similar to the flair background (~25-40%)\n */\nfunction generateAnalogousGradient(primaryColor: string): string {\n const hsl = hexToHsl(primaryColor);\n \n // Target lightness around 25-40% to match flair background darkness\n const baseLightness = 30;\n \n // Create analogous colors by shifting hue\n // Spread across the color wheel for a smooth gradient with rich, dark colors\n const colors = [\n hslToHex((hsl.h - 30 + 360) % 360, Math.min(hsl.s + 15, 100), baseLightness + 10), // Shifted left\n hslToHex((hsl.h - 10 + 360) % 360, Math.min(hsl.s + 10, 100), baseLightness + 5), // Slightly shifted\n hslToHex(hsl.h, Math.min(hsl.s + 5, 100), baseLightness), // Primary base\n hslToHex((hsl.h + 20) % 360, Math.min(hsl.s + 10, 100), baseLightness + 5), // Shifted right\n hslToHex((hsl.h + 45) % 360, Math.min(hsl.s + 15, 100), baseLightness + 10), // Further right\n ];\n\n return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 25%, ${colors[2]} 50%, ${colors[3]} 75%, ${colors[4]} 100%)`;\n}\n\n/**\n * Canvas Design System - Gradient Banner Component\n *\n * A dynamic gradient banner that generates analogous colors\n * based on the primary theme color. Updates live when the\n * primary color changes.\n */\nexport function GradientBanner({\n height = \"200px\",\n className,\n children,\n}: GradientBannerProps) {\n const [gradient, setGradient] = useState<string>(\n \"linear-gradient(135deg, #1a4a7a 0%, #093378 50%, #3d1a78 100%)\"\n );\n\n useEffect(() => {\n // Function to update gradient based on primary color\n const updateGradient = () => {\n const primaryColor = getComputedStyle(document.documentElement)\n .getPropertyValue(\"--canvas-primary\")\n .trim();\n \n if (primaryColor) {\n const newGradient = generateAnalogousGradient(primaryColor);\n setGradient(newGradient);\n }\n };\n\n // Initial update\n updateGradient();\n\n // Create a MutationObserver to watch for style changes on :root\n const observer = new MutationObserver((mutations) => {\n mutations.forEach((mutation) => {\n if (mutation.type === \"attributes\" && mutation.attributeName === \"style\") {\n updateGradient();\n }\n });\n });\n\n // Observe the document element for style attribute changes\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"style\"],\n });\n\n // Also listen for custom theme change events if they exist\n const handleThemeChange = () => updateGradient();\n window.addEventListener(\"theme-change\", handleThemeChange);\n\n return () => {\n observer.disconnect();\n window.removeEventListener(\"theme-change\", handleThemeChange);\n };\n }, []);\n\n return (\n <div\n className={cn(\"relative w-full overflow-hidden\", className)}\n style={{\n height,\n background: gradient,\n }}\n >\n {children}\n </div>\n );\n}\n\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"utils"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "graph-metric-tiles",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/graph-metric-tiles.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState, useEffect, useCallback } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n ArrowUpRight,\n ArrowDownRight,\n DotsThreeVertical,\n Gear,\n} from \"@phosphor-icons/react\";\nimport {\n Chart as ChartJS,\n CategoryScale,\n LinearScale,\n PointElement,\n LineElement,\n Title,\n Tooltip,\n Legend,\n Filler,\n ArcElement,\n} from \"chart.js\";\nimport { Line, Doughnut } from \"react-chartjs-2\";\n\n// Register Chart.js components\nChartJS.register(\n CategoryScale,\n LinearScale,\n PointElement,\n LineElement,\n Title,\n Tooltip,\n Legend,\n Filler,\n ArcElement\n);\n\n// ============================================\n// Custom Hook: useCSSVariableColors\n// ============================================\n\ninterface CSSVariableColors {\n primary: string;\n background: string;\n text: string;\n textMuted: string;\n textPlaceholder: string;\n border: string;\n success: string;\n chartColor1: string;\n chartColor2: string;\n chartColor3: string;\n chartColor4: string;\n chartColor5: string;\n chartLineColor: string;\n chartAreaColor: string;\n}\n\nfunction useCSSVariableColors(): CSSVariableColors {\n const [colors, setColors] = useState<CSSVariableColors>({\n primary: \"#1165ef\",\n background: \"#ffffff\",\n text: \"#0d121c\",\n textMuted: \"#4b5565\",\n textPlaceholder: \"#6c7684\",\n border: \"#e9eef3\",\n success: \"#08875d\",\n chartColor1: \"#e45451\",\n chartColor2: \"#69bdbc\",\n chartColor3: \"#f8f578\",\n chartColor4: \"#f2b66b\",\n chartColor5: \"#6c7684\",\n chartLineColor: \"#1165ef\",\n chartAreaColor: \"rgba(17, 101, 239, 0.1)\",\n });\n\n const readColors = useCallback(() => {\n if (typeof window === \"undefined\") return;\n const style = getComputedStyle(document.documentElement);\n setColors({\n primary: style.getPropertyValue(\"--canvas-primary\").trim() || \"#1165ef\",\n background: style.getPropertyValue(\"--canvas-background\").trim() || \"#ffffff\",\n text: style.getPropertyValue(\"--canvas-text\").trim() || \"#0d121c\",\n textMuted: style.getPropertyValue(\"--canvas-text-muted\").trim() || \"#4b5565\",\n textPlaceholder: style.getPropertyValue(\"--canvas-text-placeholder\").trim() || \"#6c7684\",\n border: style.getPropertyValue(\"--canvas-border\").trim() || \"#e9eef3\",\n success: style.getPropertyValue(\"--canvas-success\").trim() || \"#08875d\",\n chartColor1: style.getPropertyValue(\"--chart-color-1\").trim() || \"#e45451\",\n chartColor2: style.getPropertyValue(\"--chart-color-2\").trim() || \"#69bdbc\",\n chartColor3: style.getPropertyValue(\"--chart-color-3\").trim() || \"#f8f578\",\n chartColor4: style.getPropertyValue(\"--chart-color-4\").trim() || \"#f2b66b\",\n chartColor5: style.getPropertyValue(\"--chart-color-5\").trim() || \"#6c7684\",\n chartLineColor: style.getPropertyValue(\"--chart-line-color\").trim() || \"#1165ef\",\n chartAreaColor: style.getPropertyValue(\"--chart-area-color\").trim() || \"rgba(17, 101, 239, 0.1)\",\n });\n }, []);\n\n useEffect(() => {\n readColors();\n\n // MutationObserver to watch for style attribute changes on :root\n const observer = new MutationObserver(() => {\n readColors();\n });\n\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"style\"],\n });\n\n // Also listen for custom event that variables drawer might dispatch\n const handleVariableChange = () => readColors();\n window.addEventListener(\"css-variables-changed\", handleVariableChange);\n\n return () => {\n observer.disconnect();\n window.removeEventListener(\"css-variables-changed\", handleVariableChange);\n };\n }, [readColors]);\n\n return colors;\n}\n\n// ============================================\n// Types\n// ============================================\n\nexport interface MetricCardData {\n id: string;\n label: string;\n value: string;\n change?: {\n value: string;\n direction: \"up\" | \"down\";\n };\n}\n\nexport interface MetricListItem {\n id: string;\n date?: string;\n label: string;\n value: string;\n}\n\nexport interface DonutChartLegendItem {\n id: string;\n label: string;\n value: string;\n color: string;\n}\n\nexport interface ProgressBarItem {\n id: string;\n label: string;\n sublabel?: string;\n value?: string;\n progress: number; // 0-100\n imageUrl?: string;\n color?: string;\n}\n\n// ============================================\n// Shared Sub-components\n// ============================================\n\ninterface WidgetCardProps {\n children: React.ReactNode;\n className?: string;\n}\n\nfunction WidgetCard({ children, className }: WidgetCardProps) {\n return (\n <div\n className={cn(\"flex flex-col overflow-hidden\", className)}\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-nav, 8px)\",\n boxShadow: \"0px 1px 2px 0px rgba(0, 0, 0, 0.02)\",\n }}\n >\n {children}\n </div>\n );\n}\n\ninterface WidgetHeaderProps {\n label: string;\n onMenuClick?: () => void;\n}\n\nfunction WidgetHeader({ label, onMenuClick }: WidgetHeaderProps) {\n return (\n <div className=\"flex items-center justify-between w-full\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {label}\n </span>\n <button\n onClick={onMenuClick}\n className=\"flex items-center justify-center rounded-full hover:bg-[var(--canvas-surface)]\"\n style={{ width: 32, height: 32 }}\n >\n <DotsThreeVertical size={20} weight=\"bold\" color=\"var(--canvas-text-placeholder)\" />\n </button>\n </div>\n );\n}\n\ninterface WidgetFooterProps {\n manageLabel?: string;\n viewDetailsLabel?: string;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nfunction WidgetFooter({\n manageLabel = \"Manage\",\n viewDetailsLabel = \"View details >\",\n onManageClick,\n onViewDetailsClick,\n}: WidgetFooterProps) {\n return (\n <div\n className=\"flex items-center justify-between w-full\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-2xl, 20px) var(--spacing-4xl, 32px)\",\n }}\n >\n <button\n onClick={onManageClick}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md, 8px)\" }}\n >\n <Gear size={16} color=\"var(--canvas-text-placeholder)\" />\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {manageLabel}\n </span>\n </button>\n <button\n onClick={onViewDetailsClick}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-primary)\",\n }}\n >\n {viewDetailsLabel}\n </button>\n </div>\n );\n}\n\n// ============================================\n// MetricCard Component\n// ============================================\n\nexport interface MetricCardProps {\n label: string;\n value: string;\n change?: {\n value: string;\n direction: \"up\" | \"down\";\n };\n className?: string;\n}\n\nexport function MetricCard({ label, value, change, className }: MetricCardProps) {\n return (\n <WidgetCard className={cn(\"flex-1 min-w-0\", className)}>\n <div\n className=\"flex flex-col justify-center\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n gap: \"var(--spacing-lg, 12px)\",\n }}\n >\n {/* Label */}\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {label}\n </span>\n\n {/* Value */}\n <span\n style={{\n fontFamily: \"var(--typo-h4-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h4-size, 36px)\",\n fontWeight: 700,\n lineHeight: 1.25,\n letterSpacing: \"-0.01em\",\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n\n {/* Change indicator */}\n {change && (\n <div className=\"flex items-center\" style={{ gap: \"var(--spacing-xs, 4px)\" }}>\n {change.direction === \"up\" ? (\n <ArrowUpRight size={20} weight=\"bold\" color=\"var(--canvas-success)\" />\n ) : (\n <ArrowDownRight size={20} weight=\"bold\" color=\"var(--canvas-destructive)\" />\n )}\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height, 1.4)\",\n color: change.direction === \"up\" ? \"var(--canvas-success)\" : \"var(--canvas-destructive)\",\n }}\n >\n {change.value}\n </span>\n </div>\n )}\n </div>\n </WidgetCard>\n );\n}\n\n// ============================================\n// MetricCardsRow Component\n// ============================================\n\nexport interface MetricCardsRowProps {\n metrics: MetricCardData[];\n columns?: 2 | 3 | 4;\n className?: string;\n}\n\nexport function MetricCardsRow({ metrics, columns = 4, className }: MetricCardsRowProps) {\n return (\n <div\n className={cn(\"flex w-full\", className)}\n style={{ gap: \"var(--spacing-4xl, 32px)\" }}\n >\n {metrics.slice(0, columns).map((metric) => (\n <MetricCard\n key={metric.id}\n label={metric.label}\n value={metric.value}\n change={metric.change}\n />\n ))}\n </div>\n );\n}\n\n// ============================================\n// LineChartWidget Component\n// ============================================\n\nexport interface LineChartWidgetProps {\n label: string;\n value: string;\n description?: string;\n chartData?: number[];\n chartLabels?: string[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nexport function LineChartWidget({\n label,\n value,\n description,\n chartData = [30, 45, 35, 50, 40, 60, 55, 70, 65, 80, 75, 90],\n chartLabels = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"],\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: LineChartWidgetProps) {\n const colors = useCSSVariableColors();\n\n const data = {\n labels: chartLabels,\n datasets: [\n {\n data: chartData,\n fill: true,\n backgroundColor: colors.chartAreaColor,\n borderColor: colors.chartLineColor,\n borderWidth: 2,\n tension: 0.4,\n pointRadius: 0,\n pointHoverRadius: 4,\n pointHoverBackgroundColor: colors.chartLineColor,\n pointHoverBorderColor: colors.background,\n pointHoverBorderWidth: 2,\n },\n ],\n };\n\n const options = {\n responsive: true,\n maintainAspectRatio: false,\n plugins: {\n legend: {\n display: false,\n },\n tooltip: {\n backgroundColor: colors.text,\n titleColor: colors.background,\n bodyColor: colors.background,\n padding: 12,\n cornerRadius: 8,\n displayColors: false,\n },\n },\n scales: {\n x: {\n grid: {\n display: false,\n },\n ticks: {\n color: colors.textPlaceholder,\n font: {\n size: 12,\n },\n },\n border: {\n display: false,\n },\n },\n y: {\n grid: {\n color: colors.border,\n },\n ticks: {\n color: colors.textPlaceholder,\n font: {\n size: 12,\n },\n },\n border: {\n display: false,\n },\n },\n },\n interaction: {\n intersect: false,\n mode: \"index\" as const,\n },\n };\n\n return (\n <WidgetCard className={cn(\"w-full\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={label} onMenuClick={onMenuClick} />\n\n {/* Value & Description */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n {description && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </span>\n )}\n </div>\n\n {/* Chart */}\n <div style={{ height: 300, width: \"100%\" }}>\n <Line data={data} options={options} />\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// DonutChartWidget Component\n// ============================================\n\nexport interface DonutChartWidgetProps {\n label: string;\n value: string;\n description?: string;\n legendItems?: DonutChartLegendItem[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultDonutLegendItems: DonutChartLegendItem[] = [\n { id: \"1\", label: \"Product sales\", value: \"$400,000\", color: \"chartColor1\" },\n { id: \"2\", label: \"Subscription\", value: \"$350,000\", color: \"chartColor2\" },\n { id: \"3\", label: \"Advertising\", value: \"$300,000\", color: \"chartColor3\" },\n { id: \"4\", label: \"Services fees\", value: \"$250,000\", color: \"chartColor4\" },\n { id: \"5\", label: \"Royalties\", value: \"$200,000\", color: \"chartColor5\" },\n { id: \"6\", label: \"Affiliate marketing\", value: \"$197,454\", color: \"chartColor5\" },\n { id: \"7\", label: \"Licensing\", value: \"$180,000\", color: \"chartColor5\" },\n { id: \"8\", label: \"Sponsorship\", value: \"$150,000\", color: \"chartColor5\" },\n];\n\nexport function DonutChartWidget({\n label,\n value,\n description,\n legendItems = defaultDonutLegendItems,\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: DonutChartWidgetProps) {\n const colors = useCSSVariableColors();\n\n const getColorValue = (colorKey: string): string => {\n const colorMap: Record<string, string> = {\n chartColor1: colors.chartColor1,\n chartColor2: colors.chartColor2,\n chartColor3: colors.chartColor3,\n chartColor4: colors.chartColor4,\n chartColor5: colors.chartColor5,\n };\n return colorMap[colorKey] || colorKey;\n };\n\n const chartColors = legendItems.map((item) => getColorValue(item.color));\n const chartValues = legendItems.map((item) => {\n const numericValue = parseFloat(item.value.replace(/[$,]/g, \"\"));\n return isNaN(numericValue) ? 0 : numericValue;\n });\n\n const data = {\n labels: legendItems.map((item) => item.label),\n datasets: [\n {\n data: chartValues,\n backgroundColor: chartColors,\n borderColor: colors.background,\n borderWidth: 3,\n hoverBorderColor: colors.background,\n hoverBorderWidth: 3,\n },\n ],\n };\n\n const options = {\n responsive: true,\n maintainAspectRatio: false,\n cutout: \"60%\",\n plugins: {\n legend: {\n display: false,\n },\n tooltip: {\n backgroundColor: colors.text,\n titleColor: colors.background,\n bodyColor: colors.background,\n padding: 12,\n cornerRadius: 8,\n },\n },\n };\n\n return (\n <WidgetCard className={cn(\"w-full\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={label} onMenuClick={onMenuClick} />\n\n {/* Value & Description */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {value}\n </span>\n {description && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {description}\n </span>\n )}\n </div>\n\n {/* Chart + Legend */}\n <div\n className=\"flex items-start w-full\"\n style={{\n gap: \"var(--spacing-4xl, 32px)\",\n padding: \"var(--spacing-3xl, 24px) 0\",\n borderTop: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Donut Chart */}\n <div style={{ width: 280, height: 280, flexShrink: 0 }}>\n <Doughnut data={data} options={options} />\n </div>\n\n {/* Legend Grid */}\n <div\n className=\"flex-1 grid grid-cols-2\"\n style={{ gap: \"var(--spacing-3xl, 24px)\" }}\n >\n {legendItems.map((item) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-md, 8px)\" }}\n >\n <div\n className=\"rounded-full\"\n style={{\n width: 8,\n height: 8,\n backgroundColor: getColorValue(item.color),\n flexShrink: 0,\n }}\n />\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, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.value}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 400,\n lineHeight: 1.4,\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.label}\n </span>\n </div>\n </div>\n ))}\n </div>\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// MetricListCard Component\n// ============================================\n\nexport interface MetricListCardProps {\n headerLabel: string;\n title: string;\n subtitle?: string;\n items: MetricListItem[];\n viewDetailsLabel?: string;\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultMetricListItems: MetricListItem[] = [\n { id: \"1\", date: \"Aug 9\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"2\", date: \"Jul 18\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"3\", date: \"Jul 12\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"4\", date: \"Jun 8\", label: \"Software subscription\", value: \"-$40\" },\n];\n\nexport function MetricListCard({\n headerLabel,\n title,\n subtitle,\n items = defaultMetricListItems,\n viewDetailsLabel = \"View transactions >\",\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: MetricListCardProps) {\n return (\n <WidgetCard className={cn(\"flex-1 min-w-0\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={headerLabel} onMenuClick={onMenuClick} />\n\n {/* Title & Subtitle */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </span>\n {subtitle && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {subtitle}\n </span>\n )}\n </div>\n\n {/* List Items */}\n <div className=\"flex flex-col\">\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{\n padding: \"var(--spacing-lg, 12px) 0\",\n gap: \"var(--spacing-3xl, 24px)\",\n borderTop: index === 0 ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n {item.date && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text-placeholder)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.date}\n </span>\n )}\n <span\n className=\"flex-1\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n }}\n >\n {item.label}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.value}\n </span>\n </div>\n ))}\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n viewDetailsLabel={viewDetailsLabel}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// ProgressMetricCard Component\n// ============================================\n\nexport interface ProgressMetricCardProps {\n headerLabel: string;\n title: string;\n subtitle?: string;\n items: ProgressBarItem[];\n className?: string;\n onMenuClick?: () => void;\n onManageClick?: () => void;\n onViewDetailsClick?: () => void;\n}\n\nconst defaultProgressItems: ProgressBarItem[] = [\n { id: \"1\", label: \"$55,000\", progress: 80, imageUrl: \"/logos/shopify.png\" },\n { id: \"2\", label: \"$30,000\", progress: 50, imageUrl: \"/logos/etsy.png\" },\n { id: \"3\", label: \"$16,493\", progress: 30, imageUrl: \"/logos/amazon.png\" },\n { id: \"4\", label: \"$13,800\", progress: 25, imageUrl: \"/logos/dribbble.png\" },\n];\n\nexport function ProgressMetricCard({\n headerLabel,\n title,\n subtitle,\n items = defaultProgressItems,\n className,\n onMenuClick,\n onManageClick,\n onViewDetailsClick,\n}: ProgressMetricCardProps) {\n const colors = useCSSVariableColors();\n\n return (\n <WidgetCard className={cn(\"flex-1 min-w-0\", className)}>\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{\n padding: \"var(--spacing-4xl, 32px)\",\n paddingBottom: 0,\n gap: \"var(--spacing-3xl, 24px)\",\n }}\n >\n <WidgetHeader label={headerLabel} onMenuClick={onMenuClick} />\n\n {/* Title & Subtitle */}\n <div className=\"flex flex-col\">\n <span\n style={{\n fontFamily: \"var(--typo-h5-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h5-size, 30px)\",\n fontWeight: 600,\n lineHeight: 1.27,\n color: \"var(--canvas-text)\",\n }}\n >\n {title}\n </span>\n {subtitle && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {subtitle}\n </span>\n )}\n </div>\n\n {/* Progress Items */}\n <div className=\"flex flex-col\">\n {items.map((item, index) => (\n <div\n key={item.id}\n className=\"flex items-center\"\n style={{\n padding: \"var(--spacing-lg, 12px) 0\",\n gap: \"var(--spacing-xl, 16px)\",\n borderTop: index === 0 ? \"1px solid var(--canvas-border)\" : \"none\",\n borderBottom: index < items.length - 1 ? \"1px solid var(--canvas-border)\" : \"none\",\n height: 64,\n }}\n >\n {/* Image */}\n {item.imageUrl && (\n <div\n className=\"flex items-center justify-center\"\n style={{ width: 32, height: 32, flexShrink: 0 }}\n >\n <img\n src={item.imageUrl}\n alt=\"\"\n style={{\n maxWidth: \"100%\",\n maxHeight: \"100%\",\n objectFit: \"contain\",\n }}\n onError={(e) => {\n // Hide image on error\n (e.target as HTMLImageElement).style.display = \"none\";\n }}\n />\n </div>\n )}\n\n {/* Progress Bar */}\n <div\n className=\"flex-1\"\n style={{\n height: 8,\n backgroundColor: \"var(--canvas-border)\",\n borderRadius: \"var(--spacing-3xl, 24px)\",\n overflow: \"hidden\",\n }}\n >\n <div\n style={{\n width: `${item.progress}%`,\n height: \"100%\",\n backgroundColor: item.color || colors.primary,\n borderRadius: \"var(--spacing-3xl, 24px)\",\n }}\n />\n </div>\n\n {/* Value */}\n <div className=\"flex flex-col items-end\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 600,\n lineHeight: 1.5,\n color: \"var(--canvas-text)\",\n whiteSpace: \"nowrap\",\n }}\n >\n {item.label}\n </span>\n {item.sublabel && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 14px)\",\n fontWeight: 500,\n lineHeight: 1.4,\n color: \"var(--canvas-text-placeholder)\",\n }}\n >\n {item.sublabel}\n </span>\n )}\n </div>\n </div>\n ))}\n </div>\n </div>\n\n <WidgetFooter\n onManageClick={onManageClick}\n onViewDetailsClick={onViewDetailsClick}\n />\n </WidgetCard>\n );\n}\n\n// ============================================\n// TwoColumnWidgets Layout Component\n// ============================================\n\nexport interface TwoColumnWidgetsProps {\n children: React.ReactNode;\n className?: string;\n}\n\nexport function TwoColumnWidgets({ children, className }: TwoColumnWidgetsProps) {\n return (\n <div\n className={cn(\"flex w-full\", className)}\n style={{ gap: \"var(--spacing-4xl, 32px)\" }}\n >\n {children}\n </div>\n );\n}\n\n// ============================================\n// DashboardHeader Component\n// ============================================\n\nexport interface DashboardHeaderProps {\n title: string;\n subtitle?: string;\n className?: string;\n}\n\nexport function DashboardHeader({ title, subtitle, className }: DashboardHeaderProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xs, 4px)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size, 24px)\",\n fontWeight: \"var(--typo-h6-weight, 600)\",\n lineHeight: \"var(--typo-h6-line-height, 1.25)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n {subtitle && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size, 16px)\",\n fontWeight: 400,\n lineHeight: 1.5,\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n )}\n </div>\n );\n}\n\n// ============================================\n// Full Dashboard Demo Component\n// ============================================\n\nexport interface GraphMetricTilesDemoProps {\n className?: string;\n}\n\nexport function GraphMetricTilesDemo({ className }: GraphMetricTilesDemoProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-3xl, 24px)\" }}\n >\n {/* Header */}\n <DashboardHeader title=\"Dashboard\" subtitle=\"View your company's metric\" />\n\n {/* 4-Column Metrics Row */}\n <MetricCardsRow\n metrics={[\n { id: \"1\", label: \"Sales\", value: \"164\", change: { value: \"34\", direction: \"up\" } },\n { id: \"2\", label: \"Revenue\", value: \"$5,462\", change: { value: \"$3,462\", direction: \"up\" } },\n { id: \"3\", label: \"Expenses\", value: \"$1,642\", change: { value: \"$642\", direction: \"up\" } },\n { id: \"4\", label: \"Profit\", value: \"$3,820\", change: { value: \"$2,820\", direction: \"up\" } },\n ]}\n columns={4}\n />\n\n {/* Line Chart Widget */}\n <LineChartWidget\n label=\"Profit and loss\"\n value=\"$127,454\"\n description=\"Your expenses decreased by 8% this month\"\n />\n\n {/* Donut Chart Widget */}\n <DonutChartWidget\n label=\"Revenue\"\n value=\"$2,127,454\"\n description=\"Your revenue increased by 25% this month\"\n />\n\n {/* Two Column - Transactions & Financial Summary */}\n <TwoColumnWidgets>\n <MetricListCard\n headerLabel=\"Recent activities\"\n title=\"Transactions\"\n subtitle=\"You spent $2,321 in the past 7 days\"\n items={[\n { id: \"1\", date: \"Aug 9\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"2\", date: \"Jul 18\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"3\", date: \"Jul 12\", label: \"Software subscription\", value: \"-$40\" },\n { id: \"4\", date: \"Jun 8\", label: \"Software subscription\", value: \"-$40\" },\n ]}\n />\n <MetricListCard\n headerLabel=\"Cash\"\n title=\"Financial Summary\"\n subtitle=\"You have positive cash flow\"\n items={[\n { id: \"1\", label: \"ProjectCo March invoice\", value: \"$80,000\" },\n { id: \"2\", label: \"Tax refund\", value: \"$5,646\" },\n { id: \"3\", label: \"Kohort February invoice\", value: \"$64,000\" },\n { id: \"4\", label: \"License fee\", value: \"$7,500\" },\n ]}\n />\n </TwoColumnWidgets>\n\n {/* Two Column - Progress Metrics */}\n <TwoColumnWidgets>\n <ProgressMetricCard\n headerLabel=\"Revenue\"\n title=\"Sources\"\n subtitle=\"Third-party marketplaces\"\n items={[\n { id: \"1\", label: \"$55,000\", progress: 80, color: \"#95bf47\" },\n { id: \"2\", label: \"$30,000\", progress: 50, color: \"#f27123\" },\n { id: \"3\", label: \"$16,493\", progress: 30, color: \"#faa11f\" },\n { id: \"4\", label: \"$13,800\", progress: 25, color: \"#ff91e9\" },\n ]}\n />\n <ProgressMetricCard\n headerLabel=\"Net income\"\n title=\"Trend analysis\"\n subtitle=\"2020 - 2024\"\n items={[\n { id: \"1\", label: \"$132,000\", sublabel: \"2024\", progress: 100 },\n { id: \"2\", label: \"$78,252\", sublabel: \"2023\", progress: 60 },\n { id: \"3\", label: \"$36,493\", sublabel: \"2022\", progress: 28 },\n { id: \"4\", label: \"$21,800\", sublabel: \"2021\", progress: 17 },\n ]}\n />\n </TwoColumnWidgets>\n </div>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@phosphor-icons/react",
|
|
14
|
+
"chart.js",
|
|
15
|
+
"react-chartjs-2"
|
|
16
|
+
],
|
|
17
|
+
"registryDependencies": [
|
|
18
|
+
"utils"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "grid-tiles-list",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Responsive grid of property/listing tiles with configurable 2-5 columns. Features images, star ratings, prices, favorite buttons, and optional tags. Includes header with sort/filter controls.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/grid-tiles-list.tsx",
|
|
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 { Heart, Star } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface GridTileItem {\n id: string;\n /** Image URL for the tile */\n imageUrl: string;\n /** Tile title */\n title: string;\n /** Rating from 1-5 */\n rating: number;\n /** Location text */\n location: string;\n /** Price (e.g., \"$205\") */\n price: string;\n /** Price unit (e.g., \"night\") */\n priceUnit?: string;\n /** Optional tag badge (e.g., \"Popular\") */\n tag?: string;\n /** Whether item is favorited */\n isFavorite?: boolean;\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 GridTilesListProps {\n /** Block title */\n title?: string;\n /** Subtitle text (e.g., \"20 listings\") */\n subtitle?: string;\n /** Array of tile items */\n items?: GridTileItem[];\n /** Number of grid columns (2, 3, 4, or 5) */\n columns?: 2 | 3 | 4 | 5;\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\n /** Primary action button text */\n actionButtonText?: string;\n /** Callback when action button is clicked */\n onAddNew?: () => void;\n /** Callback when sort value changes */\n onSort?: (value: string) => void;\n /** Callback when filter value changes */\n onFilter?: (value: string) => void;\n /** Callback when favorite button is clicked on a tile */\n onFavorite?: (item: GridTileItem) => void;\n /** Callback when a tile is clicked */\n onItemClick?: (item: GridTileItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: GridTileItem[] = [\n {\n id: \"1\",\n imageUrl: \"https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop\",\n title: \"Central Danish Apartment\",\n rating: 5,\n location: \"Copenhagen, Denmark\",\n price: \"$205\",\n priceUnit: \"night\",\n tag: \"Popular\",\n isFavorite: false,\n },\n {\n id: \"2\",\n imageUrl: \"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop\",\n title: \"Condo on Market Street\",\n rating: 5,\n location: \"San Francisco, CA\",\n price: \"$280\",\n priceUnit: \"night\",\n isFavorite: false,\n },\n {\n id: \"3\",\n imageUrl: \"https://images.unsplash.com/photo-1522708323590-d24dbb6b0267?w=800&h=600&fit=crop\",\n title: \"Waikiki Beach Apt\",\n rating: 4,\n location: \"Honolulu, HI\",\n price: \"$135\",\n priceUnit: \"night\",\n isFavorite: false,\n },\n {\n id: \"4\",\n imageUrl: \"https://images.unsplash.com/photo-1493809842364-78817add7ffb?w=800&h=600&fit=crop\",\n title: \"Upper East Side Apt\",\n rating: 5,\n location: \"New York, NY\",\n price: \"$320\",\n priceUnit: \"night\",\n tag: \"Popular\",\n isFavorite: false,\n },\n {\n id: \"5\",\n imageUrl: \"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop\",\n title: \"Condo on Market Street\",\n rating: 5,\n location: \"San Francisco, CA\",\n price: \"$280\",\n priceUnit: \"night\",\n isFavorite: false,\n },\n {\n id: \"6\",\n imageUrl: \"https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop\",\n title: \"Central Danish Apartment\",\n rating: 5,\n location: \"Copenhagen, Denmark\",\n price: \"$205\",\n priceUnit: \"night\",\n tag: \"Popular\",\n isFavorite: false,\n },\n {\n id: \"7\",\n imageUrl: \"https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=800&h=600&fit=crop\",\n title: \"Apartment in Shoreditch\",\n rating: 5,\n location: \"London, United Kingdom\",\n price: \"$179\",\n priceUnit: \"night\",\n isFavorite: false,\n },\n {\n id: \"8\",\n imageUrl: \"https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop\",\n title: \"Waikiki Beach Apt\",\n rating: 5,\n location: \"Honolulu, HI\",\n price: \"$135\",\n priceUnit: \"night\",\n isFavorite: false,\n },\n];\n\nconst defaultSortOptions: SortOption[] = [\n { id: \"price-low\", label: \"Price (Low to High)\" },\n { id: \"price-high\", label: \"Price (High to Low)\" },\n { id: \"rating\", label: \"Highest Rated\" },\n { id: \"newest\", label: \"Newest\" },\n];\n\nconst defaultFilterOptions: FilterOption[] = [\n { id: \"all\", label: \"All Properties\" },\n { id: \"popular\", label: \"Popular\" },\n { id: \"instant\", label: \"Instant Booking\" },\n { id: \"family\", label: \"Family Friendly\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\nfunction StarRating({ rating }: { rating: number }) {\n return (\n <div \n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-xxs, 2px)\" }}\n >\n {[1, 2, 3, 4, 5].map((star) => (\n <Star\n key={star}\n className=\"size-5\"\n fill={star <= rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n stroke={star <= rating ? \"var(--canvas-primary)\" : \"var(--canvas-border)\"}\n />\n ))}\n </div>\n );\n}\n\ninterface TileCardProps {\n item: GridTileItem;\n onFavorite?: (item: GridTileItem) => void;\n onClick?: (item: GridTileItem) => void;\n}\n\nfunction TileCard({ item, onFavorite, onClick }: TileCardProps) {\n const [isFavorite, setIsFavorite] = useState(item.isFavorite ?? false);\n\n const handleFavorite = (e: React.MouseEvent) => {\n e.stopPropagation();\n setIsFavorite(!isFavorite);\n onFavorite?.(item);\n };\n\n return (\n <div \n className=\"flex flex-col cursor-pointer\"\n style={{ gap: \"var(--spacing-xl, 16px)\" }}\n onClick={() => onClick?.(item)}\n >\n {/* Image Container */}\n <div \n className=\"relative w-full overflow-hidden aspect-square\"\n style={{ \n borderRadius: \"var(--radius-md, 8px)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n {/* Main Image */}\n <img\n src={item.imageUrl}\n alt={item.title}\n className=\"absolute inset-0 w-full h-full object-cover\"\n style={{ borderRadius: \"var(--radius-md, 8px)\" }}\n />\n\n {/* Favorite Button */}\n <button\n onClick={handleFavorite}\n className=\"absolute flex items-center justify-center transition-transform hover:scale-105\"\n style={{\n top: \"var(--spacing-md, 8px)\",\n left: \"var(--spacing-md, 8px)\",\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-4xl, 24px)\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-text)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <Heart\n className=\"size-5\"\n fill={isFavorite ? \"var(--canvas-primary)\" : \"transparent\"}\n stroke=\"var(--canvas-text)\"\n />\n </button>\n\n {/* Tag Badge */}\n {item.tag && (\n <div\n className=\"absolute flex items-center\"\n style={{\n top: \"var(--spacing-md, 8px)\",\n left: \"47px\",\n height: \"32px\",\n paddingLeft: \"var(--spacing-lg, 12px)\",\n paddingRight: \"var(--spacing-lg, 12px)\",\n borderRadius: \"var(--radius-xs, 4px)\",\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-text)\",\n boxShadow: \"0px 1px 8px 0px rgba(0,0,0,0.03)\",\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 {item.tag}\n </div>\n )}\n </div>\n\n {/* Content */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs, 4px)\" }}\n >\n {/* Title */}\n <h3\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.title}\n </h3>\n\n {/* Star Rating */}\n <StarRating rating={item.rating} />\n\n {/* Location */}\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 {item.location}\n </p>\n\n {/* Price */}\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n <span style={{ fontWeight: 500 }}>{item.price}</span>\n {item.priceUnit && (\n <span style={{ fontWeight: \"var(--typo-body-s-weight)\" }}>\n {\" \"}{item.priceUnit}\n </span>\n )}\n </p>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Grid Column Classes\n// ============================================\n\nfunction getGridClasses(columns: 2 | 3 | 4 | 5): string {\n const columnClassMap: Record<2 | 3 | 4 | 5, string> = {\n 2: \"grid-cols-1 sm:grid-cols-2\",\n 3: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\",\n 4: \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-4\",\n 5: \"grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5\",\n };\n return columnClassMap[columns];\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Grid Tiles List Block\n * \n * A responsive grid of property/listing tiles with configurable columns,\n * images, star ratings, prices, and favorite buttons. Includes header\n * section with title, subtitle, and sort/filter controls.\n * \n * @example\n * ```tsx\n * <GridTilesList\n * title=\"Properties\"\n * columns={4}\n * onFavorite={(item) => console.log(\"Favorited:\", item.title)}\n * />\n * ```\n */\nexport function GridTilesList({\n title = \"Properties\",\n subtitle,\n items = defaultItems,\n columns = 4,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n actionButtonText = \"Add new\",\n onAddNew,\n onSort,\n onFilter,\n onFavorite,\n onItemClick,\n className,\n}: GridTilesListProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displaySubtitle = subtitle ?? `${items.length} listings`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl, 16px)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl, 16px)\" }}\n >\n {/* Title and Subtitle */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs, 4px)\" }}\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, 24px)\" }}\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={cn(\"grid w-full\", getGridClasses(columns))}\n style={{ gap: \"var(--spacing-4xl, 32px)\" }}\n >\n {items.map((item) => (\n <TileCard\n key={item.id}\n item={item}\n onFavorite={onFavorite}\n onClick={onItemClick}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"lucide-react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"utils",
|
|
17
|
+
"button",
|
|
18
|
+
"select"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hero-dark-centered",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Dark centered hero with multi-field search (location, dates, guests).",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/marketing/hero-dark-centered.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { MapPin, Calendar, Users } from \"@phosphor-icons/react\";\nimport { Button } from \"../../ui/button\";\nimport { Typography } from \"../../ui/typography\";\n\ninterface HeroDarkCenteredProps {\n title?: string;\n subtitle?: string;\n}\n\nexport function HeroDarkCentered({ \n title = \"Plan your next adventure\",\n subtitle = \"Live like locals from anywhere in the world\",\n}: HeroDarkCenteredProps) {\n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-20 pb-8 md:pb-12\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n }}\n >\n <div \n className=\"w-full flex flex-col items-center justify-center text-center p-12 lg:p-32\"\n style={{\n backgroundColor: \"var(--canvas-dark-section-bg)\",\n borderRadius: \"var(--spacing-2xl)\",\n }}\n >\n {/* Content */}\n <div className=\"flex flex-col gap-6 w-full max-w-[992px]\">\n <div className=\"flex flex-col gap-2 items-center\">\n <Typography\n variant=\"h2\"\n as=\"h1\"\n style={{ color: \"white\" }}\n >\n {title}\n </Typography>\n <Typography\n variant=\"body-xl\"\n color=\"muted\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n {subtitle}\n </Typography>\n </div>\n \n {/* Multi-field Search Bar */}\n <div \n className=\"flex flex-col md:flex-row items-center w-full max-w-[900px] mx-auto\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n borderRadius: \"var(--spacing-md)\",\n border: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-md)\",\n boxShadow: \"0px 4px 16px 0px rgba(0, 0, 0, 0.04)\",\n }}\n >\n {/* Destination */}\n <div \n className=\"flex items-center gap-2 px-3 py-2 border-b md:border-b-0 md:border-r w-full md:flex-1 min-w-0\"\n style={{ borderColor: \"var(--canvas-border)\" }}\n >\n <MapPin size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Destination\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Check-in */}\n <div \n className=\"flex items-center gap-2 px-3 py-2 border-b md:border-b-0 md:border-r w-full md:flex-1 min-w-0\"\n style={{ borderColor: \"var(--canvas-border)\" }}\n >\n <Calendar size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Check-in\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Check-out */}\n <div \n className=\"flex items-center gap-2 px-3 py-2 border-b md:border-b-0 md:border-r w-full md:flex-1 min-w-0\"\n style={{ borderColor: \"var(--canvas-border)\" }}\n >\n <Calendar size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Check out\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Guests */}\n <div className=\"flex items-center gap-2 px-3 py-2 w-full md:flex-1 min-w-0\">\n <Users size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Guests\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Search Button */}\n <Button variant=\"primary\" size=\"lg\" className=\"shrink-0 w-full md:w-auto mt-2 md:mt-0 md:ml-2\">\n Search\n </Button>\n </div>\n </div>\n </div>\n </section>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@phosphor-icons/react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": []
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hero-dark-with-image",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Dark hero section with text on left and image on right.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/marketing/hero-dark-with-image.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { MagnifyingGlass } from \"@phosphor-icons/react\";\nimport { Typography } from \"../../ui/typography\";\n\ninterface HeroDarkWithImageProps {\n title?: string;\n subtitle?: string;\n searchPlaceholder?: string;\n image?: string;\n}\n\nexport function HeroDarkWithImage({ \n title = \"Plan your next adventure\",\n subtitle = \"Live like locals from anywhere in the world\",\n searchPlaceholder = \"Search for a place\",\n image = \"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=700&h=380&fit=crop\"\n}: HeroDarkWithImageProps) {\n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-20 pb-8 md:pb-12\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n }}\n >\n <div \n className=\"w-full flex flex-wrap items-center gap-10 lg:gap-20 p-8 lg:p-20 overflow-hidden\"\n style={{\n backgroundColor: \"var(--canvas-dark-section-bg)\",\n borderRadius: \"var(--spacing-2xl)\",\n }}\n >\n {/* Content */}\n <div \n className=\"flex flex-col gap-6 min-w-0\"\n style={{ flex: \"1 1 400px\", maxWidth: \"100%\" }}\n >\n <div className=\"flex flex-col gap-2\">\n <Typography\n variant=\"h2\"\n as=\"h1\"\n style={{ color: \"white\" }}\n >\n {title}\n </Typography>\n <Typography\n variant=\"body-xl\"\n color=\"muted\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n {subtitle}\n </Typography>\n </div>\n \n {/* Search Bar */}\n <div \n className=\"flex items-center w-full max-w-[480px]\"\n style={{\n height: \"64px\",\n backgroundColor: \"var(--canvas-background)\",\n borderRadius: \"var(--spacing-md)\",\n border: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-md) var(--spacing-md) var(--spacing-md) var(--spacing-xl)\",\n gap: \"var(--spacing-3xl)\",\n boxShadow: \"0px 4px 16px 0px rgba(0, 0, 0, 0.04)\",\n }}\n >\n <input\n type=\"text\"\n placeholder={searchPlaceholder}\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n <button \n className=\"shrink-0 flex items-center justify-center\"\n style={{\n width: \"44px\",\n height: \"44px\",\n backgroundColor: \"var(--canvas-primary)\",\n borderRadius: \"var(--spacing-md)\",\n }}\n >\n <MagnifyingGlass size={20} color=\"white\" weight=\"bold\" />\n </button>\n </div>\n </div>\n\n {/* Image */}\n <div \n className=\"h-[280px] lg:h-[380px] overflow-hidden min-w-0\"\n style={{\n flex: \"1 1 300px\",\n maxWidth: \"100%\",\n borderRadius: \"var(--spacing-md)\",\n }}\n >\n <img \n src={image} \n alt=\"Adventure\"\n className=\"w-full h-full object-cover\"\n />\n </div>\n </div>\n </section>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@phosphor-icons/react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": []
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hero-fullwidth-image",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Full-width hero image with centered content and multi-field search.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/marketing/hero-fullwidth-image.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { MapPin, Calendar, Users } from \"@phosphor-icons/react\";\nimport { Button } from \"../../ui/button\";\nimport { Typography } from \"../../ui/typography\";\n\ninterface HeroFullwidthImageProps {\n title?: string;\n subtitle?: string;\n backgroundImage?: string;\n}\n\nexport function HeroFullwidthImage({ \n title = \"Plan your next adventure\",\n subtitle = \"Live like locals from anywhere in the world\",\n backgroundImage = \"https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=1400&h=500&fit=crop\"\n}: HeroFullwidthImageProps) {\n return (\n <section \n className=\"w-full pb-24 md:pb-28\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n }}\n >\n {/* Hero Image with Overlay */}\n <div \n className=\"relative w-full flex flex-col items-center justify-center px-4 md:px-10\"\n style={{\n height: \"420px\",\n }}\n >\n {/* Background Image */}\n <div \n className=\"absolute inset-0 bg-cover bg-center\"\n style={{ backgroundImage: `url(${backgroundImage})` }}\n />\n {/* Overlay */}\n <div className=\"absolute inset-0 bg-black/30\" />\n \n {/* Content */}\n <div className=\"relative z-10 flex flex-col items-center text-center gap-3 max-w-[992px] w-full\">\n <Typography\n variant=\"h2\"\n as=\"h1\"\n style={{ color: \"white\" }}\n >\n {title}\n </Typography>\n <Typography\n variant=\"body-xl\"\n style={{ color: \"white\" }}\n >\n {subtitle}\n </Typography>\n </div>\n\n {/* Search Bar - positioned at bottom, overlapping */}\n <div \n className=\"absolute left-1/2 -translate-x-1/2 flex flex-col md:flex-row items-center w-[calc(100%-32px)] max-w-[900px]\"\n style={{\n bottom: \"-40px\",\n backgroundColor: \"var(--canvas-background)\",\n borderRadius: \"var(--spacing-md)\",\n border: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-md)\",\n boxShadow: \"0px 4px 16px 0px rgba(0, 0, 0, 0.04)\",\n }}\n >\n {/* Destination */}\n <div \n className=\"flex items-center gap-2 px-3 py-2 border-b md:border-b-0 md:border-r w-full md:flex-1 min-w-0\"\n style={{ borderColor: \"var(--canvas-border)\" }}\n >\n <MapPin size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Destination\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Check-in */}\n <div \n className=\"flex items-center gap-2 px-3 py-2 border-b md:border-b-0 md:border-r w-full md:flex-1 min-w-0\"\n style={{ borderColor: \"var(--canvas-border)\" }}\n >\n <Calendar size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Check-in\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Check-out */}\n <div \n className=\"flex items-center gap-2 px-3 py-2 border-b md:border-b-0 md:border-r w-full md:flex-1 min-w-0\"\n style={{ borderColor: \"var(--canvas-border)\" }}\n >\n <Calendar size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Check out\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Guests */}\n <div className=\"flex items-center gap-2 px-3 py-2 w-full md:flex-1 min-w-0\">\n <Users size={20} className=\"shrink-0\" style={{ color: \"var(--canvas-text-placeholder)\" }} />\n <input\n type=\"text\"\n placeholder=\"Guests\"\n className=\"flex-1 bg-transparent outline-none min-w-0\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n </div>\n\n {/* Search Button */}\n <Button variant=\"primary\" size=\"lg\" className=\"shrink-0 w-full md:w-auto mt-2 md:mt-0 md:ml-2\">\n Search\n </Button>\n </div>\n </div>\n </section>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@phosphor-icons/react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": []
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hero-section",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Hero with background image and simple search bar overlay.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/marketing/hero-section.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { MagnifyingGlass } from \"@phosphor-icons/react\";\nimport { Typography } from \"../../ui/typography\";\n\ninterface HeroSectionProps {\n title: string;\n subtitle: string;\n searchPlaceholder?: string;\n backgroundImage?: string;\n}\n\nexport function HeroSection({ \n title, \n subtitle, \n searchPlaceholder = \"Search for a place\",\n backgroundImage = \"https://images.unsplash.com/photo-1506929562872-bb421503ef21?w=1400&h=600&fit=crop\"\n}: HeroSectionProps) {\n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-20 pt-5 pb-8 md:pb-12\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n }}\n >\n <div \n className=\"relative w-full overflow-hidden flex items-center min-h-[320px] md:min-h-[420px] p-6 md:p-10\"\n style={{\n borderRadius: \"var(--spacing-2xl)\",\n }}\n >\n {/* Background Image */}\n <div \n className=\"absolute inset-0 bg-cover bg-center\"\n style={{ backgroundImage: `url(${backgroundImage})` }}\n />\n {/* Overlay */}\n <div className=\"absolute inset-0 bg-black/30\" />\n \n {/* Content */}\n <div \n className=\"relative z-10 flex flex-col max-w-[480px]\"\n style={{ gap: \"var(--spacing-5xl)\" }}\n >\n <div className=\"flex flex-col text-white\" style={{ gap: \"var(--spacing-md)\" }}>\n <Typography \n variant=\"h2\" \n as=\"h1\" \n className=\"text-white\"\n style={{ color: \"white\" }}\n >\n {title}\n </Typography>\n <Typography \n variant=\"body-xl\" \n className=\"text-white/90\"\n style={{ color: \"rgba(255,255,255,0.9)\" }}\n >\n {subtitle}\n </Typography>\n </div>\n \n {/* Search Bar */}\n <div \n className=\"flex items-center w-full shadow-lg\"\n style={{\n height: \"64px\",\n backgroundColor: \"var(--canvas-background)\",\n borderRadius: \"var(--spacing-md)\",\n border: \"1px solid var(--canvas-border)\",\n padding: \"var(--spacing-md) var(--spacing-md) var(--spacing-md) var(--spacing-xl)\",\n gap: \"var(--spacing-3xl)\",\n }}\n >\n <input\n type=\"text\"\n placeholder={searchPlaceholder}\n className=\"flex-1 bg-transparent outline-none\"\n style={{\n fontFamily: \"var(--typo-global-font)\",\n fontSize: \"var(--typo-body-l-size)\",\n color: \"var(--canvas-text)\",\n }}\n />\n <button \n className=\"shrink-0 flex items-center justify-center\"\n style={{\n width: \"44px\",\n height: \"44px\",\n backgroundColor: \"var(--canvas-primary)\",\n borderRadius: \"var(--spacing-md)\",\n }}\n >\n <MagnifyingGlass size={20} color=\"white\" weight=\"bold\" />\n </button>\n </div>\n </div>\n </div>\n </section>\n );\n}\n\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@phosphor-icons/react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": []
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "how-it-works",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Horizontal 3-step how it works section.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/marketing/how-it-works.tsx",
|
|
8
|
+
"type": "registry:block",
|
|
9
|
+
"content": "\"use client\";\n\nimport { ShieldCheck, Coffee, Coins, Heart } from \"@phosphor-icons/react\";\nimport { Typography } from \"../../ui/typography\";\n\ninterface FeatureItem {\n id: string;\n title: string;\n description: string;\n icon: React.ReactNode;\n}\n\nconst defaultFeatures: FeatureItem[] = [\n {\n id: \"1\",\n title: \"Peace of mind\",\n description: \"Our money-back guarantee allows you to book with confidence\",\n icon: <ShieldCheck size={64} weight=\"thin\" />,\n },\n {\n id: \"2\",\n title: \"A home away from home\",\n description: \"Stay with full kitchens, laundry, pools, and spacious yards.\",\n icon: <Coffee size={64} weight=\"thin\" />,\n },\n {\n id: \"3\",\n title: \"More for less\",\n description: \"More space, more privacy, more amenities — more value\",\n icon: <Coins size={64} weight=\"thin\" />,\n },\n {\n id: \"4\",\n title: \"A space for everyone\",\n description: \"We celebrate diversity, inclusion, and families worldwide.\",\n icon: <Heart size={64} weight=\"thin\" />,\n },\n];\n\ninterface HowItWorksProps {\n variant?: \"light\" | \"dark\";\n features?: FeatureItem[];\n}\n\nexport function HowItWorks({ variant = \"light\", features = defaultFeatures }: HowItWorksProps) {\n const isDark = variant === \"dark\";\n \n return (\n <section \n className=\"w-full px-4 md:px-8 lg:px-10 py-10 md:py-16\"\n style={{\n backgroundColor: isDark ? \"var(--canvas-dark-section-bg)\" : \"var(--canvas-surface)\",\n }}\n >\n <div className=\"w-full max-w-[1240px] mx-auto\">\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-16\">\n {features.map((feature) => (\n <div \n key={feature.id}\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n <div style={{ color: isDark ? \"var(--canvas-primary-foreground)\" : \"var(--canvas-text)\" }}>\n {feature.icon}\n </div>\n <div className=\"flex flex-col\" style={{ gap: \"var(--spacing-xxs)\" }}>\n <Typography \n variant=\"body-xl\" \n className=\"font-semibold\"\n style={{ color: isDark ? \"var(--canvas-primary-foreground)\" : \"var(--canvas-text)\" }}\n >\n {feature.title}\n </Typography>\n <Typography \n variant=\"body-m\"\n style={{ color: isDark ? \"var(--canvas-text-placeholder)\" : \"var(--canvas-text-muted)\" }}\n >\n {feature.description}\n </Typography>\n </div>\n </div>\n ))}\n </div>\n </div>\n </section>\n );\n}\n\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@phosphor-icons/react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": []
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "image-feed-with-nested-comments",
|
|
3
|
+
"type": "registry:block",
|
|
4
|
+
"description": "Instagram-style image feed with large images, social interactions (like/comment/share/bookmark), and nested comment threads.",
|
|
5
|
+
"files": [
|
|
6
|
+
{
|
|
7
|
+
"path": "components/blocks/image-feed-with-nested-comments.tsx",
|
|
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 { Heart, MessageCircle, Send, Bookmark } from \"lucide-react\";\nimport { \n NestedCommentsTable, \n type Comment, \n type CommentAuthor \n} from \"./nested-comments-table\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PostAuthor {\n id: string;\n name: string;\n username?: string;\n avatarUrl?: string;\n}\n\nexport interface ImagePost {\n id: string;\n author: PostAuthor;\n date: string;\n imageUrl: string;\n caption?: string;\n likesCount: number;\n likedByText?: string;\n commentsCount: number;\n isLiked?: boolean;\n isBookmarked?: boolean;\n comments?: Comment[];\n}\n\nexport interface ImageFeedWithNestedCommentsProps {\n /** Section title */\n title?: string;\n /** Section subtitle */\n subtitle?: string;\n /** Posts data */\n posts?: ImagePost[];\n /** Current user for comment avatars */\n currentUser?: CommentAuthor;\n /** Callback when post like is clicked */\n onLike?: (postId: string) => void;\n /** Callback when new comment is submitted */\n onComment?: (postId: string, content: string) => void;\n /** Callback when share is clicked */\n onShare?: (postId: string) => void;\n /** Callback when bookmark is clicked */\n onBookmark?: (postId: string) => void;\n /** Callback when reply is submitted */\n onReply?: (postId: string, commentId: string, content: string) => void;\n /** Callback when comment like is clicked */\n onCommentLike?: (postId: string, commentId: string) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultCurrentUser: CommentAuthor = {\n id: \"current\",\n name: \"Mary Trott\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n};\n\nconst defaultPosts: ImagePost[] = [\n {\n id: \"1\",\n author: {\n id: \"aya\",\n name: \"Aya Williams\",\n username: \"ayawilliams\",\n avatarUrl: \"https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&h=150&fit=crop&crop=face\",\n },\n date: \"May 23, 2024\",\n imageUrl: \"https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=1200&h=800&fit=crop\",\n caption: \"Beautiful day\",\n likesCount: 42,\n likedByText: \"Liked by sc04116 and others\",\n commentsCount: 6,\n isLiked: false,\n isBookmarked: false,\n comments: [\n {\n id: \"c1\",\n author: {\n id: \"raj\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Wow, Paris looks absolutely stunning! The Eiffel Tower is such an iconic landmark. Hope you have an amazing time exploring the city and soaking in all its beauty. Safe travels!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 3,\n isLiked: true,\n replies: [\n {\n id: \"r1\",\n author: {\n id: \"stacy\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Ah, the City of Love! Your picture brings back memories of my own trip to Paris. The Eiffel Tower is even more breathtaking in person. Have a fantastic time exploring all the charm Paris has to offer!\",\n timestamp: \"Feb 23, 1:32 PM\",\n likes: 0,\n isLiked: false,\n replies: [\n {\n id: \"r2\",\n author: {\n id: \"mary\",\n name: \"Raj Mishra\",\n avatarUrl: \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&h=150&fit=crop&crop=face\",\n },\n content: \"Paris is truly a dream destination! The Eiffel Tower never fails to impress. Enjoy every moment of your adventure and make unforgettable memories. Can't wait to see more of your journey!\",\n timestamp: \"Mar 8, 11:23 AM\",\n likes: 0,\n isLiked: false,\n },\n ],\n },\n ],\n },\n ],\n },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface ActionIconsRowProps {\n isLiked?: boolean;\n isBookmarked?: boolean;\n onLike?: () => void;\n onComment?: () => void;\n onShare?: () => void;\n onBookmark?: () => void;\n}\n\nfunction ActionIconsRow({\n isLiked,\n isBookmarked,\n onLike,\n onComment,\n onShare,\n onBookmark,\n}: ActionIconsRowProps) {\n return (\n <div\n className=\"flex items-center w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <button\n type=\"button\"\n onClick={onLike}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Heart\n className=\"w-6 h-6\"\n style={{\n fill: isLiked ? \"var(--canvas-destructive)\" : \"transparent\",\n stroke: isLiked ? \"var(--canvas-destructive)\" : \"currentColor\",\n }}\n />\n </button>\n <button\n type=\"button\"\n onClick={onComment}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <MessageCircle className=\"w-6 h-6\" />\n </button>\n <button\n type=\"button\"\n onClick={onShare}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Send className=\"w-6 h-6\" />\n </button>\n <div className=\"flex-1 flex justify-end\">\n <button\n type=\"button\"\n onClick={onBookmark}\n className=\"shrink-0\"\n style={{ color: \"var(--canvas-text-placeholder)\" }}\n >\n <Bookmark\n className=\"w-6 h-6\"\n style={{\n fill: isBookmarked ? \"currentColor\" : \"transparent\",\n }}\n />\n </button>\n </div>\n </div>\n );\n}\n\ninterface SocialMetadataProps {\n likedByText?: string;\n username?: string;\n caption?: string;\n commentsCount: number;\n onViewComments?: () => void;\n}\n\nfunction SocialMetadata({\n likedByText,\n username,\n caption,\n commentsCount,\n onViewComments,\n}: SocialMetadataProps) {\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {likedByText && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {likedByText}\n </p>\n )}\n {caption && (\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n <span style={{ fontWeight: 600 }}>{username}</span>{\" \"}\n <span style={{ fontWeight: 400 }}>{caption}</span>\n </p>\n )}\n {commentsCount > 0 && (\n <button\n type=\"button\"\n onClick={onViewComments}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n textAlign: \"left\",\n background: \"none\",\n border: \"none\",\n padding: 0,\n cursor: \"pointer\",\n }}\n >\n View all {commentsCount} comments\n </button>\n )}\n </div>\n );\n}\n\ninterface PostCardProps {\n post: ImagePost;\n currentUser?: CommentAuthor;\n onLike?: () => void;\n onComment?: (content: string) => void;\n onShare?: () => void;\n onBookmark?: () => void;\n onReply?: (commentId: string, content: string) => void;\n onCommentLike?: (commentId: string) => void;\n}\n\nfunction PostCard({\n post,\n currentUser,\n onLike,\n onComment,\n onShare,\n onBookmark,\n onReply,\n onCommentLike,\n}: PostCardProps) {\n const [showComments, setShowComments] = useState(true);\n\n return (\n <div\n className=\"flex flex-col w-full\"\n style={{\n gap: \"var(--spacing-xl)\",\n paddingBottom: \"var(--spacing-5xl)\",\n }}\n >\n {/* Author Row */}\n <div\n className=\"flex items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <Avatar\n className=\"shrink-0\"\n style={{\n width: 48,\n height: 48,\n borderRadius: \"var(--spacing-3xl)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n >\n <AvatarImage src={post.author.avatarUrl} />\n <AvatarFallback>\n {post.author.name.split(\" \").map(n => n[0]).join(\"\").slice(0, 2)}\n </AvatarFallback>\n </Avatar>\n <div className=\"flex flex-col flex-1\">\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {post.author.name}\n </span>\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 400,\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {post.date}\n </span>\n </div>\n </div>\n\n {/* Post Image */}\n <div\n className=\"w-full overflow-hidden\"\n style={{\n borderTop: \"1px solid var(--canvas-border)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n >\n <img\n src={post.imageUrl}\n alt={post.caption || \"Post image\"}\n className=\"w-full h-auto object-cover\"\n style={{ maxHeight: 768 }}\n />\n </div>\n\n {/* Action Icons */}\n <ActionIconsRow\n isLiked={post.isLiked}\n isBookmarked={post.isBookmarked}\n onLike={onLike}\n onComment={() => setShowComments(!showComments)}\n onShare={onShare}\n onBookmark={onBookmark}\n />\n\n {/* Social Metadata */}\n <SocialMetadata\n likedByText={post.likedByText}\n username={post.author.username}\n caption={post.caption}\n commentsCount={post.commentsCount}\n onViewComments={() => setShowComments(!showComments)}\n />\n\n {/* Comments Section - using NestedCommentsTable */}\n {showComments && post.comments && post.comments.length > 0 && (\n <NestedCommentsTable\n title=\"\"\n subtitle=\"\"\n comments={post.comments}\n currentUser={currentUser}\n onComment={onComment}\n onReply={onReply}\n onLike={onCommentLike}\n className=\"pt-0\"\n />\n )}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Image Feed with Nested Comments Block\n *\n * An Instagram-style image feed component with large images, social interactions\n * (like/comment/share/bookmark), and nested comment threads. Uses NestedCommentsTable\n * internally for the comments section.\n *\n * @example\n * ```tsx\n * <ImageFeedWithNestedComments\n * title=\"My posts\"\n * subtitle=\"In the past year\"\n * posts={[...]}\n * onLike={(postId) => console.log(\"Liked\", postId)}\n * />\n * ```\n */\nexport function ImageFeedWithNestedComments({\n title = \"My posts\",\n subtitle = \"In the past year\",\n posts = defaultPosts,\n currentUser = defaultCurrentUser,\n onLike,\n onComment,\n onShare,\n onBookmark,\n onReply,\n onCommentLike,\n className,\n}: ImageFeedWithNestedCommentsProps) {\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n {(title || subtitle) && (\n <div\n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n <div\n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {title && (\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 )}\n {subtitle && (\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 )}\n </div>\n </div>\n )}\n\n {/* Posts List */}\n <div className=\"flex flex-col w-full\">\n {posts.map((post) => (\n <PostCard\n key={post.id}\n post={post}\n currentUser={currentUser}\n onLike={() => onLike?.(post.id)}\n onComment={(content) => onComment?.(post.id, content)}\n onShare={() => onShare?.(post.id)}\n onBookmark={() => onBookmark?.(post.id)}\n onReply={(commentId, content) => onReply?.(post.id, commentId, content)}\n onCommentLike={(commentId) => onCommentLike?.(post.id, commentId)}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"lucide-react"
|
|
14
|
+
],
|
|
15
|
+
"registryDependencies": [
|
|
16
|
+
"utils",
|
|
17
|
+
"avatar",
|
|
18
|
+
"nested-comments-table"
|
|
19
|
+
]
|
|
20
|
+
}
|