@actuate-media/cms-admin 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +35 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  6. package/dist/components/Breadcrumbs.js +1 -0
  7. package/dist/components/Breadcrumbs.js.map +1 -1
  8. package/dist/components/ErrorBoundary.js +1 -1
  9. package/dist/components/ErrorBoundary.js.map +1 -1
  10. package/dist/hooks/useBuilderState.d.ts +49 -0
  11. package/dist/hooks/useBuilderState.d.ts.map +1 -0
  12. package/dist/hooks/useBuilderState.js +238 -0
  13. package/dist/hooks/useBuilderState.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +4 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/layout/Sidebar.d.ts.map +1 -1
  19. package/dist/layout/Sidebar.js +2 -2
  20. package/dist/layout/Sidebar.js.map +1 -1
  21. package/dist/views/ForgotPassword.d.ts +5 -0
  22. package/dist/views/ForgotPassword.d.ts.map +1 -0
  23. package/dist/views/ForgotPassword.js +41 -0
  24. package/dist/views/ForgotPassword.js.map +1 -0
  25. package/dist/views/ResetPassword.d.ts +6 -0
  26. package/dist/views/ResetPassword.d.ts.map +1 -0
  27. package/dist/views/ResetPassword.js +46 -0
  28. package/dist/views/ResetPassword.js.map +1 -0
  29. package/dist/views/ScriptTagEditor.d.ts +6 -0
  30. package/dist/views/ScriptTagEditor.d.ts.map +1 -0
  31. package/dist/views/ScriptTagEditor.js +109 -0
  32. package/dist/views/ScriptTagEditor.js.map +1 -0
  33. package/dist/views/ScriptTags.d.ts +5 -0
  34. package/dist/views/ScriptTags.d.ts.map +1 -0
  35. package/dist/views/ScriptTags.js +54 -0
  36. package/dist/views/ScriptTags.js.map +1 -0
  37. package/dist/views/page-builder/AIBlockAssist.d.ts +9 -0
  38. package/dist/views/page-builder/AIBlockAssist.d.ts.map +1 -0
  39. package/dist/views/page-builder/AIBlockAssist.js +40 -0
  40. package/dist/views/page-builder/AIBlockAssist.js.map +1 -0
  41. package/dist/views/page-builder/AIGenerateDialog.d.ts +8 -0
  42. package/dist/views/page-builder/AIGenerateDialog.d.ts.map +1 -0
  43. package/dist/views/page-builder/AIGenerateDialog.js +170 -0
  44. package/dist/views/page-builder/AIGenerateDialog.js.map +1 -0
  45. package/dist/views/page-builder/BlockEditor.d.ts +11 -0
  46. package/dist/views/page-builder/BlockEditor.d.ts.map +1 -0
  47. package/dist/views/page-builder/BlockEditor.js +67 -0
  48. package/dist/views/page-builder/BlockEditor.js.map +1 -0
  49. package/dist/views/page-builder/BlockPicker.d.ts +7 -0
  50. package/dist/views/page-builder/BlockPicker.d.ts.map +1 -0
  51. package/dist/views/page-builder/BlockPicker.js +102 -0
  52. package/dist/views/page-builder/BlockPicker.js.map +1 -0
  53. package/dist/views/page-builder/BottomBar.d.ts +9 -0
  54. package/dist/views/page-builder/BottomBar.d.ts.map +1 -0
  55. package/dist/views/page-builder/BottomBar.js +13 -0
  56. package/dist/views/page-builder/BottomBar.js.map +1 -0
  57. package/dist/views/page-builder/BuilderToolbar.d.ts +21 -0
  58. package/dist/views/page-builder/BuilderToolbar.d.ts.map +1 -0
  59. package/dist/views/page-builder/BuilderToolbar.js +18 -0
  60. package/dist/views/page-builder/BuilderToolbar.js.map +1 -0
  61. package/dist/views/page-builder/ContextPanel.d.ts +20 -0
  62. package/dist/views/page-builder/ContextPanel.d.ts.map +1 -0
  63. package/dist/views/page-builder/ContextPanel.js +40 -0
  64. package/dist/views/page-builder/ContextPanel.js.map +1 -0
  65. package/dist/views/page-builder/DesignScore.d.ts +6 -0
  66. package/dist/views/page-builder/DesignScore.d.ts.map +1 -0
  67. package/dist/views/page-builder/DesignScore.js +93 -0
  68. package/dist/views/page-builder/DesignScore.js.map +1 -0
  69. package/dist/views/page-builder/NodeSettings.d.ts +12 -0
  70. package/dist/views/page-builder/NodeSettings.d.ts.map +1 -0
  71. package/dist/views/page-builder/NodeSettings.js +80 -0
  72. package/dist/views/page-builder/NodeSettings.js.map +1 -0
  73. package/dist/views/page-builder/PageBuilder.d.ts +8 -0
  74. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -0
  75. package/dist/views/page-builder/PageBuilder.js +126 -0
  76. package/dist/views/page-builder/PageBuilder.js.map +1 -0
  77. package/dist/views/page-builder/PageSettings.d.ts +7 -0
  78. package/dist/views/page-builder/PageSettings.d.ts.map +1 -0
  79. package/dist/views/page-builder/PageSettings.js +27 -0
  80. package/dist/views/page-builder/PageSettings.js.map +1 -0
  81. package/dist/views/page-builder/SEOPanel.d.ts +10 -0
  82. package/dist/views/page-builder/SEOPanel.d.ts.map +1 -0
  83. package/dist/views/page-builder/SEOPanel.js +105 -0
  84. package/dist/views/page-builder/SEOPanel.js.map +1 -0
  85. package/dist/views/page-builder/SavedSections.d.ts +6 -0
  86. package/dist/views/page-builder/SavedSections.d.ts.map +1 -0
  87. package/dist/views/page-builder/SavedSections.js +145 -0
  88. package/dist/views/page-builder/SavedSections.js.map +1 -0
  89. package/dist/views/page-builder/TemplatePicker.d.ts +7 -0
  90. package/dist/views/page-builder/TemplatePicker.d.ts.map +1 -0
  91. package/dist/views/page-builder/TemplatePicker.js +68 -0
  92. package/dist/views/page-builder/TemplatePicker.js.map +1 -0
  93. package/dist/views/page-builder/block-renderers/CTAPreview.d.ts +3 -0
  94. package/dist/views/page-builder/block-renderers/CTAPreview.d.ts.map +1 -0
  95. package/dist/views/page-builder/block-renderers/CTAPreview.js +19 -0
  96. package/dist/views/page-builder/block-renderers/CTAPreview.js.map +1 -0
  97. package/dist/views/page-builder/block-renderers/CardsPreview.d.ts +3 -0
  98. package/dist/views/page-builder/block-renderers/CardsPreview.d.ts.map +1 -0
  99. package/dist/views/page-builder/block-renderers/CardsPreview.js +22 -0
  100. package/dist/views/page-builder/block-renderers/CardsPreview.js.map +1 -0
  101. package/dist/views/page-builder/block-renderers/CodePreview.d.ts +3 -0
  102. package/dist/views/page-builder/block-renderers/CodePreview.d.ts.map +1 -0
  103. package/dist/views/page-builder/block-renderers/CodePreview.js +16 -0
  104. package/dist/views/page-builder/block-renderers/CodePreview.js.map +1 -0
  105. package/dist/views/page-builder/block-renderers/FAQPreview.d.ts +3 -0
  106. package/dist/views/page-builder/block-renderers/FAQPreview.d.ts.map +1 -0
  107. package/dist/views/page-builder/block-renderers/FAQPreview.js +24 -0
  108. package/dist/views/page-builder/block-renderers/FAQPreview.js.map +1 -0
  109. package/dist/views/page-builder/block-renderers/FallbackPreview.d.ts +6 -0
  110. package/dist/views/page-builder/block-renderers/FallbackPreview.d.ts.map +1 -0
  111. package/dist/views/page-builder/block-renderers/FallbackPreview.js +7 -0
  112. package/dist/views/page-builder/block-renderers/FallbackPreview.js.map +1 -0
  113. package/dist/views/page-builder/block-renderers/FormPreview.d.ts +3 -0
  114. package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -0
  115. package/dist/views/page-builder/block-renderers/FormPreview.js +14 -0
  116. package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -0
  117. package/dist/views/page-builder/block-renderers/GalleryPreview.d.ts +3 -0
  118. package/dist/views/page-builder/block-renderers/GalleryPreview.d.ts.map +1 -0
  119. package/dist/views/page-builder/block-renderers/GalleryPreview.js +21 -0
  120. package/dist/views/page-builder/block-renderers/GalleryPreview.js.map +1 -0
  121. package/dist/views/page-builder/block-renderers/HeroPreview.d.ts +3 -0
  122. package/dist/views/page-builder/block-renderers/HeroPreview.d.ts.map +1 -0
  123. package/dist/views/page-builder/block-renderers/HeroPreview.js +19 -0
  124. package/dist/views/page-builder/block-renderers/HeroPreview.js.map +1 -0
  125. package/dist/views/page-builder/block-renderers/ImagePreview.d.ts +3 -0
  126. package/dist/views/page-builder/block-renderers/ImagePreview.d.ts.map +1 -0
  127. package/dist/views/page-builder/block-renderers/ImagePreview.js +17 -0
  128. package/dist/views/page-builder/block-renderers/ImagePreview.js.map +1 -0
  129. package/dist/views/page-builder/block-renderers/TextPreview.d.ts +3 -0
  130. package/dist/views/page-builder/block-renderers/TextPreview.d.ts.map +1 -0
  131. package/dist/views/page-builder/block-renderers/TextPreview.js +26 -0
  132. package/dist/views/page-builder/block-renderers/TextPreview.js.map +1 -0
  133. package/dist/views/page-builder/block-renderers/VideoPreview.d.ts +3 -0
  134. package/dist/views/page-builder/block-renderers/VideoPreview.d.ts.map +1 -0
  135. package/dist/views/page-builder/block-renderers/VideoPreview.js +21 -0
  136. package/dist/views/page-builder/block-renderers/VideoPreview.js.map +1 -0
  137. package/dist/views/page-builder/block-renderers/index.d.ts +9 -0
  138. package/dist/views/page-builder/block-renderers/index.d.ts.map +1 -0
  139. package/dist/views/page-builder/block-renderers/index.js +25 -0
  140. package/dist/views/page-builder/block-renderers/index.js.map +1 -0
  141. package/dist/views/page-builder/canvas/BlockRenderer.d.ts +8 -0
  142. package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -0
  143. package/dist/views/page-builder/canvas/BlockRenderer.js +30 -0
  144. package/dist/views/page-builder/canvas/BlockRenderer.js.map +1 -0
  145. package/dist/views/page-builder/canvas/BuilderCanvas.d.ts +10 -0
  146. package/dist/views/page-builder/canvas/BuilderCanvas.d.ts.map +1 -0
  147. package/dist/views/page-builder/canvas/BuilderCanvas.js +26 -0
  148. package/dist/views/page-builder/canvas/BuilderCanvas.js.map +1 -0
  149. package/dist/views/page-builder/canvas/ColumnRenderer.d.ts +8 -0
  150. package/dist/views/page-builder/canvas/ColumnRenderer.d.ts.map +1 -0
  151. package/dist/views/page-builder/canvas/ColumnRenderer.js +36 -0
  152. package/dist/views/page-builder/canvas/ColumnRenderer.js.map +1 -0
  153. package/dist/views/page-builder/canvas/ContainerRenderer.d.ts +8 -0
  154. package/dist/views/page-builder/canvas/ContainerRenderer.d.ts.map +1 -0
  155. package/dist/views/page-builder/canvas/ContainerRenderer.js +33 -0
  156. package/dist/views/page-builder/canvas/ContainerRenderer.js.map +1 -0
  157. package/dist/views/page-builder/canvas/RowRenderer.d.ts +8 -0
  158. package/dist/views/page-builder/canvas/RowRenderer.d.ts.map +1 -0
  159. package/dist/views/page-builder/canvas/RowRenderer.js +32 -0
  160. package/dist/views/page-builder/canvas/RowRenderer.js.map +1 -0
  161. package/dist/views/page-builder/canvas/SectionRenderer.d.ts +8 -0
  162. package/dist/views/page-builder/canvas/SectionRenderer.d.ts.map +1 -0
  163. package/dist/views/page-builder/canvas/SectionRenderer.js +54 -0
  164. package/dist/views/page-builder/canvas/SectionRenderer.js.map +1 -0
  165. package/dist/views/page-builder/canvas/index.d.ts +3 -0
  166. package/dist/views/page-builder/canvas/index.d.ts.map +1 -0
  167. package/dist/views/page-builder/canvas/index.js +2 -0
  168. package/dist/views/page-builder/canvas/index.js.map +1 -0
  169. package/package.json +7 -4
  170. package/src/AdminRoot.tsx +41 -0
  171. package/src/components/Breadcrumbs.tsx +1 -0
  172. package/src/components/ErrorBoundary.tsx +3 -3
  173. package/src/hooks/useBuilderState.ts +328 -0
  174. package/src/index.ts +8 -0
  175. package/src/layout/Sidebar.tsx +7 -0
  176. package/src/views/ForgotPassword.tsx +136 -0
  177. package/src/views/ResetPassword.tsx +192 -0
  178. package/src/views/ScriptTagEditor.tsx +361 -0
  179. package/src/views/ScriptTags.tsx +174 -0
  180. package/src/views/page-builder/AIBlockAssist.tsx +68 -0
  181. package/src/views/page-builder/AIGenerateDialog.tsx +574 -0
  182. package/src/views/page-builder/BlockEditor.tsx +352 -0
  183. package/src/views/page-builder/BlockPicker.tsx +338 -0
  184. package/src/views/page-builder/BottomBar.tsx +64 -0
  185. package/src/views/page-builder/BuilderToolbar.tsx +218 -0
  186. package/src/views/page-builder/ContextPanel.tsx +145 -0
  187. package/src/views/page-builder/DesignScore.tsx +258 -0
  188. package/src/views/page-builder/NodeSettings.tsx +515 -0
  189. package/src/views/page-builder/PageBuilder.tsx +288 -0
  190. package/src/views/page-builder/PageSettings.tsx +161 -0
  191. package/src/views/page-builder/SEOPanel.tsx +485 -0
  192. package/src/views/page-builder/SavedSections.tsx +486 -0
  193. package/src/views/page-builder/TemplatePicker.tsx +201 -0
  194. package/src/views/page-builder/block-renderers/CTAPreview.tsx +81 -0
  195. package/src/views/page-builder/block-renderers/CardsPreview.tsx +71 -0
  196. package/src/views/page-builder/block-renderers/CodePreview.tsx +46 -0
  197. package/src/views/page-builder/block-renderers/FAQPreview.tsx +90 -0
  198. package/src/views/page-builder/block-renderers/FallbackPreview.tsx +18 -0
  199. package/src/views/page-builder/block-renderers/FormPreview.tsx +69 -0
  200. package/src/views/page-builder/block-renderers/GalleryPreview.tsx +93 -0
  201. package/src/views/page-builder/block-renderers/HeroPreview.tsx +103 -0
  202. package/src/views/page-builder/block-renderers/ImagePreview.tsx +54 -0
  203. package/src/views/page-builder/block-renderers/TextPreview.tsx +81 -0
  204. package/src/views/page-builder/block-renderers/VideoPreview.tsx +78 -0
  205. package/src/views/page-builder/block-renderers/index.ts +34 -0
  206. package/src/views/page-builder/canvas/BlockRenderer.tsx +62 -0
  207. package/src/views/page-builder/canvas/BuilderCanvas.tsx +90 -0
  208. package/src/views/page-builder/canvas/ColumnRenderer.tsx +86 -0
  209. package/src/views/page-builder/canvas/ContainerRenderer.tsx +71 -0
  210. package/src/views/page-builder/canvas/RowRenderer.tsx +72 -0
  211. package/src/views/page-builder/canvas/SectionRenderer.tsx +97 -0
  212. package/src/views/page-builder/canvas/index.ts +2 -0
@@ -0,0 +1,352 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useCallback } from 'react';
4
+ import { Star, Copy, Trash2 } from 'lucide-react';
5
+ import * as SwitchPrimitive from '@radix-ui/react-switch';
6
+ import type { BlockNode, BlockTypeDefinition, FieldDefinition } from '@actuate-media/cms-core';
7
+ import { BlockCatalog } from '@actuate-media/cms-core';
8
+ import { AIBlockAssist } from './AIBlockAssist.js';
9
+
10
+ export interface BlockEditorProps {
11
+ node: BlockNode;
12
+ onUpdateBlock: (id: string, data: Record<string, unknown>) => void;
13
+ onUpdateSettings: (id: string, settings: Record<string, unknown>) => void;
14
+ onRemoveNode: (id: string) => void;
15
+ onDuplicateNode: (id: string) => void;
16
+ config: any;
17
+ }
18
+
19
+ const INPUT_CLASS =
20
+ 'w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring';
21
+ const LABEL_CLASS = 'text-sm font-medium text-foreground mb-1 block';
22
+
23
+ export function BlockEditor({
24
+ node,
25
+ onUpdateBlock,
26
+ onUpdateSettings,
27
+ onRemoveNode,
28
+ onDuplicateNode,
29
+ config,
30
+ }: BlockEditorProps) {
31
+ const [confirmDelete, setConfirmDelete] = useState(false);
32
+
33
+ const catalog = useMemo(() => new BlockCatalog(), []);
34
+ const blockDef = useMemo(
35
+ () => catalog.get(node.settings.blockType),
36
+ [catalog, node.settings.blockType]
37
+ );
38
+
39
+ const handleFieldChange = useCallback(
40
+ (fieldName: string, value: unknown) => {
41
+ onUpdateBlock(node.id, { [fieldName]: value });
42
+ },
43
+ [node.id, onUpdateBlock]
44
+ );
45
+
46
+ const handleVariantChange = useCallback(
47
+ (variantName: string) => {
48
+ onUpdateSettings(node.id, { variant: variantName });
49
+ },
50
+ [node.id, onUpdateSettings]
51
+ );
52
+
53
+ const handleDelete = useCallback(() => {
54
+ if (confirmDelete) {
55
+ onRemoveNode(node.id);
56
+ setConfirmDelete(false);
57
+ } else {
58
+ setConfirmDelete(true);
59
+ }
60
+ }, [confirmDelete, node.id, onRemoveNode]);
61
+
62
+ if (!blockDef) {
63
+ return (
64
+ <div className="p-4">
65
+ <p className="text-sm text-muted-foreground">
66
+ Unknown block type: <code className="text-xs">{node.settings.blockType}</code>
67
+ </p>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div className="flex flex-col h-full">
74
+ <div className="p-4 border-b border-border">
75
+ <div className="flex items-center gap-2">
76
+ <Star size={16} className="text-muted-foreground" />
77
+ <span className="text-sm font-medium text-foreground flex-1">{blockDef.label}</span>
78
+ <AIBlockAssist
79
+ block={node}
80
+ onUpdateData={(data) => onUpdateBlock(node.id, data)}
81
+ />
82
+ </div>
83
+ {blockDef.description && (
84
+ <p className="text-xs text-muted-foreground mt-1">{blockDef.description}</p>
85
+ )}
86
+ </div>
87
+
88
+ {blockDef.variants.length > 1 && (
89
+ <div className="p-4 border-b border-border">
90
+ <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
91
+ Variant
92
+ </p>
93
+ <div className="grid grid-cols-2 gap-1.5">
94
+ {blockDef.variants.map((variant) => (
95
+ <button
96
+ key={variant.name}
97
+ type="button"
98
+ onClick={() => handleVariantChange(variant.name)}
99
+ className={`px-2 py-1.5 text-xs rounded-md border transition-colors ${
100
+ node.settings.variant === variant.name
101
+ ? 'bg-primary text-primary-foreground border-primary'
102
+ : 'bg-background border-input text-foreground hover:bg-accent'
103
+ }`}
104
+ >
105
+ {variant.label}
106
+ </button>
107
+ ))}
108
+ </div>
109
+ </div>
110
+ )}
111
+
112
+ <div className="space-y-4 p-4 flex-1">
113
+ <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
114
+ Fields
115
+ </p>
116
+ {Object.entries(blockDef.fields).map(([fieldName, fieldDef]) => (
117
+ <FieldRenderer
118
+ key={fieldName}
119
+ name={fieldName}
120
+ definition={fieldDef}
121
+ value={node.data[fieldName]}
122
+ onChange={(value) => handleFieldChange(fieldName, value)}
123
+ />
124
+ ))}
125
+ </div>
126
+
127
+ <div className="p-4 border-t border-border space-y-2">
128
+ <button
129
+ type="button"
130
+ onClick={() => onDuplicateNode(node.id)}
131
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium bg-background border border-input rounded-md hover:bg-accent transition-colors"
132
+ >
133
+ <Copy size={14} />
134
+ Duplicate
135
+ </button>
136
+ <button
137
+ type="button"
138
+ onClick={handleDelete}
139
+ onBlur={() => setConfirmDelete(false)}
140
+ className={`w-full flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
141
+ confirmDelete
142
+ ? 'bg-destructive text-destructive-foreground'
143
+ : 'bg-background border border-destructive text-destructive hover:bg-destructive/10'
144
+ }`}
145
+ >
146
+ <Trash2 size={14} />
147
+ {confirmDelete ? 'Click again to confirm' : 'Delete'}
148
+ </button>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ interface FieldRendererProps {
155
+ name: string;
156
+ definition: FieldDefinition;
157
+ value: unknown;
158
+ onChange: (value: unknown) => void;
159
+ }
160
+
161
+ function FieldRenderer({ name, definition, value, onChange }: FieldRendererProps) {
162
+ const label = definition.label || name;
163
+
164
+ switch (definition.type) {
165
+ case 'text':
166
+ return (
167
+ <div>
168
+ <label className={LABEL_CLASS}>{label}</label>
169
+ <input
170
+ type="text"
171
+ value={(value as string) ?? ''}
172
+ onChange={(e) => onChange(e.target.value)}
173
+ placeholder={definition.admin?.placeholder}
174
+ className={INPUT_CLASS}
175
+ />
176
+ </div>
177
+ );
178
+
179
+ case 'richText':
180
+ return (
181
+ <div>
182
+ <label className={LABEL_CLASS}>{label}</label>
183
+ <textarea
184
+ value={(value as string) ?? ''}
185
+ onChange={(e) => onChange(e.target.value)}
186
+ placeholder={definition.admin?.placeholder}
187
+ rows={4}
188
+ className={`${INPUT_CLASS} resize-y`}
189
+ />
190
+ </div>
191
+ );
192
+
193
+ case 'url':
194
+ return (
195
+ <div>
196
+ <label className={LABEL_CLASS}>{label}</label>
197
+ <input
198
+ type="url"
199
+ value={(value as string) ?? ''}
200
+ onChange={(e) => onChange(e.target.value)}
201
+ placeholder={definition.admin?.placeholder ?? 'https://'}
202
+ className={INPUT_CLASS}
203
+ />
204
+ </div>
205
+ );
206
+
207
+ case 'number':
208
+ return (
209
+ <div>
210
+ <label className={LABEL_CLASS}>{label}</label>
211
+ <input
212
+ type="number"
213
+ value={(value as number) ?? ''}
214
+ onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
215
+ min={definition.min}
216
+ max={definition.max}
217
+ step={definition.step}
218
+ className={INPUT_CLASS}
219
+ />
220
+ </div>
221
+ );
222
+
223
+ case 'boolean':
224
+ return (
225
+ <div className="flex items-center justify-between">
226
+ <label className="text-sm font-medium text-foreground">{label}</label>
227
+ <SwitchPrimitive.Root
228
+ checked={!!value}
229
+ onCheckedChange={(checked) => onChange(checked)}
230
+ className="w-9 h-5 bg-input rounded-full relative data-[state=checked]:bg-primary transition-colors"
231
+ aria-label={label}
232
+ >
233
+ <SwitchPrimitive.Thumb className="block h-3.5 w-3.5 rounded-full bg-background shadow-sm transition-transform translate-x-0.5 data-[state=checked]:translate-x-[18px]" />
234
+ </SwitchPrimitive.Root>
235
+ </div>
236
+ );
237
+
238
+ case 'media':
239
+ return (
240
+ <div>
241
+ <label className={LABEL_CLASS}>{label}</label>
242
+ <div className="flex gap-2">
243
+ <input
244
+ type="text"
245
+ value={(value as string) ?? ''}
246
+ onChange={(e) => onChange(e.target.value)}
247
+ placeholder="Select media..."
248
+ className={`${INPUT_CLASS} flex-1`}
249
+ />
250
+ <button
251
+ type="button"
252
+ className="px-3 py-2 text-xs font-medium bg-accent text-foreground border border-input rounded-md hover:bg-accent/80 transition-colors"
253
+ >
254
+ Browse
255
+ </button>
256
+ </div>
257
+ </div>
258
+ );
259
+
260
+ case 'select':
261
+ return (
262
+ <div>
263
+ <label className={LABEL_CLASS}>{label}</label>
264
+ <select
265
+ value={(value as string) ?? ''}
266
+ onChange={(e) => onChange(e.target.value)}
267
+ className={INPUT_CLASS}
268
+ >
269
+ <option value="">Select...</option>
270
+ {definition.options.map((opt) => (
271
+ <option key={opt.value} value={opt.value}>
272
+ {opt.label}
273
+ </option>
274
+ ))}
275
+ </select>
276
+ </div>
277
+ );
278
+
279
+ case 'array':
280
+ return (
281
+ <div>
282
+ <label className={LABEL_CLASS}>{label}</label>
283
+ <div className="px-3 py-2 text-sm bg-muted border border-input rounded-md text-muted-foreground">
284
+ {Array.isArray(value) ? `${value.length} items` : '0 items'}
285
+ </div>
286
+ </div>
287
+ );
288
+
289
+ case 'relationship':
290
+ return (
291
+ <div>
292
+ <label className={LABEL_CLASS}>{label}</label>
293
+ <input
294
+ type="text"
295
+ value={(value as string) ?? ''}
296
+ onChange={(e) => onChange(e.target.value)}
297
+ placeholder="Select relationship..."
298
+ className={INPUT_CLASS}
299
+ />
300
+ </div>
301
+ );
302
+
303
+ case 'email':
304
+ return (
305
+ <div>
306
+ <label className={LABEL_CLASS}>{label}</label>
307
+ <input
308
+ type="email"
309
+ value={(value as string) ?? ''}
310
+ onChange={(e) => onChange(e.target.value)}
311
+ placeholder={definition.admin?.placeholder ?? 'email@example.com'}
312
+ className={INPUT_CLASS}
313
+ />
314
+ </div>
315
+ );
316
+
317
+ case 'color':
318
+ return (
319
+ <div>
320
+ <label className={LABEL_CLASS}>{label}</label>
321
+ <div className="flex items-center gap-2">
322
+ <input
323
+ type="color"
324
+ value={(value as string) ?? '#000000'}
325
+ onChange={(e) => onChange(e.target.value)}
326
+ className="h-9 w-9 rounded-md border border-input cursor-pointer"
327
+ />
328
+ <input
329
+ type="text"
330
+ value={(value as string) ?? ''}
331
+ onChange={(e) => onChange(e.target.value)}
332
+ placeholder="#000000"
333
+ className={`${INPUT_CLASS} flex-1`}
334
+ />
335
+ </div>
336
+ </div>
337
+ );
338
+
339
+ default:
340
+ return (
341
+ <div>
342
+ <label className={LABEL_CLASS}>{label}</label>
343
+ <input
344
+ type="text"
345
+ value={String(value ?? '')}
346
+ onChange={(e) => onChange(e.target.value)}
347
+ className={INPUT_CLASS}
348
+ />
349
+ </div>
350
+ );
351
+ }
352
+ }
@@ -0,0 +1,338 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
4
+ import * as Dialog from '@radix-ui/react-dialog';
5
+ import {
6
+ Star,
7
+ Type,
8
+ Image,
9
+ LayoutGrid,
10
+ MousePointer,
11
+ Play,
12
+ Grid,
13
+ HelpCircle,
14
+ FileText,
15
+ Code,
16
+ Box,
17
+ X,
18
+ ArrowLeft,
19
+ Search,
20
+ Check,
21
+ } from 'lucide-react';
22
+ import type { LucideIcon } from 'lucide-react';
23
+ import { BlockCatalog } from '@actuate-media/cms-core';
24
+ import type { BlockTypeDefinition, VariantDefinition } from '@actuate-media/cms-core';
25
+
26
+ export interface BlockPickerProps {
27
+ open: boolean;
28
+ onClose: () => void;
29
+ onSelect: (blockType: string, variant?: string) => void;
30
+ }
31
+
32
+ const iconMap: Record<string, LucideIcon> = {
33
+ Star,
34
+ Type,
35
+ Image,
36
+ LayoutGrid,
37
+ MousePointer,
38
+ Play,
39
+ Grid,
40
+ HelpCircle,
41
+ FileText,
42
+ Code,
43
+ };
44
+
45
+ function resolveIcon(name: string): LucideIcon {
46
+ return iconMap[name] ?? Box;
47
+ }
48
+
49
+ export function BlockPicker({ open, onClose, onSelect }: BlockPickerProps) {
50
+ const [search, setSearch] = useState('');
51
+ const [selectedBlock, setSelectedBlock] = useState<BlockTypeDefinition | null>(null);
52
+ const searchInputRef = useRef<HTMLInputElement>(null);
53
+ const variantListRef = useRef<HTMLDivElement>(null);
54
+
55
+ const catalog = useMemo(() => new BlockCatalog(), []);
56
+ const allBlocks = useMemo(() => catalog.getAll(), [catalog]);
57
+
58
+ const filteredBlocks = useMemo(() => {
59
+ if (!search.trim()) return allBlocks;
60
+ const q = search.toLowerCase();
61
+ return allBlocks.filter(
62
+ (block) =>
63
+ block.label.toLowerCase().includes(q) ||
64
+ (block.description?.toLowerCase().includes(q) ?? false)
65
+ );
66
+ }, [allBlocks, search]);
67
+
68
+ const resetState = useCallback(() => {
69
+ setSearch('');
70
+ setSelectedBlock(null);
71
+ }, []);
72
+
73
+ useEffect(() => {
74
+ if (open) {
75
+ resetState();
76
+ }
77
+ }, [open, resetState]);
78
+
79
+ useEffect(() => {
80
+ if (open && !selectedBlock) {
81
+ const timer = setTimeout(() => searchInputRef.current?.focus(), 50);
82
+ return () => clearTimeout(timer);
83
+ }
84
+ }, [open, selectedBlock]);
85
+
86
+ useEffect(() => {
87
+ if (selectedBlock && variantListRef.current) {
88
+ const firstButton = variantListRef.current.querySelector<HTMLButtonElement>(
89
+ 'button[data-variant]'
90
+ );
91
+ firstButton?.focus();
92
+ }
93
+ }, [selectedBlock]);
94
+
95
+ function handleBlockClick(block: BlockTypeDefinition) {
96
+ if (block.variants.length === 1) {
97
+ onSelect(block.type, block.variants[0]!.name);
98
+ onClose();
99
+ return;
100
+ }
101
+ setSelectedBlock(block);
102
+ }
103
+
104
+ function handleVariantClick(variant: VariantDefinition) {
105
+ if (!selectedBlock) return;
106
+ onSelect(selectedBlock.type, variant.name);
107
+ onClose();
108
+ }
109
+
110
+ function handleBack() {
111
+ setSelectedBlock(null);
112
+ }
113
+
114
+ function handleKeyDownOnGrid(e: React.KeyboardEvent) {
115
+ if (e.key === 'Escape') {
116
+ e.stopPropagation();
117
+ onClose();
118
+ }
119
+ }
120
+
121
+ function handleKeyDownOnVariants(e: React.KeyboardEvent) {
122
+ if (e.key === 'Escape') {
123
+ e.stopPropagation();
124
+ handleBack();
125
+ }
126
+ }
127
+
128
+ return (
129
+ <Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
130
+ <Dialog.Portal>
131
+ <Dialog.Overlay className="fixed inset-0 bg-black/50 z-50 motion-safe:animate-in motion-safe:fade-in-0" />
132
+ <Dialog.Content
133
+ className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-card rounded-xl shadow-2xl border border-border z-50 w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"
134
+ aria-describedby={undefined}
135
+ onEscapeKeyDown={() => {
136
+ if (selectedBlock) {
137
+ handleBack();
138
+ }
139
+ }}
140
+ >
141
+ {selectedBlock ? (
142
+ <VariantView
143
+ block={selectedBlock}
144
+ variantListRef={variantListRef}
145
+ onBack={handleBack}
146
+ onVariantClick={handleVariantClick}
147
+ onKeyDown={handleKeyDownOnVariants}
148
+ />
149
+ ) : (
150
+ <BlockTypeGrid
151
+ search={search}
152
+ filteredBlocks={filteredBlocks}
153
+ searchInputRef={searchInputRef}
154
+ onSearchChange={setSearch}
155
+ onBlockClick={handleBlockClick}
156
+ onClose={onClose}
157
+ onKeyDown={handleKeyDownOnGrid}
158
+ />
159
+ )}
160
+ </Dialog.Content>
161
+ </Dialog.Portal>
162
+ </Dialog.Root>
163
+ );
164
+ }
165
+
166
+ interface BlockTypeGridProps {
167
+ search: string;
168
+ filteredBlocks: BlockTypeDefinition[];
169
+ searchInputRef: React.RefObject<HTMLInputElement | null>;
170
+ onSearchChange: (value: string) => void;
171
+ onBlockClick: (block: BlockTypeDefinition) => void;
172
+ onClose: () => void;
173
+ onKeyDown: (e: React.KeyboardEvent) => void;
174
+ }
175
+
176
+ function BlockTypeGrid({
177
+ search,
178
+ filteredBlocks,
179
+ searchInputRef,
180
+ onSearchChange,
181
+ onBlockClick,
182
+ onClose,
183
+ onKeyDown,
184
+ }: BlockTypeGridProps) {
185
+ return (
186
+ <div className="flex flex-col h-full" onKeyDown={onKeyDown}>
187
+ <div className="flex items-center justify-between gap-3 px-5 pt-5 pb-3">
188
+ <Dialog.Title className="text-lg font-medium text-foreground">
189
+ Add Block
190
+ </Dialog.Title>
191
+ <Dialog.Close asChild>
192
+ <button
193
+ type="button"
194
+ className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
195
+ aria-label="Close"
196
+ >
197
+ <X size={18} />
198
+ </button>
199
+ </Dialog.Close>
200
+ </div>
201
+
202
+ <div className="px-5 pb-3">
203
+ <div className="relative">
204
+ <Search
205
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
206
+ size={16}
207
+ />
208
+ <input
209
+ ref={searchInputRef}
210
+ type="text"
211
+ placeholder="Search blocks..."
212
+ value={search}
213
+ onChange={(e) => onSearchChange(e.target.value)}
214
+ className="w-full pl-9 pr-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
215
+ aria-label="Search block types"
216
+ />
217
+ </div>
218
+ </div>
219
+
220
+ <div className="flex-1 overflow-y-auto px-5 pb-5">
221
+ {filteredBlocks.length === 0 ? (
222
+ <div className="flex flex-col items-center justify-center py-12 text-center">
223
+ <Search size={24} className="text-muted-foreground mb-2" />
224
+ <p className="text-sm text-muted-foreground">
225
+ No blocks match &ldquo;{search}&rdquo;
226
+ </p>
227
+ </div>
228
+ ) : (
229
+ <div className="grid grid-cols-2 gap-3" role="list" aria-label="Available block types">
230
+ {filteredBlocks.map((block) => {
231
+ const Icon = resolveIcon(block.icon);
232
+ return (
233
+ <button
234
+ key={block.type}
235
+ type="button"
236
+ role="listitem"
237
+ onClick={() => onBlockClick(block)}
238
+ className="p-4 border border-border rounded-lg hover:border-primary cursor-pointer transition-colors bg-card text-left flex items-start gap-3 group focus:outline-none focus:ring-2 focus:ring-ring"
239
+ >
240
+ <div className="shrink-0 w-9 h-9 rounded-md bg-accent flex items-center justify-center text-foreground group-hover:bg-primary/10 group-hover:text-primary transition-colors">
241
+ <Icon size={18} />
242
+ </div>
243
+ <div className="min-w-0">
244
+ <p className="text-sm font-medium text-foreground truncate">
245
+ {block.label}
246
+ </p>
247
+ {block.description && (
248
+ <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
249
+ {block.description}
250
+ </p>
251
+ )}
252
+ </div>
253
+ </button>
254
+ );
255
+ })}
256
+ </div>
257
+ )}
258
+ </div>
259
+ </div>
260
+ );
261
+ }
262
+
263
+ interface VariantViewProps {
264
+ block: BlockTypeDefinition;
265
+ variantListRef: React.RefObject<HTMLDivElement | null>;
266
+ onBack: () => void;
267
+ onVariantClick: (variant: VariantDefinition) => void;
268
+ onKeyDown: (e: React.KeyboardEvent) => void;
269
+ }
270
+
271
+ function VariantView({
272
+ block,
273
+ variantListRef,
274
+ onBack,
275
+ onVariantClick,
276
+ onKeyDown,
277
+ }: VariantViewProps) {
278
+ const Icon = resolveIcon(block.icon);
279
+
280
+ return (
281
+ <div className="flex flex-col h-full" onKeyDown={onKeyDown}>
282
+ <div className="flex items-center gap-3 px-5 pt-5 pb-3">
283
+ <button
284
+ type="button"
285
+ onClick={onBack}
286
+ className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
287
+ aria-label="Back to block types"
288
+ >
289
+ <ArrowLeft size={18} />
290
+ </button>
291
+ <div className="flex items-center gap-2 min-w-0">
292
+ <Icon size={18} className="text-foreground shrink-0" />
293
+ <h2 className="text-lg font-medium text-foreground truncate">
294
+ {block.label}
295
+ </h2>
296
+ </div>
297
+ </div>
298
+
299
+ {block.description && (
300
+ <p className="text-sm text-muted-foreground px-5 pb-3">
301
+ {block.description}
302
+ </p>
303
+ )}
304
+
305
+ <div className="flex-1 overflow-y-auto px-5 pb-5" ref={variantListRef}>
306
+ <div className="space-y-2" role="list" aria-label={`${block.label} variants`}>
307
+ {block.variants.map((variant, index) => (
308
+ <button
309
+ key={variant.name}
310
+ type="button"
311
+ role="listitem"
312
+ data-variant={variant.name}
313
+ onClick={() => onVariantClick(variant)}
314
+ className="w-full p-4 border border-border rounded-lg hover:border-primary cursor-pointer transition-colors bg-card text-left flex items-center gap-3 group focus:outline-none focus:ring-2 focus:ring-ring"
315
+ >
316
+ <div className="flex-1 min-w-0">
317
+ <div className="flex items-center gap-2">
318
+ <p className="text-sm font-medium text-foreground">
319
+ {variant.label}
320
+ </p>
321
+ {index === 0 && (
322
+ <span className="inline-flex items-center gap-1 text-xs text-primary bg-primary/10 px-1.5 py-0.5 rounded">
323
+ <Check size={10} />
324
+ Default
325
+ </span>
326
+ )}
327
+ </div>
328
+ <p className="text-xs text-muted-foreground mt-0.5">
329
+ {variant.description}
330
+ </p>
331
+ </div>
332
+ </button>
333
+ ))}
334
+ </div>
335
+ </div>
336
+ </div>
337
+ );
338
+ }