canvas-ui-sdk 0.3.13 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -71
- package/dist/index.js +60 -60
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/prompts/CLAUDE.md +85 -0
- package/prompts/bake-theme.md +194 -0
- package/registry/blocks/activity-feed.json +1 -1
- package/registry/blocks/circular-progress-bar-list.json +1 -1
- package/registry/blocks/faqs-table.json +1 -1
- package/registry/blocks/fixed-column-data-table.json +1 -1
- package/registry/blocks/form-group.json +1 -1
- package/registry/blocks/grid-tiles-list.json +1 -1
- package/registry/blocks/image-feed-with-nested-comments.json +1 -1
- package/registry/blocks/large-image-labels-list.json +1 -1
- package/registry/blocks/monthly-calendar-widget.json +1 -1
- package/registry/blocks/nested-comments-table.json +1 -1
- package/registry/blocks/nested-data-table.json +1 -1
- package/registry/blocks/profile-grid-tiles-list.json +1 -1
- package/registry/blocks/reviews-table.json +1 -1
- package/registry/blocks/slideshow-grid-tiles.json +1 -1
- package/registry/blocks/social-feed.json +1 -1
- package/registry/blocks/standard-data-table.json +1 -1
- package/registry/blocks/standard-list-with-image.json +1 -1
- package/registry/blocks/upvoting-posts-table.json +1 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/form-group.tsx",
|
|
8
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"
|
|
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-end 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-end justify-end shrink-0 gap-3 max-sm:w-full max-sm:flex-wrap\">\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select\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
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [],
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/grid-tiles-list.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { 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"
|
|
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-end 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-end justify-end shrink-0 max-sm:w-full max-sm:flex-wrap\"\n style={{ gap: \"var(--spacing-3xl, 24px)\" }}\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Action Button */}\n <Button\n variant=\"primary\"\n size=\"sm\"\n onClick={onAddNew}\n style={{\n height: \"var(--btn-small-height)\",\n paddingLeft: \"var(--btn-small-px)\",\n paddingRight: \"var(--btn-small-px)\",\n fontSize: \"var(--btn-small-font-size)\",\n borderRadius: \"var(--btn-small-radius)\",\n backgroundColor: \"var(--btn-primary-bg)\",\n color: \"var(--btn-primary-text)\",\n borderColor: \"var(--btn-primary-border)\",\n }}\n >\n {actionButtonText}\n </Button>\n </div>\n </div>\n\n {/* Grid Section */}\n <div \n className={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
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/image-feed-with-nested-comments.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Avatar, AvatarImage, AvatarFallback } from \"../ui/avatar\";\nimport { 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"
|
|
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-end 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
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/large-image-labels-list.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Heart, Star, MapPin, Users, Zap, Briefcase } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface IconLabelConfig {\n id: string;\n icon: \"heart\" | \"users\" | \"zap\" | \"map-pin\" | \"briefcase\";\n label: string;\n}\n\nexport interface ListItem {\n id: string;\n imageUrl: string;\n title: string;\n price: string;\n priceUnit?: string;\n rating: number;\n reviewCount: number;\n location: string;\n description: string;\n labels: IconLabelConfig[];\n tag?: string;\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 LargeImageLabelsListProps {\n /** Section title */\n title?: string;\n /** Number of results (defaults to items.length) */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** List items to display */\n items?: ListItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\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 */\n onFavorite?: (item: ListItem) => void;\n /** Callback when item is clicked */\n onItemClick?: (item: ListItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ListItem[] = [\n {\n id: \"1\",\n imageUrl: \"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop\",\n title: \"Waikiki Beach Apartment\",\n price: \"$140\",\n priceUnit: \"/night\",\n rating: 5,\n reviewCount: 210,\n location: \"Honolulu, HI\",\n description: \"Stylish apartment near Waikiki beach that offers the perfect blend of comfort and convenience for your Hawaiian escape\",\n labels: [\n { id: \"1\", icon: \"heart\", label: \"Popular\" },\n { id: \"2\", icon: \"users\", label: \"Perfect for families\" },\n { id: \"3\", icon: \"zap\", label: \"Instant booking\" },\n ],\n tag: \"Popular\",\n isFavorite: false,\n },\n {\n id: \"2\",\n imageUrl: \"https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop\",\n title: \"Central Danish Apartment\",\n price: \"$180\",\n priceUnit: \"/night\",\n rating: 5,\n reviewCount: 98,\n location: \"Copenhagen, Denmark\",\n description: \"A centrally located haven nestled in the heart of the city of Copenhagen, offering unparalleled convenience and accessibility\",\n labels: [\n { id: \"1\", icon: \"map-pin\", label: \"Central\" },\n { id: \"2\", icon: \"briefcase\", label: \"Business travelers\" },\n { id: \"3\", icon: \"zap\", label: \"Instant booking\" },\n ],\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: \"instant\", label: \"Instant Booking\" },\n { id: \"popular\", label: \"Popular\" },\n { id: \"family\", label: \"Family Friendly\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\nfunction StarRating({ rating, reviewCount }: { rating: number; reviewCount: 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 <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\",\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n paddingLeft: \"var(--spacing-sm)\",\n }}\n >\n {reviewCount}\n </span>\n </div>\n );\n}\n\nfunction IconLabel({ icon, label }: { icon: IconLabelConfig[\"icon\"]; label: string }) {\n const iconMap = {\n heart: Heart,\n users: Users,\n zap: Zap,\n \"map-pin\": MapPin,\n briefcase: Briefcase,\n };\n const IconComponent = iconMap[icon];\n\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <IconComponent\n className=\"size-5\"\n style={{ color: \"var(--canvas-text)\" }}\n />\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {label}\n </span>\n </div>\n );\n}\n\ninterface ListItemCardProps {\n item: ListItem;\n onFavorite?: (item: ListItem) => void;\n onClick?: (item: ListItem) => void;\n}\n\nfunction ListItemCard({ item, onFavorite, onClick }: ListItemCardProps) {\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 md:flex-row w-full cursor-pointer\"\n style={{\n gap: \"var(--spacing-3xl)\",\n paddingTop: \"var(--spacing-3xl)\",\n paddingBottom: \"var(--spacing-3xl)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n onClick={() => onClick?.(item)}\n >\n {/* Image Section */}\n <div className=\"relative md:flex-1 aspect-[4/3] md:aspect-auto md:min-h-[200px]\">\n <img\n src={item.imageUrl}\n alt={item.title}\n className=\"w-full h-full object-cover\"\n style={{\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n />\n {/* Favorite Button */}\n <button\n onClick={handleFavorite}\n className=\"absolute flex items-center justify-center\"\n style={{\n top: \"var(--spacing-md)\",\n left: \"var(--spacing-md)\",\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-full, 24px)\",\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 }}\n >\n <Heart\n className=\"size-5\"\n fill={isFavorite ? \"var(--canvas-primary)\" : \"transparent\"}\n stroke=\"var(--canvas-text)\"\n />\n </button>\n {/* Tag Badge */}\n {item.tag && (\n <div\n className=\"absolute flex items-center\"\n style={{\n top: \"var(--spacing-md)\",\n left: \"47px\",\n height: \"32px\",\n paddingLeft: \"var(--spacing-lg)\",\n paddingRight: \"var(--spacing-lg)\",\n borderRadius: \"var(--radius-xs)\",\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 Section */}\n <div\n className=\"flex flex-col md:flex-1\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header Info */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {/* Title and Price Row */}\n <div className=\"flex items-center justify-between w-full\">\n <h3\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {item.title}\n </h3>\n <div className=\"flex items-baseline shrink-0\">\n <span\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.price}\n </span>\n {item.priceUnit && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {item.priceUnit}\n </span>\n )}\n </div>\n </div>\n\n {/* Rating */}\n <StarRating rating={item.rating} reviewCount={item.reviewCount} />\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 </div>\n\n {/* Description */}\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.description}\n </p>\n\n {/* Labels */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {item.labels.map((labelConfig) => (\n <IconLabel\n key={labelConfig.id}\n icon={labelConfig.icon}\n label={labelConfig.label}\n />\n ))}\n </div>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Large Image Labels List Block\n * \n * A property-style listing component with large images, ratings, prices,\n * and icon labels. Includes a header section with title, result count,\n * and sort/filter controls.\n * \n * @example\n * ```tsx\n * <LargeImageLabelsList\n * title=\"Properties\"\n * items={[\n * {\n * id: \"1\",\n * imageUrl: \"https://example.com/image.jpg\",\n * title: \"Beach Apartment\",\n * price: \"$140\",\n * priceUnit: \"/night\",\n * rating: 5,\n * reviewCount: 210,\n * location: \"Honolulu, HI\",\n * description: \"Beautiful beachfront property...\",\n * labels: [{ id: \"1\", icon: \"heart\", label: \"Popular\" }],\n * tag: \"Popular\",\n * }\n * ]}\n * onFavorite={(item) => console.log(\"Favorited:\", item.title)}\n * />\n * ```\n */\nexport function LargeImageLabelsList({\n title = \"Properties\",\n resultCount,\n resultCountText,\n items = defaultItems,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n onSort,\n onFilter,\n onFavorite,\n onItemClick,\n className,\n}: LargeImageLabelsListProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayResultCount = resultCount ?? items.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-start w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-start justify-end shrink-0 gap-3\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n </div>\n </div>\n\n {/* List Section */}\n <div \n className=\"flex flex-col w-full\"\n style={{ borderTop: \"1px solid var(--canvas-border)\" }}\n >\n {items.map((item) => (\n <ListItemCard\n key={item.id}\n item={item}\n onFavorite={onFavorite}\n onClick={onItemClick}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"../ui/select\";\nimport { Heart, Star, MapPin, Users, Zap, Briefcase } from \"lucide-react\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface IconLabelConfig {\n id: string;\n icon: \"heart\" | \"users\" | \"zap\" | \"map-pin\" | \"briefcase\";\n label: string;\n}\n\nexport interface ListItem {\n id: string;\n imageUrl: string;\n title: string;\n price: string;\n priceUnit?: string;\n rating: number;\n reviewCount: number;\n location: string;\n description: string;\n labels: IconLabelConfig[];\n tag?: string;\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 LargeImageLabelsListProps {\n /** Section title */\n title?: string;\n /** Number of results (defaults to items.length) */\n resultCount?: number;\n /** Custom result count text (overrides default \"{count} results\") */\n resultCountText?: string;\n /** List items to display */\n items?: ListItem[];\n /** Sort options for the sort dropdown */\n sortOptions?: SortOption[];\n /** Filter options for the filter dropdown */\n filterOptions?: FilterOption[];\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 */\n onFavorite?: (item: ListItem) => void;\n /** Callback when item is clicked */\n onItemClick?: (item: ListItem) => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Default Data\n// ============================================\n\nconst defaultItems: ListItem[] = [\n {\n id: \"1\",\n imageUrl: \"https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&h=600&fit=crop\",\n title: \"Waikiki Beach Apartment\",\n price: \"$140\",\n priceUnit: \"/night\",\n rating: 5,\n reviewCount: 210,\n location: \"Honolulu, HI\",\n description: \"Stylish apartment near Waikiki beach that offers the perfect blend of comfort and convenience for your Hawaiian escape\",\n labels: [\n { id: \"1\", icon: \"heart\", label: \"Popular\" },\n { id: \"2\", icon: \"users\", label: \"Perfect for families\" },\n { id: \"3\", icon: \"zap\", label: \"Instant booking\" },\n ],\n tag: \"Popular\",\n isFavorite: false,\n },\n {\n id: \"2\",\n imageUrl: \"https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&h=600&fit=crop\",\n title: \"Central Danish Apartment\",\n price: \"$180\",\n priceUnit: \"/night\",\n rating: 5,\n reviewCount: 98,\n location: \"Copenhagen, Denmark\",\n description: \"A centrally located haven nestled in the heart of the city of Copenhagen, offering unparalleled convenience and accessibility\",\n labels: [\n { id: \"1\", icon: \"map-pin\", label: \"Central\" },\n { id: \"2\", icon: \"briefcase\", label: \"Business travelers\" },\n { id: \"3\", icon: \"zap\", label: \"Instant booking\" },\n ],\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: \"instant\", label: \"Instant Booking\" },\n { id: \"popular\", label: \"Popular\" },\n { id: \"family\", label: \"Family Friendly\" },\n];\n\n// ============================================\n// Sub-components\n// ============================================\n\nfunction StarRating({ rating, reviewCount }: { rating: number; reviewCount: 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 <span\n style={{\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xs-size)\",\n fontWeight: \"var(--typo-body-xs-weight)\",\n lineHeight: \"var(--typo-body-xs-line-height)\",\n color: \"var(--canvas-text-muted)\",\n paddingLeft: \"var(--spacing-sm)\",\n }}\n >\n {reviewCount}\n </span>\n </div>\n );\n}\n\nfunction IconLabel({ icon, label }: { icon: IconLabelConfig[\"icon\"]; label: string }) {\n const iconMap = {\n heart: Heart,\n users: Users,\n zap: Zap,\n \"map-pin\": MapPin,\n briefcase: Briefcase,\n };\n const IconComponent = iconMap[icon];\n\n return (\n <div\n className=\"flex items-center\"\n style={{ gap: \"var(--spacing-sm)\" }}\n >\n <IconComponent\n className=\"size-5\"\n style={{ color: \"var(--canvas-text)\" }}\n />\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {label}\n </span>\n </div>\n );\n}\n\ninterface ListItemCardProps {\n item: ListItem;\n onFavorite?: (item: ListItem) => void;\n onClick?: (item: ListItem) => void;\n}\n\nfunction ListItemCard({ item, onFavorite, onClick }: ListItemCardProps) {\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 md:flex-row w-full cursor-pointer\"\n style={{\n gap: \"var(--spacing-3xl)\",\n paddingTop: \"var(--spacing-3xl)\",\n paddingBottom: \"var(--spacing-3xl)\",\n borderBottom: \"1px solid var(--canvas-border)\",\n }}\n onClick={() => onClick?.(item)}\n >\n {/* Image Section */}\n <div className=\"relative md:flex-1 aspect-[4/3] md:aspect-auto md:min-h-[200px]\">\n <img\n src={item.imageUrl}\n alt={item.title}\n className=\"w-full h-full object-cover\"\n style={{\n borderRadius: \"var(--radius-md)\",\n border: \"1px solid var(--canvas-border)\",\n }}\n />\n {/* Favorite Button */}\n <button\n onClick={handleFavorite}\n className=\"absolute flex items-center justify-center\"\n style={{\n top: \"var(--spacing-md)\",\n left: \"var(--spacing-md)\",\n width: \"32px\",\n height: \"32px\",\n borderRadius: \"var(--radius-full, 24px)\",\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 }}\n >\n <Heart\n className=\"size-5\"\n fill={isFavorite ? \"var(--canvas-primary)\" : \"transparent\"}\n stroke=\"var(--canvas-text)\"\n />\n </button>\n {/* Tag Badge */}\n {item.tag && (\n <div\n className=\"absolute flex items-center\"\n style={{\n top: \"var(--spacing-md)\",\n left: \"47px\",\n height: \"32px\",\n paddingLeft: \"var(--spacing-lg)\",\n paddingRight: \"var(--spacing-lg)\",\n borderRadius: \"var(--radius-xs)\",\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 Section */}\n <div\n className=\"flex flex-col md:flex-1\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Header Info */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n {/* Title and Price Row */}\n <div className=\"flex items-center justify-between w-full\">\n <h3\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {item.title}\n </h3>\n <div className=\"flex items-baseline shrink-0\">\n <span\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n fontWeight: 600,\n lineHeight: \"var(--typo-body-xl-line-height)\",\n color: \"var(--canvas-text)\",\n }}\n >\n {item.price}\n </span>\n {item.priceUnit && (\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {item.priceUnit}\n </span>\n )}\n </div>\n </div>\n\n {/* Rating */}\n <StarRating rating={item.rating} reviewCount={item.reviewCount} />\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 </div>\n\n {/* Description */}\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.description}\n </p>\n\n {/* Labels */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-md)\" }}\n >\n {item.labels.map((labelConfig) => (\n <IconLabel\n key={labelConfig.id}\n icon={labelConfig.icon}\n label={labelConfig.label}\n />\n ))}\n </div>\n </div>\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Large Image Labels List Block\n * \n * A property-style listing component with large images, ratings, prices,\n * and icon labels. Includes a header section with title, result count,\n * and sort/filter controls.\n * \n * @example\n * ```tsx\n * <LargeImageLabelsList\n * title=\"Properties\"\n * items={[\n * {\n * id: \"1\",\n * imageUrl: \"https://example.com/image.jpg\",\n * title: \"Beach Apartment\",\n * price: \"$140\",\n * priceUnit: \"/night\",\n * rating: 5,\n * reviewCount: 210,\n * location: \"Honolulu, HI\",\n * description: \"Beautiful beachfront property...\",\n * labels: [{ id: \"1\", icon: \"heart\", label: \"Popular\" }],\n * tag: \"Popular\",\n * }\n * ]}\n * onFavorite={(item) => console.log(\"Favorited:\", item.title)}\n * />\n * ```\n */\nexport function LargeImageLabelsList({\n title = \"Properties\",\n resultCount,\n resultCountText,\n items = defaultItems,\n sortOptions = defaultSortOptions,\n filterOptions = defaultFilterOptions,\n onSort,\n onFilter,\n onFavorite,\n onItemClick,\n className,\n}: LargeImageLabelsListProps) {\n const [sortValue, setSortValue] = useState<string>(\"\");\n const [filterValue, setFilterValue] = useState<string>(\"\");\n\n const displayResultCount = resultCount ?? items.length;\n const displayResultText = resultCountText ?? `${displayResultCount} results`;\n\n const handleSortChange = (value: string) => {\n setSortValue(value);\n onSort?.(value);\n };\n\n const handleFilterChange = (value: string) => {\n setFilterValue(value);\n onFilter?.(value);\n };\n\n return (\n <div \n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Header Section */}\n <div \n className=\"flex flex-wrap items-end w-full\"\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title and Count */}\n <div \n className=\"flex flex-col flex-1 min-w-[200px]\"\n style={{ gap: \"var(--spacing-xs)\" }}\n >\n <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {displayResultText}\n </p>\n </div>\n\n {/* Controls */}\n <div \n className=\"flex items-end justify-end shrink-0 gap-3 max-sm:w-full max-sm:flex-wrap\"\n >\n {/* Sort Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={sortValue || undefined} onValueChange={handleSortChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Sort\" />\n </SelectTrigger>\n <SelectContent>\n {sortOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n\n {/* Filter Dropdown */}\n <div className=\"w-[120px] max-sm:flex-1 max-sm:min-w-[120px]\">\n <Select value={filterValue || undefined} onValueChange={handleFilterChange}>\n <SelectTrigger inputSize=\"sm\">\n <SelectValue placeholder=\"Filter\" />\n </SelectTrigger>\n <SelectContent>\n {filterOptions.map((option) => (\n <SelectItem key={option.id} value={option.id}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n </div>\n </div>\n\n {/* List Section */}\n <div \n className=\"flex flex-col w-full\"\n style={{ borderTop: \"1px solid var(--canvas-border)\" }}\n >\n {items.map((item) => (\n <ListItemCard\n key={item.id}\n item={item}\n onFavorite={onFavorite}\n onClick={onItemClick}\n />\n ))}\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"path": "components/blocks/monthly-calendar-widget.tsx",
|
|
8
8
|
"type": "registry:block",
|
|
9
|
-
"content": "\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { ChevronLeft, ChevronRight, ArrowRight } from \"lucide-react\";\nimport {\n format,\n startOfMonth,\n endOfMonth,\n startOfWeek,\n endOfWeek,\n addDays,\n addMonths,\n subMonths,\n isSameMonth,\n isSameDay,\n isWithinInterval,\n isBefore,\n isAfter,\n} from \"date-fns\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PricedDate {\n date: Date;\n price: string;\n}\n\nexport interface DateRange {\n start: Date | null;\n end: Date | null;\n}\n\nexport interface MonthlyCalendarWidgetProps {\n /** Widget title */\n title?: string;\n /** Widget subtitle */\n subtitle?: string;\n /** Initial month to display (defaults to current month) */\n initialMonth?: Date;\n /** Currently selected date range */\n selectedRange?: DateRange;\n /** Array of dates that should be disabled/unavailable */\n disabledDates?: Date[];\n /** Array of dates with prices to display */\n pricedDates?: PricedDate[];\n /** Override for \"today\" (useful for demos) */\n todayDate?: Date;\n /** Callback when a date is selected */\n onDateSelect?: (date: Date) => void;\n /** Callback when the date range changes */\n onRangeChange?: (range: DateRange) => void;\n /** Callback when Confirm button is clicked */\n onConfirm?: () => void;\n /** Callback when Cancel button is clicked */\n onCancel?: () => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Helper Functions\n// ============================================\n\nfunction isDateDisabled(date: Date, disabledDates: Date[]): boolean {\n return disabledDates.some((d) => isSameDay(d, date));\n}\n\nfunction isDateInRange(date: Date, range: DateRange): boolean {\n if (!range.start || !range.end) return false;\n return isWithinInterval(date, { start: range.start, end: range.end });\n}\n\nfunction isRangeStart(date: Date, range: DateRange): boolean {\n return range.start ? isSameDay(date, range.start) : false;\n}\n\nfunction isRangeEnd(date: Date, range: DateRange): boolean {\n return range.end ? isSameDay(date, range.end) : false;\n}\n\nfunction getDatePrice(date: Date, pricedDates: PricedDate[]): string | null {\n const priced = pricedDates.find((p) => isSameDay(p.date, date));\n return priced ? priced.price : null;\n}\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface DateCellProps {\n date: Date;\n currentMonth: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onSelect: (date: Date) => void;\n}\n\nfunction DateCell({\n date,\n currentMonth,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onSelect,\n}: DateCellProps) {\n const isCurrentMonth = isSameMonth(date, currentMonth);\n const isToday = isSameDay(date, today);\n const isDisabled = isDateDisabled(date, disabledDates);\n const isInRange = isDateInRange(date, selectedRange);\n const isStart = isRangeStart(date, selectedRange);\n const isEnd = isRangeEnd(date, selectedRange);\n const isSelected = isStart || isEnd;\n const price = getDatePrice(date, pricedDates);\n\n // Determine if this is a past date (before today, should show as disabled)\n const isPast = isBefore(date, today) && !isSameDay(date, today);\n\n // Don't render dates from other months\n if (!isCurrentMonth) {\n return (\n <div className=\"flex justify-center items-center\">\n <div className=\"size-12\" />\n </div>\n );\n }\n\n const handleClick = () => {\n if (!isDisabled && !isPast) {\n onSelect(date);\n }\n };\n\n // Determine styling based on state\n let bgColor = \"transparent\";\n let textColor = \"var(--canvas-text-placeholder)\";\n let showStrikethrough = false;\n let priceTextColor = \"var(--canvas-text)\";\n\n if (isDisabled || isPast) {\n textColor = \"var(--canvas-border-disabled)\";\n showStrikethrough = true;\n } else if (isSelected) {\n bgColor = \"var(--canvas-primary)\";\n textColor = \"var(--canvas-primary-foreground)\";\n priceTextColor = \"var(--canvas-primary-foreground)\";\n } else if (isInRange) {\n bgColor = \"var(--canvas-surface-brand)\";\n textColor = \"var(--canvas-primary)\";\n }\n\n return (\n <div className=\"flex justify-center items-center\">\n <button\n type=\"button\"\n onClick={handleClick}\n disabled={isDisabled || isPast}\n className={cn(\n \"relative flex flex-col items-center justify-center rounded-full size-12 transition-colors\",\n !isDisabled && !isPast && \"hover:bg-[var(--canvas-surface)] cursor-pointer\",\n (isDisabled || isPast) && \"cursor-not-allowed\"\n )}\n style={{\n backgroundColor: bgColor,\n }}\n >\n {/* Today indicator */}\n {isToday && !isSelected && (\n <div\n className=\"absolute top-1.5 rounded-full\"\n style={{\n width: \"var(--spacing-sm)\",\n height: \"var(--spacing-sm)\",\n backgroundColor: \"var(--canvas-primary)\",\n }}\n />\n )}\n\n {/* Date number */}\n <span\n className={cn(\n \"font-semibold leading-6\",\n showStrikethrough && \"line-through\"\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n color: textColor,\n }}\n >\n {format(date, \"d\")}\n </span>\n\n {/* Price label */}\n {price && !isDisabled && !isPast && (\n <span\n className=\"absolute text-[6px] font-normal\"\n style={{\n bottom: \"4px\",\n color: priceTextColor,\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n }}\n >\n {price}\n </span>\n )}\n </button>\n </div>\n );\n}\n\ninterface MonthCalendarProps {\n month: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onDateSelect: (date: Date) => void;\n onPrevMonth?: () => void;\n onNextMonth?: () => void;\n showPrevArrow?: boolean;\n showNextArrow?: boolean;\n}\n\nfunction MonthCalendar({\n month,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onDateSelect,\n onPrevMonth,\n onNextMonth,\n showPrevArrow = false,\n showNextArrow = false,\n}: MonthCalendarProps) {\n const dayHeaders = [\"SUN\", \"MON\", \"TUE\", \"WED\", \"THU\", \"FRI\", \"SAT\"];\n\n // Generate calendar grid\n const monthStart = startOfMonth(month);\n const monthEnd = endOfMonth(month);\n const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });\n const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });\n\n const weeks: Date[][] = [];\n let currentDate = calendarStart;\n\n while (currentDate <= calendarEnd) {\n const week: Date[] = [];\n for (let i = 0; i < 7; i++) {\n week.push(currentDate);\n currentDate = addDays(currentDate, 1);\n }\n weeks.push(week);\n }\n\n return (\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-lg)\", minWidth: \"336px\" }}\n >\n {/* Month Header */}\n <div\n className=\"flex items-center justify-center\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {showPrevArrow && (\n <button\n type=\"button\"\n onClick={onPrevMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronLeft\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n <span\n className=\"flex-1 text-center font-medium\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n lineHeight: \"30px\",\n color: \"var(--canvas-text)\",\n }}\n >\n {format(month, \"MMMM\")}\n </span>\n {showNextArrow && (\n <button\n type=\"button\"\n onClick={onNextMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronRight\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n </div>\n\n {/* Day Headers */}\n <div\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {dayHeaders.map((day) => (\n <div\n key={day}\n className=\"text-center\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"20px\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {day}\n </div>\n ))}\n </div>\n\n {/* Week Rows */}\n {weeks.map((week, weekIndex) => (\n <div\n key={weekIndex}\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {week.map((date, dateIndex) => (\n <DateCell\n key={dateIndex}\n date={date}\n currentMonth={month}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onSelect={onDateSelect}\n />\n ))}\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Monthly Calendar Widget Block\n *\n * A dual-month calendar widget for date range selection with support for\n * disabled dates, price labels, and today indicator. Commonly used for\n * booking and scheduling interfaces.\n *\n * @example\n * ```tsx\n * <MonthlyCalendarWidget\n * title=\"Browse availability\"\n * subtitle=\"Book your stay\"\n * onRangeChange={(range) => console.log(range)}\n * onConfirm={() => console.log(\"Confirmed\")}\n * />\n * ```\n */\nexport function MonthlyCalendarWidget({\n title = \"Browse availability\",\n subtitle = \"Book your stay\",\n initialMonth,\n selectedRange: controlledRange,\n disabledDates = [],\n pricedDates = [],\n todayDate,\n onDateSelect,\n onRangeChange,\n onConfirm,\n onCancel,\n className,\n}: MonthlyCalendarWidgetProps) {\n const today = todayDate || new Date();\n const [currentMonth, setCurrentMonth] = useState<Date>(\n initialMonth || startOfMonth(today)\n );\n const [internalRange, setInternalRange] = useState<DateRange>({\n start: null,\n end: null,\n });\n\n const selectedRange = controlledRange ?? internalRange;\n const nextMonth = addMonths(currentMonth, 1);\n\n const handleDateSelect = (date: Date) => {\n onDateSelect?.(date);\n\n let newRange: DateRange;\n\n if (!selectedRange.start || (selectedRange.start && selectedRange.end)) {\n // Start new selection\n newRange = { start: date, end: null };\n } else {\n // Complete the selection\n if (isBefore(date, selectedRange.start)) {\n newRange = { start: date, end: selectedRange.start };\n } else {\n newRange = { start: selectedRange.start, end: date };\n }\n }\n\n if (!controlledRange) {\n setInternalRange(newRange);\n }\n onRangeChange?.(newRange);\n };\n\n const handlePrevMonth = () => {\n setCurrentMonth((prev) => subMonths(prev, 1));\n };\n\n const handleNextMonth = () => {\n setCurrentMonth((prev) => addMonths(prev, 1));\n };\n\n const formatInputDate = (date: Date | null): string => {\n if (!date) return \"\";\n return format(date, \"MMM d, yyyy\");\n };\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title Section */}\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 <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\n\n {/* Calendar Content */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Dual Month Grid */}\n <div className=\"flex items-start justify-between w-full gap-4 overflow-x-auto\">\n <MonthCalendar\n month={currentMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onPrevMonth={handlePrevMonth}\n showPrevArrow={true}\n showNextArrow={false}\n />\n <MonthCalendar\n month={nextMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onNextMonth={handleNextMonth}\n showPrevArrow={false}\n showNextArrow={true}\n />\n </div>\n\n {/* Footer Section */}\n <div className=\"flex items-start justify-between w-full\">\n {/* Date Inputs */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-md)\", width: \"286px\" }}\n >\n {/* Start Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.start\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.start\n ? formatInputDate(selectedRange.start)\n : \"Start date\"}\n </span>\n </div>\n </div>\n\n {/* Arrow */}\n <ArrowRight\n className=\"size-5 shrink-0\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n\n {/* End Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.end\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.end\n ? formatInputDate(selectedRange.end)\n : \"End date\"}\n </span>\n </div>\n </div>\n </div>\n\n {/* Action Buttons */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"outline\" size=\"lg\" onClick={onCancel}>\n Cancel\n </Button>\n <Button variant=\"primary\" size=\"lg\" onClick={onConfirm}>\n Confirm\n </Button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
9
|
+
"content": "\"use client\";\n\nimport { useState, useMemo } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport { Button } from \"../ui/button\";\nimport { ChevronLeft, ChevronRight, ArrowRight } from \"lucide-react\";\nimport {\n format,\n startOfMonth,\n endOfMonth,\n startOfWeek,\n endOfWeek,\n addDays,\n addMonths,\n subMonths,\n isSameMonth,\n isSameDay,\n isWithinInterval,\n isBefore,\n isAfter,\n} from \"date-fns\";\n\n// ============================================\n// Types\n// ============================================\n\nexport interface PricedDate {\n date: Date;\n price: string;\n}\n\nexport interface DateRange {\n start: Date | null;\n end: Date | null;\n}\n\nexport interface MonthlyCalendarWidgetProps {\n /** Widget title */\n title?: string;\n /** Widget subtitle */\n subtitle?: string;\n /** Initial month to display (defaults to current month) */\n initialMonth?: Date;\n /** Currently selected date range */\n selectedRange?: DateRange;\n /** Array of dates that should be disabled/unavailable */\n disabledDates?: Date[];\n /** Array of dates with prices to display */\n pricedDates?: PricedDate[];\n /** Override for \"today\" (useful for demos) */\n todayDate?: Date;\n /** Callback when a date is selected */\n onDateSelect?: (date: Date) => void;\n /** Callback when the date range changes */\n onRangeChange?: (range: DateRange) => void;\n /** Callback when Confirm button is clicked */\n onConfirm?: () => void;\n /** Callback when Cancel button is clicked */\n onCancel?: () => void;\n /** Additional class names */\n className?: string;\n}\n\n// ============================================\n// Helper Functions\n// ============================================\n\nfunction isDateDisabled(date: Date, disabledDates: Date[]): boolean {\n return disabledDates.some((d) => isSameDay(d, date));\n}\n\nfunction isDateInRange(date: Date, range: DateRange): boolean {\n if (!range.start || !range.end) return false;\n return isWithinInterval(date, { start: range.start, end: range.end });\n}\n\nfunction isRangeStart(date: Date, range: DateRange): boolean {\n return range.start ? isSameDay(date, range.start) : false;\n}\n\nfunction isRangeEnd(date: Date, range: DateRange): boolean {\n return range.end ? isSameDay(date, range.end) : false;\n}\n\nfunction getDatePrice(date: Date, pricedDates: PricedDate[]): string | null {\n const priced = pricedDates.find((p) => isSameDay(p.date, date));\n return priced ? priced.price : null;\n}\n\n// ============================================\n// Sub-components\n// ============================================\n\ninterface DateCellProps {\n date: Date;\n currentMonth: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onSelect: (date: Date) => void;\n}\n\nfunction DateCell({\n date,\n currentMonth,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onSelect,\n}: DateCellProps) {\n const isCurrentMonth = isSameMonth(date, currentMonth);\n const isToday = isSameDay(date, today);\n const isDisabled = isDateDisabled(date, disabledDates);\n const isInRange = isDateInRange(date, selectedRange);\n const isStart = isRangeStart(date, selectedRange);\n const isEnd = isRangeEnd(date, selectedRange);\n const isSelected = isStart || isEnd;\n const price = getDatePrice(date, pricedDates);\n\n // Determine if this is a past date (before today, should show as disabled)\n const isPast = isBefore(date, today) && !isSameDay(date, today);\n\n // Don't render dates from other months\n if (!isCurrentMonth) {\n return (\n <div className=\"flex justify-center items-center\">\n <div className=\"size-12\" />\n </div>\n );\n }\n\n const handleClick = () => {\n if (!isDisabled && !isPast) {\n onSelect(date);\n }\n };\n\n // Determine styling based on state\n let bgColor = \"transparent\";\n let textColor = \"var(--canvas-text-placeholder)\";\n let showStrikethrough = false;\n let priceTextColor = \"var(--canvas-text)\";\n\n if (isDisabled || isPast) {\n textColor = \"var(--canvas-border-disabled)\";\n showStrikethrough = true;\n } else if (isSelected) {\n bgColor = \"var(--canvas-primary)\";\n textColor = \"var(--canvas-primary-foreground)\";\n priceTextColor = \"var(--canvas-primary-foreground)\";\n } else if (isInRange) {\n bgColor = \"var(--canvas-surface-brand)\";\n textColor = \"var(--canvas-primary)\";\n }\n\n return (\n <div className=\"flex justify-center items-center\">\n <button\n type=\"button\"\n onClick={handleClick}\n disabled={isDisabled || isPast}\n className={cn(\n \"relative flex flex-col items-center justify-center rounded-full size-12 transition-colors\",\n !isDisabled && !isPast && \"hover:bg-[var(--canvas-surface)] cursor-pointer\",\n (isDisabled || isPast) && \"cursor-not-allowed\"\n )}\n style={{\n backgroundColor: bgColor,\n }}\n >\n {/* Today indicator */}\n {isToday && !isSelected && (\n <div\n className=\"absolute top-1.5 rounded-full\"\n style={{\n width: \"var(--spacing-sm)\",\n height: \"var(--spacing-sm)\",\n backgroundColor: \"var(--canvas-primary)\",\n }}\n />\n )}\n\n {/* Date number */}\n <span\n className={cn(\n \"font-semibold leading-6\",\n showStrikethrough && \"line-through\"\n )}\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n color: textColor,\n }}\n >\n {format(date, \"d\")}\n </span>\n\n {/* Price label */}\n {price && !isDisabled && !isPast && (\n <span\n className=\"absolute text-[6px] font-normal\"\n style={{\n bottom: \"4px\",\n color: priceTextColor,\n fontFamily: \"var(--typo-body-xs-font, var(--typo-global-font))\",\n }}\n >\n {price}\n </span>\n )}\n </button>\n </div>\n );\n}\n\ninterface MonthCalendarProps {\n month: Date;\n today: Date;\n selectedRange: DateRange;\n disabledDates: Date[];\n pricedDates: PricedDate[];\n onDateSelect: (date: Date) => void;\n onPrevMonth?: () => void;\n onNextMonth?: () => void;\n showPrevArrow?: boolean;\n showNextArrow?: boolean;\n}\n\nfunction MonthCalendar({\n month,\n today,\n selectedRange,\n disabledDates,\n pricedDates,\n onDateSelect,\n onPrevMonth,\n onNextMonth,\n showPrevArrow = false,\n showNextArrow = false,\n}: MonthCalendarProps) {\n const dayHeaders = [\"SUN\", \"MON\", \"TUE\", \"WED\", \"THU\", \"FRI\", \"SAT\"];\n\n // Generate calendar grid\n const monthStart = startOfMonth(month);\n const monthEnd = endOfMonth(month);\n const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });\n const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });\n\n const weeks: Date[][] = [];\n let currentDate = calendarStart;\n\n while (currentDate <= calendarEnd) {\n const week: Date[] = [];\n for (let i = 0; i < 7; i++) {\n week.push(currentDate);\n currentDate = addDays(currentDate, 1);\n }\n weeks.push(week);\n }\n\n return (\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-lg)\", minWidth: \"336px\" }}\n >\n {/* Month Header */}\n <div\n className=\"flex items-center justify-center\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {showPrevArrow && (\n <button\n type=\"button\"\n onClick={onPrevMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronLeft\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n <span\n className=\"flex-1 text-center font-medium\"\n style={{\n fontFamily: \"var(--typo-body-xl-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-xl-size)\",\n lineHeight: \"30px\",\n color: \"var(--canvas-text)\",\n }}\n >\n {format(month, \"MMMM\")}\n </span>\n {showNextArrow && (\n <button\n type=\"button\"\n onClick={onNextMonth}\n className=\"size-8 flex items-center justify-center hover:bg-[var(--canvas-surface)] rounded-md transition-colors shrink-0\"\n >\n <ChevronRight\n className=\"size-4\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n </button>\n )}\n </div>\n\n {/* Day Headers */}\n <div\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n }}\n >\n {dayHeaders.map((day) => (\n <div\n key={day}\n className=\"text-center\"\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: 500,\n lineHeight: \"20px\",\n color: \"var(--canvas-text-muted)\",\n }}\n >\n {day}\n </div>\n ))}\n </div>\n\n {/* Week Rows */}\n {weeks.map((week, weekIndex) => (\n <div\n key={weekIndex}\n className=\"grid grid-cols-7\"\n style={{\n paddingLeft: showPrevArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingRight: showNextArrow ? \"0\" : \"var(--spacing-4xl)\",\n paddingTop: \"var(--spacing-md)\",\n paddingBottom: \"var(--spacing-md)\",\n }}\n >\n {week.map((date, dateIndex) => (\n <DateCell\n key={dateIndex}\n date={date}\n currentMonth={month}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onSelect={onDateSelect}\n />\n ))}\n </div>\n ))}\n </div>\n );\n}\n\n// ============================================\n// Main Component\n// ============================================\n\n/**\n * Canvas Design System - Monthly Calendar Widget Block\n *\n * A dual-month calendar widget for date range selection with support for\n * disabled dates, price labels, and today indicator. Commonly used for\n * booking and scheduling interfaces.\n *\n * @example\n * ```tsx\n * <MonthlyCalendarWidget\n * title=\"Browse availability\"\n * subtitle=\"Book your stay\"\n * onRangeChange={(range) => console.log(range)}\n * onConfirm={() => console.log(\"Confirmed\")}\n * />\n * ```\n */\nexport function MonthlyCalendarWidget({\n title = \"Browse availability\",\n subtitle = \"Book your stay\",\n initialMonth,\n selectedRange: controlledRange,\n disabledDates = [],\n pricedDates = [],\n todayDate,\n onDateSelect,\n onRangeChange,\n onConfirm,\n onCancel,\n className,\n}: MonthlyCalendarWidgetProps) {\n const today = todayDate || new Date();\n const [currentMonth, setCurrentMonth] = useState<Date>(\n initialMonth || startOfMonth(today)\n );\n const [internalRange, setInternalRange] = useState<DateRange>({\n start: null,\n end: null,\n });\n\n const selectedRange = controlledRange ?? internalRange;\n const nextMonth = addMonths(currentMonth, 1);\n\n const handleDateSelect = (date: Date) => {\n onDateSelect?.(date);\n\n let newRange: DateRange;\n\n if (!selectedRange.start || (selectedRange.start && selectedRange.end)) {\n // Start new selection\n newRange = { start: date, end: null };\n } else {\n // Complete the selection\n if (isBefore(date, selectedRange.start)) {\n newRange = { start: date, end: selectedRange.start };\n } else {\n newRange = { start: selectedRange.start, end: date };\n }\n }\n\n if (!controlledRange) {\n setInternalRange(newRange);\n }\n onRangeChange?.(newRange);\n };\n\n const handlePrevMonth = () => {\n setCurrentMonth((prev) => subMonths(prev, 1));\n };\n\n const handleNextMonth = () => {\n setCurrentMonth((prev) => addMonths(prev, 1));\n };\n\n const formatInputDate = (date: Date | null): string => {\n if (!date) return \"\";\n return format(date, \"MMM d, yyyy\");\n };\n\n return (\n <div\n className={cn(\"flex flex-col w-full\", className)}\n style={{ gap: \"var(--spacing-xl)\" }}\n >\n {/* Title Section */}\n <div\n className=\"flex flex-wrap items-end 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 <h2\n style={{\n fontFamily: \"var(--typo-h6-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-h6-size)\",\n fontWeight: \"var(--typo-h6-weight)\",\n letterSpacing: \"var(--typo-h6-spacing)\",\n lineHeight: \"var(--typo-h6-line-height)\",\n color: \"var(--canvas-text)\",\n margin: 0,\n }}\n >\n {title}\n </h2>\n <p\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"var(--typo-body-s-line-height)\",\n color: \"var(--canvas-text-muted)\",\n margin: 0,\n }}\n >\n {subtitle}\n </p>\n </div>\n </div>\n\n {/* Calendar Content */}\n <div\n className=\"flex flex-col w-full\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n {/* Dual Month Grid */}\n <div className=\"flex items-start justify-between w-full gap-4 overflow-x-auto\">\n <MonthCalendar\n month={currentMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onPrevMonth={handlePrevMonth}\n showPrevArrow={true}\n showNextArrow={false}\n />\n <MonthCalendar\n month={nextMonth}\n today={today}\n selectedRange={selectedRange}\n disabledDates={disabledDates}\n pricedDates={pricedDates}\n onDateSelect={handleDateSelect}\n onNextMonth={handleNextMonth}\n showPrevArrow={false}\n showNextArrow={true}\n />\n </div>\n\n {/* Footer Section */}\n <div className=\"flex items-start justify-between w-full\">\n {/* Date Inputs */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-md)\", width: \"286px\" }}\n >\n {/* Start Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.start\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.start\n ? formatInputDate(selectedRange.start)\n : \"Start date\"}\n </span>\n </div>\n </div>\n\n {/* Arrow */}\n <ArrowRight\n className=\"size-5 shrink-0\"\n style={{ color: \"var(--canvas-text-muted)\" }}\n />\n\n {/* End Date Input */}\n <div\n className=\"flex flex-col\"\n style={{ gap: \"var(--spacing-xs)\", width: \"128px\" }}\n >\n <div\n className=\"flex items-center h-11 rounded\"\n style={{\n backgroundColor: \"var(--canvas-background)\",\n border: \"1px solid var(--canvas-border)\",\n borderRadius: \"var(--radius-xs)\",\n paddingLeft: \"var(--spacing-xl)\",\n paddingRight: \"var(--spacing-xl)\",\n boxShadow: \"0px 1px 2px 0px rgba(0,0,0,0.02)\",\n }}\n >\n <span\n style={{\n fontFamily: \"var(--typo-body-s-font, var(--typo-global-font))\",\n fontSize: \"var(--typo-body-s-size)\",\n fontWeight: \"var(--typo-body-s-weight)\",\n lineHeight: \"24px\",\n color: selectedRange.end\n ? \"var(--canvas-text)\"\n : \"var(--canvas-text-placeholder)\",\n }}\n >\n {selectedRange.end\n ? formatInputDate(selectedRange.end)\n : \"End date\"}\n </span>\n </div>\n </div>\n </div>\n\n {/* Action Buttons */}\n <div\n className=\"flex items-center justify-center\"\n style={{ gap: \"var(--spacing-3xl)\" }}\n >\n <Button variant=\"outline\" size=\"lg\" onClick={onCancel}>\n Cancel\n </Button>\n <Button variant=\"primary\" size=\"lg\" onClick={onConfirm}>\n Confirm\n </Button>\n </div>\n </div>\n </div>\n </div>\n );\n}\n"
|
|
10
10
|
}
|
|
11
11
|
],
|
|
12
12
|
"dependencies": [
|