@actuate-media/cms-admin 0.6.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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +13 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ErrorBoundary.js +1 -1
- package/dist/components/ErrorBoundary.js.map +1 -1
- package/dist/hooks/useBuilderState.d.ts +49 -0
- package/dist/hooks/useBuilderState.d.ts.map +1 -0
- package/dist/hooks/useBuilderState.js +238 -0
- package/dist/hooks/useBuilderState.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -2
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/page-builder/AIBlockAssist.d.ts +9 -0
- package/dist/views/page-builder/AIBlockAssist.d.ts.map +1 -0
- package/dist/views/page-builder/AIBlockAssist.js +40 -0
- package/dist/views/page-builder/AIBlockAssist.js.map +1 -0
- package/dist/views/page-builder/AIGenerateDialog.d.ts +8 -0
- package/dist/views/page-builder/AIGenerateDialog.d.ts.map +1 -0
- package/dist/views/page-builder/AIGenerateDialog.js +170 -0
- package/dist/views/page-builder/AIGenerateDialog.js.map +1 -0
- package/dist/views/page-builder/BlockEditor.d.ts +11 -0
- package/dist/views/page-builder/BlockEditor.d.ts.map +1 -0
- package/dist/views/page-builder/BlockEditor.js +67 -0
- package/dist/views/page-builder/BlockEditor.js.map +1 -0
- package/dist/views/page-builder/BlockPicker.d.ts +7 -0
- package/dist/views/page-builder/BlockPicker.d.ts.map +1 -0
- package/dist/views/page-builder/BlockPicker.js +102 -0
- package/dist/views/page-builder/BlockPicker.js.map +1 -0
- package/dist/views/page-builder/BottomBar.d.ts +9 -0
- package/dist/views/page-builder/BottomBar.d.ts.map +1 -0
- package/dist/views/page-builder/BottomBar.js +13 -0
- package/dist/views/page-builder/BottomBar.js.map +1 -0
- package/dist/views/page-builder/BuilderToolbar.d.ts +21 -0
- package/dist/views/page-builder/BuilderToolbar.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderToolbar.js +18 -0
- package/dist/views/page-builder/BuilderToolbar.js.map +1 -0
- package/dist/views/page-builder/ContextPanel.d.ts +20 -0
- package/dist/views/page-builder/ContextPanel.d.ts.map +1 -0
- package/dist/views/page-builder/ContextPanel.js +40 -0
- package/dist/views/page-builder/ContextPanel.js.map +1 -0
- package/dist/views/page-builder/DesignScore.d.ts +6 -0
- package/dist/views/page-builder/DesignScore.d.ts.map +1 -0
- package/dist/views/page-builder/DesignScore.js +93 -0
- package/dist/views/page-builder/DesignScore.js.map +1 -0
- package/dist/views/page-builder/NodeSettings.d.ts +12 -0
- package/dist/views/page-builder/NodeSettings.d.ts.map +1 -0
- package/dist/views/page-builder/NodeSettings.js +80 -0
- package/dist/views/page-builder/NodeSettings.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts +8 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -0
- package/dist/views/page-builder/PageBuilder.js +126 -0
- package/dist/views/page-builder/PageBuilder.js.map +1 -0
- package/dist/views/page-builder/PageSettings.d.ts +7 -0
- package/dist/views/page-builder/PageSettings.d.ts.map +1 -0
- package/dist/views/page-builder/PageSettings.js +27 -0
- package/dist/views/page-builder/PageSettings.js.map +1 -0
- package/dist/views/page-builder/SEOPanel.d.ts +10 -0
- package/dist/views/page-builder/SEOPanel.d.ts.map +1 -0
- package/dist/views/page-builder/SEOPanel.js +105 -0
- package/dist/views/page-builder/SEOPanel.js.map +1 -0
- package/dist/views/page-builder/SavedSections.d.ts +6 -0
- package/dist/views/page-builder/SavedSections.d.ts.map +1 -0
- package/dist/views/page-builder/SavedSections.js +145 -0
- package/dist/views/page-builder/SavedSections.js.map +1 -0
- package/dist/views/page-builder/TemplatePicker.d.ts +7 -0
- package/dist/views/page-builder/TemplatePicker.d.ts.map +1 -0
- package/dist/views/page-builder/TemplatePicker.js +68 -0
- package/dist/views/page-builder/TemplatePicker.js.map +1 -0
- package/dist/views/page-builder/block-renderers/CTAPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/CTAPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/CTAPreview.js +19 -0
- package/dist/views/page-builder/block-renderers/CTAPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/CardsPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/CardsPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/CardsPreview.js +22 -0
- package/dist/views/page-builder/block-renderers/CardsPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/CodePreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/CodePreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/CodePreview.js +16 -0
- package/dist/views/page-builder/block-renderers/CodePreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/FAQPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/FAQPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/FAQPreview.js +24 -0
- package/dist/views/page-builder/block-renderers/FAQPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/FallbackPreview.d.ts +6 -0
- package/dist/views/page-builder/block-renderers/FallbackPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/FallbackPreview.js +7 -0
- package/dist/views/page-builder/block-renderers/FallbackPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/FormPreview.js +14 -0
- package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/GalleryPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/GalleryPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/GalleryPreview.js +21 -0
- package/dist/views/page-builder/block-renderers/GalleryPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/HeroPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/HeroPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/HeroPreview.js +19 -0
- package/dist/views/page-builder/block-renderers/HeroPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/ImagePreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/ImagePreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/ImagePreview.js +17 -0
- package/dist/views/page-builder/block-renderers/ImagePreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/TextPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/TextPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/TextPreview.js +26 -0
- package/dist/views/page-builder/block-renderers/TextPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/VideoPreview.d.ts +3 -0
- package/dist/views/page-builder/block-renderers/VideoPreview.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/VideoPreview.js +21 -0
- package/dist/views/page-builder/block-renderers/VideoPreview.js.map +1 -0
- package/dist/views/page-builder/block-renderers/index.d.ts +9 -0
- package/dist/views/page-builder/block-renderers/index.d.ts.map +1 -0
- package/dist/views/page-builder/block-renderers/index.js +25 -0
- package/dist/views/page-builder/block-renderers/index.js.map +1 -0
- package/dist/views/page-builder/canvas/BlockRenderer.d.ts +8 -0
- package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -0
- package/dist/views/page-builder/canvas/BlockRenderer.js +30 -0
- package/dist/views/page-builder/canvas/BlockRenderer.js.map +1 -0
- package/dist/views/page-builder/canvas/BuilderCanvas.d.ts +10 -0
- package/dist/views/page-builder/canvas/BuilderCanvas.d.ts.map +1 -0
- package/dist/views/page-builder/canvas/BuilderCanvas.js +26 -0
- package/dist/views/page-builder/canvas/BuilderCanvas.js.map +1 -0
- package/dist/views/page-builder/canvas/ColumnRenderer.d.ts +8 -0
- package/dist/views/page-builder/canvas/ColumnRenderer.d.ts.map +1 -0
- package/dist/views/page-builder/canvas/ColumnRenderer.js +36 -0
- package/dist/views/page-builder/canvas/ColumnRenderer.js.map +1 -0
- package/dist/views/page-builder/canvas/ContainerRenderer.d.ts +8 -0
- package/dist/views/page-builder/canvas/ContainerRenderer.d.ts.map +1 -0
- package/dist/views/page-builder/canvas/ContainerRenderer.js +33 -0
- package/dist/views/page-builder/canvas/ContainerRenderer.js.map +1 -0
- package/dist/views/page-builder/canvas/RowRenderer.d.ts +8 -0
- package/dist/views/page-builder/canvas/RowRenderer.d.ts.map +1 -0
- package/dist/views/page-builder/canvas/RowRenderer.js +32 -0
- package/dist/views/page-builder/canvas/RowRenderer.js.map +1 -0
- package/dist/views/page-builder/canvas/SectionRenderer.d.ts +8 -0
- package/dist/views/page-builder/canvas/SectionRenderer.d.ts.map +1 -0
- package/dist/views/page-builder/canvas/SectionRenderer.js +54 -0
- package/dist/views/page-builder/canvas/SectionRenderer.js.map +1 -0
- package/dist/views/page-builder/canvas/index.d.ts +3 -0
- package/dist/views/page-builder/canvas/index.d.ts.map +1 -0
- package/dist/views/page-builder/canvas/index.js +2 -0
- package/dist/views/page-builder/canvas/index.js.map +1 -0
- package/package.json +3 -2
- package/src/AdminRoot.tsx +16 -0
- package/src/components/ErrorBoundary.tsx +3 -3
- package/src/hooks/useBuilderState.ts +328 -0
- package/src/index.ts +4 -0
- package/src/layout/Sidebar.tsx +5 -0
- package/src/views/page-builder/AIBlockAssist.tsx +68 -0
- package/src/views/page-builder/AIGenerateDialog.tsx +574 -0
- package/src/views/page-builder/BlockEditor.tsx +352 -0
- package/src/views/page-builder/BlockPicker.tsx +338 -0
- package/src/views/page-builder/BottomBar.tsx +64 -0
- package/src/views/page-builder/BuilderToolbar.tsx +218 -0
- package/src/views/page-builder/ContextPanel.tsx +145 -0
- package/src/views/page-builder/DesignScore.tsx +258 -0
- package/src/views/page-builder/NodeSettings.tsx +515 -0
- package/src/views/page-builder/PageBuilder.tsx +288 -0
- package/src/views/page-builder/PageSettings.tsx +161 -0
- package/src/views/page-builder/SEOPanel.tsx +485 -0
- package/src/views/page-builder/SavedSections.tsx +486 -0
- package/src/views/page-builder/TemplatePicker.tsx +201 -0
- package/src/views/page-builder/block-renderers/CTAPreview.tsx +81 -0
- package/src/views/page-builder/block-renderers/CardsPreview.tsx +71 -0
- package/src/views/page-builder/block-renderers/CodePreview.tsx +46 -0
- package/src/views/page-builder/block-renderers/FAQPreview.tsx +90 -0
- package/src/views/page-builder/block-renderers/FallbackPreview.tsx +18 -0
- package/src/views/page-builder/block-renderers/FormPreview.tsx +69 -0
- package/src/views/page-builder/block-renderers/GalleryPreview.tsx +93 -0
- package/src/views/page-builder/block-renderers/HeroPreview.tsx +103 -0
- package/src/views/page-builder/block-renderers/ImagePreview.tsx +54 -0
- package/src/views/page-builder/block-renderers/TextPreview.tsx +81 -0
- package/src/views/page-builder/block-renderers/VideoPreview.tsx +78 -0
- package/src/views/page-builder/block-renderers/index.ts +34 -0
- package/src/views/page-builder/canvas/BlockRenderer.tsx +62 -0
- package/src/views/page-builder/canvas/BuilderCanvas.tsx +90 -0
- package/src/views/page-builder/canvas/ColumnRenderer.tsx +86 -0
- package/src/views/page-builder/canvas/ContainerRenderer.tsx +71 -0
- package/src/views/page-builder/canvas/RowRenderer.tsx +72 -0
- package/src/views/page-builder/canvas/SectionRenderer.tsx +97 -0
- 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 “{search}”
|
|
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
|
+
}
|