\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n\nfunction DefaultImage({\n\tsrc,\n\talt,\n\tclassName,\n\twidth,\n\theight,\n}: {\n\tsrc: string;\n\talt: string;\n\tclassName?: string;\n\twidth?: number;\n\theight?: number;\n}) {\n\treturn (\n\t\t\n\t);\n}\n",
"target": "src/components/btst/blog/client/components/forms/image-field.tsx"
},
{
@@ -70,13 +70,13 @@
{
"path": "btst/blog/client/components/forms/markdown-editor-with-overrides.tsx",
"type": "registry:component",
- "content": "\"use client\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t\"uploadImage\" | \"placeholder\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst { uploadImage, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(\"blog\", {\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\n\treturn (\n\t\t\n\t);\n}\n",
+ "content": "\"use client\";\nimport { useCallback, useRef } from \"react\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { MarkdownEditor, type MarkdownEditorProps } from \"./markdown-editor\";\n\ntype MarkdownEditorWithOverridesProps = Omit<\n\tMarkdownEditorProps,\n\t| \"uploadImage\"\n\t| \"placeholder\"\n\t| \"insertImageRef\"\n\t| \"openMediaPickerForImageBlock\"\n>;\n\nexport function MarkdownEditorWithOverrides(\n\tprops: MarkdownEditorWithOverridesProps,\n) {\n\tconst {\n\t\tuploadImage,\n\t\timagePicker: ImagePickerTrigger,\n\t\tlocalization,\n\t} = usePluginOverrides>(\n\t\t\"blog\",\n\t\t{ localization: BLOG_LOCALIZATION },\n\t);\n\n\tconst insertImageRef = useRef<((url: string) => void) | null>(null);\n\t// Holds the Crepe-image-block `setUrl` callback while the picker is open.\n\tconst pendingInsertUrlRef = useRef<((url: string) => void) | null>(null);\n\t// Ref to the trigger wrapper so we can programmatically click the picker button.\n\tconst triggerContainerRef = useRef(null);\n\n\t// Single onSelect handler for ImagePickerTrigger.\n\t// URLs returned by the media plugin are already percent-encoded at the\n\t// source (storage adapter), so no additional encoding is applied here.\n\tconst handleSelect = useCallback((url: string) => {\n\t\tif (pendingInsertUrlRef.current) {\n\t\t\t// Crepe image block flow: set the URL into the block's link input.\n\t\t\tpendingInsertUrlRef.current(url);\n\t\t\tpendingInsertUrlRef.current = null;\n\t\t} else {\n\t\t\t// Normal flow: insert image at end of markdown content.\n\t\t\tinsertImageRef.current?.(url);\n\t\t}\n\t}, []);\n\n\t// Called by MarkdownEditor's click interceptor when the user clicks a Crepe\n\t// image-block upload placeholder.\n\tconst openMediaPickerForImageBlock = useCallback(\n\t\t(setUrl: (url: string) => void) => {\n\t\t\tpendingInsertUrlRef.current = setUrl;\n\t\t\t// Programmatically click the visible picker trigger button.\n\t\t\tconst btn = triggerContainerRef.current?.querySelector(\n\t\t\t\t'[data-testid=\"open-media-picker\"]',\n\t\t\t) as HTMLButtonElement | null;\n\t\t\tbtn?.click();\n\t\t},\n\t\t[],\n\t);\n\n\treturn (\n\t\t
\n\t * ) : (\n\t * Browse media} accept={[\"image/*\"]}\n\t * onSelect={(assets) => onChange(assets[0].url)} />\n\t * )\n\t * ```\n\t */\n\timageInputField?: ComponentType;\n\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered adjacent to the Markdown editor and allows\n\t * users to browse and select previously uploaded assets.\n\t * Receives `onSelect(url)` — insert the chosen URL into the editor.\n\t *\n\t * @example\n\t * ```tsx\n\t * imagePicker: ({ onSelect }) => (\n\t * Browse media}\n\t * accept={[\"image/*\"]}\n\t * onSelect={(assets) => onSelect(assets[0].url)}\n\t * />\n\t * )\n\t * ```\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n\t/**\n\t * Localization object for the blog plugin\n\t */\n\tlocalization?: BlogLocalization;\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'posts', 'post', 'newPost')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the posts list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforePostsPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before a single post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug\n\t * @param context - Route context\n\t */\n\tonBeforePostPageRendered?: (slug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the new post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeNewPostPageRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the edit post page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param slug - The post slug being edited\n\t * @param context - Route context\n\t */\n\tonBeforeEditPostPageRendered?: (\n\t\tslug: string,\n\t\tcontext: RouteContext,\n\t) => boolean;\n\n\t/**\n\t * Called before the drafts page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDraftsPageRendered?: (context: RouteContext) => boolean;\n\n\t// ============ Slot Overrides ============\n\n\t/**\n\t * Optional slot rendered below the blog post body.\n\t * Use this to inject a comment thread or any custom content without\n\t * coupling the blog plugin to the comments plugin.\n\t *\n\t * @example\n\t * ```tsx\n\t * blog: {\n\t * postBottomSlot: (post) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tpostBottomSlot?: (post: SerializedPost) => ReactNode;\n}\n",
"target": "src/components/btst/blog/client/overrides.ts"
},
{
diff --git a/packages/stack/registry/btst-cms.json b/packages/stack/registry/btst-cms.json
index ea31783d..eae95ea4 100644
--- a/packages/stack/registry/btst-cms.json
+++ b/packages/stack/registry/btst-cms.json
@@ -49,13 +49,13 @@
{
"path": "btst/cms/client/components/forms/content-form.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport { z } from \"zod\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport type {\n\tFieldConfig,\n\tAutoFormInputComponentProps,\n} from \"@/components/ui/auto-form/types\";\nimport { buildFieldConfigFromJsonSchema as buildFieldConfigBase } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentType, RelationConfig } from \"../../../types\";\nimport { slugify } from \"../../../utils\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { CMSFileUpload } from \"./file-upload\";\nimport { RelationField } from \"./relation-field\";\n\ninterface ContentFormProps {\n\tcontentType: SerializedContentType;\n\tinitialData?: Record;\n\tinitialSlug?: string;\n\tisEditing?: boolean;\n\tonSubmit: (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => Promise;\n\tonCancel?: () => void;\n}\n\n/**\n * Build field configuration for AutoForm with CMS-specific file upload handling.\n *\n * Uses the shared buildFieldConfigFromJsonSchema from auto-form/helpers as a base,\n * then adds special handling for \"file\" fieldType to inject CMSFileUpload component\n * ONLY if no custom component is provided via fieldComponents.\n *\n * @param jsonSchema - The JSON Schema from the content type (with fieldType embedded in properties)\n * @param uploadImage - The uploadImage function from overrides (for file fields)\n * @param fieldComponents - Custom field components from overrides\n */\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: RelationConfig;\n\t[key: string]: unknown;\n}\n\nfunction buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tuploadImage?: (file: File) => Promise,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n): FieldConfig> {\n\t// Get base config from shared utility (handles fieldType from JSON Schema)\n\tconst baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);\n\n\t// Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists\n\t// Custom fieldComponents take priority - don't override if user provided one\n\tconst properties = jsonSchema.properties as Record<\n\t\tstring,\n\t\tJsonSchemaProperty\n\t>;\n\n\tif (!properties) return baseConfig;\n\n\tfor (const [key, prop] of Object.entries(properties)) {\n\t\t// Handle \"file\" fieldType when there's NO custom component for \"file\"\n\t\tif (prop.fieldType === \"file\" && !fieldComponents?.[\"file\"]) {\n\t\t\t// Use CMSFileUpload as the default file component\n\t\t\tif (!uploadImage) {\n\t\t\t\t// Show a clear error message if uploadImage is not provided\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: () => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tFile upload requires an uploadImage function in CMS\n\t\t\t\t\t\t\toverrides.\n\t\t\t\t\t\t
\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\t\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Handle \"relation\" fieldType when there's NO custom component for \"relation\"\n\t\tif (\n\t\t\tprop.fieldType === \"relation\" &&\n\t\t\tprop.relation &&\n\t\t\t!fieldComponents?.[\"relation\"]\n\t\t) {\n\t\t\tconst relationConfig = prop.relation;\n\t\t\tbaseConfig[key] = {\n\t\t\t\t...baseConfig[key],\n\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\treturn baseConfig;\n}\n\n/**\n * Determine the first string field in the schema for slug auto-generation\n */\nfunction findSlugSourceField(\n\tjsonSchema: Record,\n): string | null {\n\tconst properties = jsonSchema.properties as Record;\n\tif (!properties) return null;\n\n\t// Look for common name fields first\n\tconst priorityFields = [\"name\", \"title\", \"heading\", \"label\"];\n\tfor (const field of priorityFields) {\n\t\tif (properties[field]?.type === \"string\") {\n\t\t\treturn field;\n\t\t}\n\t}\n\n\t// Fall back to first string field\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tif (value.type === \"string\") {\n\t\t\treturn key;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nexport function ContentForm({\n\tcontentType,\n\tinitialData = {},\n\tinitialSlug = \"\",\n\tisEditing = false,\n\tonSubmit,\n\tonCancel,\n}: ContentFormProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tuploadImage,\n\t\tfieldComponents,\n\t} = usePluginOverrides(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst [slug, setSlug] = useState(initialSlug);\n\tconst [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);\n\tconst [isSubmitting, setIsSubmitting] = useState(false);\n\tconst [formData, setFormData] =\n\t\tuseState>(initialData);\n\tconst [slugError, setSlugError] = useState(null);\n\tconst [submitError, setSubmitError] = useState(null);\n\n\t// Track if we've already synced prefill data to avoid overwriting user input\n\tconst hasSyncedPrefillRef = useRef(false);\n\n\t// Sync formData with initialData when it changes\n\t// This handles both:\n\t// 1. Editing mode: always sync when item data is loaded (isEditing=true)\n\t// 2. Create mode: only sync prefill data ONCE to avoid overwriting user input\n\t// useState only uses the initial value on mount, so we need this effect for updates\n\tuseEffect(() => {\n\t\tconst hasData = Object.keys(initialData).length > 0;\n\t\t// In edit mode, always sync (user is loading existing data)\n\t\t// In create mode, only sync prefill data once\n\t\tconst shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);\n\n\t\tif (shouldSync) {\n\t\t\tsetFormData(initialData);\n\t\t\tif (!isEditing) {\n\t\t\t\thasSyncedPrefillRef.current = true;\n\t\t\t}\n\t\t}\n\t}, [initialData, isEditing]);\n\n\t// Also sync slug when initialSlug changes\n\tuseEffect(() => {\n\t\tif (isEditing && initialSlug) {\n\t\t\tsetSlug(initialSlug);\n\t\t}\n\t}, [initialSlug, isEditing]);\n\n\t// Parse JSON Schema (now includes fieldType embedded in properties)\n\tconst jsonSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn JSON.parse(contentType.jsonSchema) as Record;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}, [contentType.jsonSchema]);\n\n\t// Convert JSON Schema to Zod schema using formSchemaToZod utility\n\t// This properly handles date fields (format: \"date-time\") and min/max date constraints\n\tconst zodSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn formSchemaToZod(jsonSchema);\n\t\t} catch {\n\t\t\treturn z.object({});\n\t\t}\n\t}, [jsonSchema]);\n\n\t// Build field config for AutoForm (fieldType is now embedded in jsonSchema)\n\tconst fieldConfig = useMemo(\n\t\t() =>\n\t\t\tbuildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents),\n\t\t[jsonSchema, uploadImage, fieldComponents],\n\t);\n\n\t// Find the field to use for slug auto-generation\n\tconst slugSourceField = useMemo(\n\t\t() => findSlugSourceField(jsonSchema),\n\t\t[jsonSchema],\n\t);\n\n\t// Handle form value changes for slug auto-generation\n\tconst handleValuesChange = (values: Record) => {\n\t\tsetFormData(values);\n\n\t\t// Auto-generate slug from source field if not manually edited\n\t\tif (!isEditing && !slugManuallyEdited && slugSourceField) {\n\t\t\tconst sourceValue = values[slugSourceField];\n\t\t\tif (typeof sourceValue === \"string\" && sourceValue.trim()) {\n\t\t\t\tsetSlug(slugify(sourceValue));\n\t\t\t}\n\t\t}\n\t};\n\n\t// Handle form submission\n\tconst handleSubmit = async (data: Record) => {\n\t\tsetSlugError(null);\n\t\tsetSubmitError(null);\n\n\t\tif (!slug.trim()) {\n\t\t\tsetSlugError(\"Slug is required\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsSubmitting(true);\n\t\ttry {\n\t\t\tawait onSubmit({ slug, data });\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : localization.CMS_TOAST_ERROR;\n\t\t\tsetSubmitError(message);\n\t\t} finally {\n\t\t\tsetIsSubmitting(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t)}\n\n\t\t\t{/* Dynamic form from Zod schema */}\n\t\t\t{/* Uses SteppedAutoForm which automatically handles both single-step and multi-step content types */}\n\t\t\t}\n\t\t\t\tvalues={formData as any}\n\t\t\t\tonValuesChange={handleValuesChange as any}\n\t\t\t\tonSubmit={handleSubmit as any}\n\t\t\t\tfieldConfig={fieldConfig as any}\n\t\t\t\tisSubmitting={isSubmitting}\n\t\t\t\tsubmitButtonText={\n\t\t\t\t\tisSubmitting\n\t\t\t\t\t\t? localization.CMS_STATUS_SAVING\n\t\t\t\t\t\t: localization.CMS_BUTTON_SAVE\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t
\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { useState, useMemo, useEffect, useRef } from \"react\";\nimport { z } from \"zod\";\nimport { SteppedAutoForm } from \"@/components/ui/auto-form/stepped-auto-form\";\nimport type {\n\tFieldConfig,\n\tAutoFormInputComponentProps,\n} from \"@/components/ui/auto-form/types\";\nimport { buildFieldConfigFromJsonSchema as buildFieldConfigBase } from \"@/components/ui/auto-form/helpers\";\nimport { formSchemaToZod } from \"@/lib/schema-converter\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { CMSPluginOverrides } from \"../../overrides\";\nimport type { SerializedContentType, RelationConfig } from \"../../../types\";\nimport { slugify } from \"../../../utils\";\nimport { CMS_LOCALIZATION } from \"../../localization\";\nimport { CMSFileUpload } from \"./file-upload\";\nimport { RelationField } from \"./relation-field\";\n\ninterface ContentFormProps {\n\tcontentType: SerializedContentType;\n\tinitialData?: Record;\n\tinitialSlug?: string;\n\tisEditing?: boolean;\n\tonSubmit: (data: {\n\t\tslug: string;\n\t\tdata: Record;\n\t}) => Promise;\n\tonCancel?: () => void;\n}\n\n/**\n * Build field configuration for AutoForm with CMS-specific file upload handling.\n *\n * Uses the shared buildFieldConfigFromJsonSchema from auto-form/helpers as a base,\n * then adds special handling for \"file\" fieldType to inject CMSFileUpload component\n * ONLY if no custom component is provided via fieldComponents.\n *\n * @param jsonSchema - The JSON Schema from the content type (with fieldType embedded in properties)\n * @param uploadImage - The uploadImage function from overrides (for file fields)\n * @param fieldComponents - Custom field components from overrides\n */\ninterface JsonSchemaProperty {\n\tfieldType?: string;\n\trelation?: RelationConfig;\n\t[key: string]: unknown;\n}\n\nfunction buildFieldConfigFromJsonSchema(\n\tjsonSchema: Record,\n\tuploadImage?: (file: File) => Promise,\n\tfieldComponents?: Record<\n\t\tstring,\n\t\tReact.ComponentType\n\t>,\n\timagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>,\n\timageInputField?: React.ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>,\n): FieldConfig> {\n\t// Get base config from shared utility (handles fieldType from JSON Schema)\n\tconst baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);\n\n\t// Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists\n\t// Custom fieldComponents take priority - don't override if user provided one\n\tconst properties = jsonSchema.properties as Record<\n\t\tstring,\n\t\tJsonSchemaProperty\n\t>;\n\n\tif (!properties) return baseConfig;\n\n\tfor (const [key, prop] of Object.entries(properties)) {\n\t\t// Handle \"file\" fieldType when there's NO custom component for \"file\"\n\t\tif (prop.fieldType === \"file\" && !fieldComponents?.[\"file\"]) {\n\t\t\t// Use CMSFileUpload as the default file component\n\t\t\tif (!uploadImage && !imageInputField) {\n\t\t\t\t// Show a clear error message if neither uploadImage nor imageInputField is provided\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: () => (\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tFile upload requires an uploadImage or{\" \"}\n\t\t\t\t\t\t\timageInputField function in CMS overrides.\n\t\t\t\t\t\t
\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbaseConfig[key] = {\n\t\t\t\t\t...baseConfig[key],\n\t\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\t Promise.resolve(\"\"))}\n\t\t\t\t\t\t\timageInputField={imageInputField}\n\t\t\t\t\t\t\timagePicker={imagePicker}\n\t\t\t\t\t\t/>\n\t\t\t\t\t),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Handle \"relation\" fieldType when there's NO custom component for \"relation\"\n\t\tif (\n\t\t\tprop.fieldType === \"relation\" &&\n\t\t\tprop.relation &&\n\t\t\t!fieldComponents?.[\"relation\"]\n\t\t) {\n\t\t\tconst relationConfig = prop.relation;\n\t\t\tbaseConfig[key] = {\n\t\t\t\t...baseConfig[key],\n\t\t\t\tfieldType: (props: AutoFormInputComponentProps) => (\n\t\t\t\t\t\n\t\t\t\t),\n\t\t\t};\n\t\t}\n\t}\n\n\treturn baseConfig;\n}\n\n/**\n * Determine the first string field in the schema for slug auto-generation\n */\nfunction findSlugSourceField(\n\tjsonSchema: Record,\n): string | null {\n\tconst properties = jsonSchema.properties as Record;\n\tif (!properties) return null;\n\n\t// Look for common name fields first\n\tconst priorityFields = [\"name\", \"title\", \"heading\", \"label\"];\n\tfor (const field of priorityFields) {\n\t\tif (properties[field]?.type === \"string\") {\n\t\t\treturn field;\n\t\t}\n\t}\n\n\t// Fall back to first string field\n\tfor (const [key, value] of Object.entries(properties)) {\n\t\tif (value.type === \"string\") {\n\t\t\treturn key;\n\t\t}\n\t}\n\n\treturn null;\n}\n\nexport function ContentForm({\n\tcontentType,\n\tinitialData = {},\n\tinitialSlug = \"\",\n\tisEditing = false,\n\tonSubmit,\n\tonCancel,\n}: ContentFormProps) {\n\tconst {\n\t\tlocalization: customLocalization,\n\t\tuploadImage,\n\t\timagePicker,\n\t\timageInputField,\n\t\tfieldComponents,\n\t} = usePluginOverrides(\"cms\");\n\tconst localization = { ...CMS_LOCALIZATION, ...customLocalization };\n\n\tconst [slug, setSlug] = useState(initialSlug);\n\tconst [slugManuallyEdited, setSlugManuallyEdited] = useState(isEditing);\n\tconst [isSubmitting, setIsSubmitting] = useState(false);\n\tconst [formData, setFormData] =\n\t\tuseState>(initialData);\n\tconst [slugError, setSlugError] = useState(null);\n\tconst [submitError, setSubmitError] = useState(null);\n\n\t// Track if we've already synced prefill data to avoid overwriting user input\n\tconst hasSyncedPrefillRef = useRef(false);\n\n\t// Sync formData with initialData when it changes\n\t// This handles both:\n\t// 1. Editing mode: always sync when item data is loaded (isEditing=true)\n\t// 2. Create mode: only sync prefill data ONCE to avoid overwriting user input\n\t// useState only uses the initial value on mount, so we need this effect for updates\n\tuseEffect(() => {\n\t\tconst hasData = Object.keys(initialData).length > 0;\n\t\t// In edit mode, always sync (user is loading existing data)\n\t\t// In create mode, only sync prefill data once\n\t\tconst shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);\n\n\t\tif (shouldSync) {\n\t\t\tsetFormData(initialData);\n\t\t\tif (!isEditing) {\n\t\t\t\thasSyncedPrefillRef.current = true;\n\t\t\t}\n\t\t}\n\t}, [initialData, isEditing]);\n\n\t// Also sync slug when initialSlug changes\n\tuseEffect(() => {\n\t\tif (isEditing && initialSlug) {\n\t\t\tsetSlug(initialSlug);\n\t\t}\n\t}, [initialSlug, isEditing]);\n\n\t// Parse JSON Schema (now includes fieldType embedded in properties)\n\tconst jsonSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn JSON.parse(contentType.jsonSchema) as Record;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}, [contentType.jsonSchema]);\n\n\t// Convert JSON Schema to Zod schema using formSchemaToZod utility\n\t// This properly handles date fields (format: \"date-time\") and min/max date constraints\n\tconst zodSchema = useMemo(() => {\n\t\ttry {\n\t\t\treturn formSchemaToZod(jsonSchema);\n\t\t} catch {\n\t\t\treturn z.object({});\n\t\t}\n\t}, [jsonSchema]);\n\n\t// Build field config for AutoForm (fieldType is now embedded in jsonSchema)\n\tconst fieldConfig = useMemo(\n\t\t() =>\n\t\t\tbuildFieldConfigFromJsonSchema(\n\t\t\t\tjsonSchema,\n\t\t\t\tuploadImage,\n\t\t\t\tfieldComponents,\n\t\t\t\timagePicker,\n\t\t\t\timageInputField,\n\t\t\t),\n\t\t[jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField],\n\t);\n\n\t// Find the field to use for slug auto-generation\n\tconst slugSourceField = useMemo(\n\t\t() => findSlugSourceField(jsonSchema),\n\t\t[jsonSchema],\n\t);\n\n\t// Handle form value changes for slug auto-generation\n\tconst handleValuesChange = (values: Record) => {\n\t\tsetFormData(values);\n\n\t\t// Auto-generate slug from source field if not manually edited\n\t\tif (!isEditing && !slugManuallyEdited && slugSourceField) {\n\t\t\tconst sourceValue = values[slugSourceField];\n\t\t\tif (typeof sourceValue === \"string\" && sourceValue.trim()) {\n\t\t\t\tsetSlug(slugify(sourceValue));\n\t\t\t}\n\t\t}\n\t};\n\n\t// Handle form submission\n\tconst handleSubmit = async (data: Record) => {\n\t\tsetSlugError(null);\n\t\tsetSubmitError(null);\n\n\t\tif (!slug.trim()) {\n\t\t\tsetSlugError(\"Slug is required\");\n\t\t\treturn;\n\t\t}\n\n\t\tsetIsSubmitting(true);\n\t\ttry {\n\t\t\tawait onSubmit({ slug, data });\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : localization.CMS_TOAST_ERROR;\n\t\t\tsetSubmitError(message);\n\t\t} finally {\n\t\t\tsetIsSubmitting(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t
\n\t\t\t)}\n\n\t\t\t{/* Dynamic form from Zod schema */}\n\t\t\t{/* Uses SteppedAutoForm which automatically handles both single-step and multi-step content types */}\n\t\t\t}\n\t\t\t\tvalues={formData as any}\n\t\t\t\tonValuesChange={handleValuesChange as any}\n\t\t\t\tonSubmit={handleSubmit as any}\n\t\t\t\tfieldConfig={fieldConfig as any}\n\t\t\t\tisSubmitting={isSubmitting}\n\t\t\t\tsubmitButtonText={\n\t\t\t\t\tisSubmitting\n\t\t\t\t\t\t? localization.CMS_STATUS_SAVING\n\t\t\t\t\t\t: localization.CMS_BUTTON_SAVE\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{onCancel && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\n\t\t
\n\t);\n}\n",
"target": "src/components/btst/cms/client/components/forms/content-form.tsx"
},
{
"path": "btst/cms/client/components/forms/file-upload.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { useState, useCallback, useEffect, type ChangeEvent } from \"react\";\nimport { toast } from \"sonner\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormItem,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Trash2, Loader2 } from \"lucide-react\";\nimport AutoFormLabel from \"@/components/ui/auto-form/common/label\";\nimport AutoFormTooltip from \"@/components/ui/auto-form/common/tooltip\";\n\n/**\n * Props for the CMSFileUpload component\n */\nexport interface CMSFileUploadProps extends AutoFormInputComponentProps {\n\t/**\n\t * Function to upload an image file and return the URL.\n\t * This is required - consumers must provide an upload implementation.\n\t */\n\tuploadImage: (file: File) => Promise;\n}\n\n/**\n * Default file upload component for CMS image fields.\n *\n * This component:\n * - Accepts image files via file input\n * - Uses the required uploadImage prop to upload and get a URL\n * - Shows a preview of the uploaded image\n * - Allows removing the uploaded image\n *\n * You can use this component directly in your fieldComponents override,\n * or create your own custom component using this as a reference.\n *\n * @example\n * ```tsx\n * // Use the default component with your upload function\n * fieldComponents: {\n * file: (props) => (\n * \n * ),\n * }\n * ```\n */\nexport function CMSFileUpload({\n\tlabel,\n\tisRequired,\n\tfieldConfigItem,\n\tfieldProps,\n\tfield,\n\tuploadImage,\n}: CMSFileUploadProps) {\n\t// Exclude showLabel and value from props spread\n\t// File inputs cannot have their value set programmatically (browser security)\n\tconst {\n\t\tshowLabel: _showLabel,\n\t\tvalue: _value,\n\t\t...safeFieldProps\n\t} = fieldProps;\n\tconst showLabel = _showLabel === undefined ? true : _showLabel;\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [previewUrl, setPreviewUrl] = useState(\n\t\tfield.value || null,\n\t);\n\n\tuseEffect(() => {\n\t\tconst normalizedValue = field.value || null;\n\t\tif (normalizedValue !== previewUrl) {\n\t\t\tsetPreviewUrl(normalizedValue);\n\t\t}\n\t}, [field.value, previewUrl]);\n\n\tconst handleFileChange = useCallback(\n\t\tasync (e: ChangeEvent) => {\n\t\t\tconst file = e.target.files?.[0];\n\t\t\tif (!file) return;\n\n\t\t\t// Check if it's an image\n\t\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\t\ttoast.error(\"Please select an image file\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetIsUploading(true);\n\t\t\ttry {\n\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\tsetPreviewUrl(url);\n\t\t\t\tfield.onChange(url);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Image upload failed:\", error);\n\t\t\t\ttoast.error(\"Failed to upload image\");\n\t\t\t} finally {\n\t\t\t\tsetIsUploading(false);\n\t\t\t}\n\t\t},\n\t\t[field, uploadImage],\n\t);\n\n\tconst handleRemove = useCallback(() => {\n\t\tsetPreviewUrl(null);\n\t\tfield.onChange(\"\");\n\t}, [field]);\n\n\treturn (\n\t\t\n\t\t\t{showLabel && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!previewUrl && (\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport {\n\tuseState,\n\tuseCallback,\n\tuseEffect,\n\ttype ChangeEvent,\n\ttype ComponentType,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tFormControl,\n\tFormItem,\n\tFormMessage,\n} from \"@/components/ui/form\";\nimport { Trash2, Loader2 } from \"lucide-react\";\nimport AutoFormLabel from \"@/components/ui/auto-form/common/label\";\nimport AutoFormTooltip from \"@/components/ui/auto-form/common/tooltip\";\n\n/**\n * Props for the CMSFileUpload component\n */\nexport interface CMSFileUploadProps extends AutoFormInputComponentProps {\n\t/**\n\t * Function to upload an image file and return the URL.\n\t * This is required - consumers must provide an upload implementation.\n\t */\n\tuploadImage: (file: File) => Promise;\n\t/**\n\t * Optional custom component for the image field.\n\t * When provided, it replaces the default file-upload input entirely.\n\t */\n\timageInputField?: ComponentType<{\n\t\tvalue: string;\n\t\tonChange: (value: string) => void;\n\t\tisRequired?: boolean;\n\t}>;\n\t/**\n\t * Optional trigger component for a media picker.\n\t * When provided, it is rendered as a \"Browse media\" option.\n\t */\n\timagePicker?: ComponentType<{ onSelect: (url: string) => void }>;\n}\n\n/**\n * Default file upload component for CMS image fields.\n *\n * This component:\n * - Accepts image files via file input\n * - Uses the required uploadImage prop to upload and get a URL\n * - Shows a preview of the uploaded image\n * - Allows removing the uploaded image\n *\n * You can use this component directly in your fieldComponents override,\n * or create your own custom component using this as a reference.\n *\n * @example\n * ```tsx\n * // Use the default component with your upload function\n * fieldComponents: {\n * file: (props) => (\n * \n * ),\n * }\n * ```\n */\nexport function CMSFileUpload({\n\tlabel,\n\tisRequired,\n\tfieldConfigItem,\n\tfieldProps,\n\tfield,\n\tuploadImage,\n\timageInputField: ImageInputField,\n\timagePicker: ImagePickerTrigger,\n}: CMSFileUploadProps) {\n\t// Exclude showLabel and value from props spread\n\t// File inputs cannot have their value set programmatically (browser security)\n\tconst {\n\t\tshowLabel: _showLabel,\n\t\tvalue: _value,\n\t\t...safeFieldProps\n\t} = fieldProps;\n\tconst showLabel = _showLabel === undefined ? true : _showLabel;\n\n\t// All hooks must be called unconditionally before any early return.\n\tconst [isUploading, setIsUploading] = useState(false);\n\tconst [previewUrl, setPreviewUrl] = useState(\n\t\tfield.value || null,\n\t);\n\n\tuseEffect(() => {\n\t\tconst normalizedValue = field.value || null;\n\t\tif (normalizedValue !== previewUrl) {\n\t\t\tsetPreviewUrl(normalizedValue);\n\t\t}\n\t}, [field.value, previewUrl]);\n\n\tconst handleFileChange = useCallback(\n\t\tasync (e: ChangeEvent) => {\n\t\t\tconst file = e.target.files?.[0];\n\t\t\tif (!file) return;\n\n\t\t\tif (!file.type.startsWith(\"image/\")) {\n\t\t\t\ttoast.error(\"Please select an image file\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tsetIsUploading(true);\n\t\t\ttry {\n\t\t\t\tconst url = await uploadImage(file);\n\t\t\t\tsetPreviewUrl(url);\n\t\t\t\tfield.onChange(url);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(\"Image upload failed:\", error);\n\t\t\t\ttoast.error(\"Failed to upload image\");\n\t\t\t} finally {\n\t\t\t\tsetIsUploading(false);\n\t\t\t}\n\t\t},\n\t\t[field, uploadImage],\n\t);\n\n\tconst handleRemove = useCallback(() => {\n\t\tsetPreviewUrl(null);\n\t\tfield.onChange(\"\");\n\t}, [field]);\n\n\t// When a custom imageInputField component is provided via overrides, delegate to it.\n\tif (ImageInputField) {\n\t\treturn (\n\t\t\t\n\t\t\t\t{showLabel && (\n\t\t\t\t\t\n\t\t\t\t)}\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t{showLabel && (\n\t\t\t\t\n\t\t\t)}\n\t\t\t{!previewUrl && (\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t)}\n\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n",
"target": "src/components/btst/cms/client/components/forms/file-upload.tsx"
},
{
@@ -199,7 +199,7 @@
{
"path": "btst/cms/client/overrides.ts",
"type": "registry:lib",
- "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\n\t/**\n\t * Function used to upload an image and return its URL.\n\t * Used by the default \"file\" field component.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Custom field components for AutoForm fields.\n\t *\n\t * These map field type names to React components. Use these to:\n\t * - Override built-in field types (checkbox, date, select, radio, switch, textarea, file, number, fallback)\n\t * - Add custom field types for your content types\n\t *\n\t * The component receives AutoFormInputComponentProps which includes:\n\t * - field: react-hook-form field controller\n\t * - label: the field label\n\t * - isRequired: whether the field is required\n\t * - fieldConfigItem: the field config (description, inputProps, etc.)\n\t * - fieldProps: additional props from fieldConfig.inputProps\n\t * - zodItem: the Zod schema for this field\n\t *\n\t * @example\n\t * ```tsx\n\t * fieldComponents: {\n\t * // Override the file type with custom S3 upload\n\t * file: ({ field, label, isRequired }) => (\n\t * \n\t * ),\n\t * // Add a custom rich text editor\n\t * richText: ({ field, label }) => (\n\t * \n\t * ),\n\t * }\n\t * ```\n\t */\n\tfieldComponents?: Record>;\n\n\t/**\n\t * Localization object for the CMS plugin\n\t */\n\tlocalization?: CMSLocalization;\n\n\t/**\n\t * API base URL\n\t */\n\tapiBaseURL: string;\n\n\t/**\n\t * API base path\n\t */\n\tapiBasePath: string;\n\n\t/**\n\t * Whether to show the attribution\n\t */\n\tshowAttribution?: boolean;\n\n\t/**\n\t * Optional headers to pass with API requests (e.g., for SSR auth)\n\t */\n\theaders?: HeadersInit;\n\n\t// Lifecycle Hooks (optional)\n\n\t/**\n\t * Called when a route is rendered\n\t * @param routeName - Name of the route (e.g., 'dashboard', 'contentList', 'contentEditor')\n\t * @param context - Route context with path, params, etc.\n\t */\n\tonRouteRender?: (\n\t\trouteName: string,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called when a route encounters an error\n\t * @param routeName - Name of the route\n\t * @param error - The error that occurred\n\t * @param context - Route context\n\t */\n\tonRouteError?: (\n\t\trouteName: string,\n\t\terror: Error,\n\t\tcontext: RouteContext,\n\t) => void | Promise;\n\n\t/**\n\t * Called before the dashboard page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param context - Route context\n\t */\n\tonBeforeDashboardRendered?: (context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the content list page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param typeSlug - The content type slug\n\t * @param context - Route context\n\t */\n\tonBeforeListRendered?: (typeSlug: string, context: RouteContext) => boolean;\n\n\t/**\n\t * Called before the content editor page is rendered\n\t * Return false to prevent rendering (e.g., for authorization)\n\t * @param typeSlug - The content type slug\n\t * @param id - The content item ID (null for new items)\n\t * @param context - Route context\n\t */\n\tonBeforeEditorRendered?: (\n\t\ttypeSlug: string,\n\t\tid: string | null,\n\t\tcontext: RouteContext,\n\t) => boolean;\n}\n",
+ "content": "import type { ComponentType } from \"react\";\nimport type { CMSLocalization } from \"./localization\";\nimport type { AutoFormInputComponentProps } from \"@/components/ui/auto-form/types\";\n\n/**\n * Props for the overridable CMS image input field component.\n */\nexport interface CmsImageInputFieldProps {\n\t/** Current image URL value */\n\tvalue: string;\n\t/** Called when the image URL changes */\n\tonChange: (value: string) => void;\n\t/** Whether the field is required */\n\tisRequired?: boolean;\n}\n\n/**\n * Context passed to lifecycle hooks\n */\nexport interface RouteContext {\n\t/** Current route path */\n\tpath: string;\n\t/** Route parameters (e.g., { typeSlug: \"product\", id: \"123\" }) */\n\tparams?: Record;\n\t/** Whether rendering on server (true) or client (false) */\n\tisSSR: boolean;\n\t/** Additional context properties */\n\t[key: string]: unknown;\n}\n\n/**\n * Overridable components and functions for the CMS plugin\n *\n * External consumers can provide their own implementations of these\n * to customize the behavior for their framework (Next.js, React Router, etc.)\n */\nexport interface CMSPluginOverrides {\n\t/**\n\t * Link component for navigation\n\t */\n\tLink?: ComponentType & Record>;\n\n\t/**\n\t * Navigation function for programmatic navigation\n\t */\n\tnavigate: (path: string) => void | Promise;\n\n\t/**\n\t * Refresh function to invalidate server-side cache (e.g., Next.js router.refresh())\n\t */\n\trefresh?: () => void | Promise;\n\n\t/**\n\t * Image component for displaying images\n\t */\n\tImage?: ComponentType<\n\t\tReact.ImgHTMLAttributes & Record\n\t>;\n\n\t/**\n\t * Function used to upload a new image file and return its URL.\n\t * Used by the default \"file\" field component when not selecting an existing\n\t * asset via `imagePicker` or `imageInputField`.\n\t */\n\tuploadImage?: (file: File) => Promise;\n\n\t/**\n\t * Optional custom component for image fields (fieldType: \"file\").\n\t *\n\t * When provided it replaces the default file-upload input entirely.\n\t * The component receives `value` (current URL string) and `onChange` (setter).\n\t *\n\t * @example\n\t * ```tsx\n\t * imageInputField: ({ value, onChange }) =>\n\t * value ? (\n\t *
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{selectedAssets.length > 0\n\t\t\t\t\t\t\t\t? `${selectedAssets.length} selected`\n\t\t\t\t\t\t\t\t: \"Click a file to select it\"}\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\n/**\n * ImageInputField — displays an image preview with change/remove actions, or a\n * \"Browse Media\" button that opens the full MediaPicker popover (Browse / Upload / URL tabs).\n *\n * Upload mode, folder selection, and multi-mode cloud support are all handled inside\n * the MediaPicker's UploadTab — this component is purely a thin wrapper.\n */\nexport function ImageInputField({\n\tvalue,\n\tonChange,\n}: {\n\tvalue: string;\n\tonChange: (v: string) => void;\n}) {\n\tconst { Image: ImageComponent } = usePluginOverrides<\n\t\tMediaPluginOverrides,\n\t\tPartial\n\t>(\"media\", {});\n\n\tif (value) {\n\t\treturn (\n\t\t\t
+ * ) : (
+ * Browse media} accept={["image/*"]}
+ * onSelect={(assets) => onChange(assets[0].url)} />
+ * )
+ * ```
+ */
+ imageInputField?: ComponentType;
+
+ /**
+ * Optional trigger component for a media picker.
+ * When provided, it is rendered adjacent to the Markdown editor and allows
+ * users to browse and select previously uploaded assets.
+ * Receives `onSelect(url)` — insert the chosen URL into the editor.
+ *
+ * @example
+ * ```tsx
+ * imagePicker: ({ onSelect }) => (
+ * Browse media}
+ * accept={["image/*"]}
+ * onSelect={(assets) => onSelect(assets[0].url)}
+ * />
+ * )
+ * ```
+ */
+ imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
/**
* Localization object for the blog plugin
*/
diff --git a/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx b/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx
index 158cee9a..de59c6a5 100644
--- a/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx
+++ b/packages/stack/src/plugins/cms/client/components/forms/content-form.tsx
@@ -56,6 +56,12 @@ function buildFieldConfigFromJsonSchema(
string,
React.ComponentType
>,
+ imagePicker?: React.ComponentType<{ onSelect: (url: string) => void }>,
+ imageInputField?: React.ComponentType<{
+ value: string;
+ onChange: (value: string) => void;
+ isRequired?: boolean;
+ }>,
): FieldConfig> {
// Get base config from shared utility (handles fieldType from JSON Schema)
const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);
@@ -73,14 +79,14 @@ function buildFieldConfigFromJsonSchema(
// Handle "file" fieldType when there's NO custom component for "file"
if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
// Use CMSFileUpload as the default file component
- if (!uploadImage) {
- // Show a clear error message if uploadImage is not provided
+ if (!uploadImage && !imageInputField) {
+ // Show a clear error message if neither uploadImage nor imageInputField is provided
baseConfig[key] = {
...baseConfig[key],
fieldType: () => (
- File upload requires an uploadImage function in CMS
- overrides.
+ File upload requires an uploadImage or{" "}
+ imageInputField function in CMS overrides.
),
};
@@ -88,7 +94,12 @@ function buildFieldConfigFromJsonSchema(
baseConfig[key] = {
...baseConfig[key],
fieldType: (props: AutoFormInputComponentProps) => (
-
+ Promise.resolve(""))}
+ imageInputField={imageInputField}
+ imagePicker={imagePicker}
+ />
),
};
}
@@ -151,6 +162,8 @@ export function ContentForm({
const {
localization: customLocalization,
uploadImage,
+ imagePicker,
+ imageInputField,
fieldComponents,
} = usePluginOverrides("cms");
const localization = { ...CMS_LOCALIZATION, ...customLocalization };
@@ -214,8 +227,14 @@ export function ContentForm({
// Build field config for AutoForm (fieldType is now embedded in jsonSchema)
const fieldConfig = useMemo(
() =>
- buildFieldConfigFromJsonSchema(jsonSchema, uploadImage, fieldComponents),
- [jsonSchema, uploadImage, fieldComponents],
+ buildFieldConfigFromJsonSchema(
+ jsonSchema,
+ uploadImage,
+ fieldComponents,
+ imagePicker,
+ imageInputField,
+ ),
+ [jsonSchema, uploadImage, fieldComponents, imagePicker, imageInputField],
);
// Find the field to use for slug auto-generation
diff --git a/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx b/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx
index 54f8b973..b76f7a68 100644
--- a/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx
+++ b/packages/stack/src/plugins/cms/client/components/forms/file-upload.tsx
@@ -1,6 +1,12 @@
"use client";
-import { useState, useCallback, useEffect, type ChangeEvent } from "react";
+import {
+ useState,
+ useCallback,
+ useEffect,
+ type ChangeEvent,
+ type ComponentType,
+} from "react";
import { toast } from "sonner";
import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
import { Input } from "@workspace/ui/components/input";
@@ -23,6 +29,20 @@ export interface CMSFileUploadProps extends AutoFormInputComponentProps {
* This is required - consumers must provide an upload implementation.
*/
uploadImage: (file: File) => Promise;
+ /**
+ * Optional custom component for the image field.
+ * When provided, it replaces the default file-upload input entirely.
+ */
+ imageInputField?: ComponentType<{
+ value: string;
+ onChange: (value: string) => void;
+ isRequired?: boolean;
+ }>;
+ /**
+ * Optional trigger component for a media picker.
+ * When provided, it is rendered as a "Browse media" option.
+ */
+ imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
}
/**
@@ -54,6 +74,8 @@ export function CMSFileUpload({
fieldProps,
field,
uploadImage,
+ imageInputField: ImageInputField,
+ imagePicker: ImagePickerTrigger,
}: CMSFileUploadProps) {
// Exclude showLabel and value from props spread
// File inputs cannot have their value set programmatically (browser security)
@@ -63,6 +85,8 @@ export function CMSFileUpload({
...safeFieldProps
} = fieldProps;
const showLabel = _showLabel === undefined ? true : _showLabel;
+
+ // All hooks must be called unconditionally before any early return.
const [isUploading, setIsUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState(
field.value || null,
@@ -80,7 +104,6 @@ export function CMSFileUpload({
const file = e.target.files?.[0];
if (!file) return;
- // Check if it's an image
if (!file.type.startsWith("image/")) {
toast.error("Please select an image file");
return;
@@ -106,6 +129,29 @@ export function CMSFileUpload({
field.onChange("");
}, [field]);
+ // When a custom imageInputField component is provided via overrides, delegate to it.
+ if (ImageInputField) {
+ return (
+
+ {showLabel && (
+
+ )}
+
+
+
+
+
+
+ );
+ }
+
return (
{showLabel && (
@@ -116,19 +162,31 @@ export function CMSFileUpload({
)}
{!previewUrl && (
-
diff --git a/packages/stack/src/plugins/cms/client/overrides.ts b/packages/stack/src/plugins/cms/client/overrides.ts
index e65d9dc6..6b1b1e49 100644
--- a/packages/stack/src/plugins/cms/client/overrides.ts
+++ b/packages/stack/src/plugins/cms/client/overrides.ts
@@ -2,6 +2,18 @@ import type { ComponentType } from "react";
import type { CMSLocalization } from "./localization";
import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
+/**
+ * Props for the overridable CMS image input field component.
+ */
+export interface CmsImageInputFieldProps {
+ /** Current image URL value */
+ value: string;
+ /** Called when the image URL changes */
+ onChange: (value: string) => void;
+ /** Whether the field is required */
+ isRequired?: boolean;
+}
+
/**
* Context passed to lifecycle hooks
*/
@@ -46,11 +58,54 @@ export interface CMSPluginOverrides {
>;
/**
- * Function used to upload an image and return its URL.
- * Used by the default "file" field component.
+ * Function used to upload a new image file and return its URL.
+ * Used by the default "file" field component when not selecting an existing
+ * asset via `imagePicker` or `imageInputField`.
*/
uploadImage?: (file: File) => Promise;
+ /**
+ * Optional custom component for image fields (fieldType: "file").
+ *
+ * When provided it replaces the default file-upload input entirely.
+ * The component receives `value` (current URL string) and `onChange` (setter).
+ *
+ * @example
+ * ```tsx
+ * imageInputField: ({ value, onChange }) =>
+ * value ? (
+ *