@vibecms/cli 0.1.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/bin/vibe.js +497 -0
- package/package.json +39 -0
- package/templates/components/cms/Analytics.tsx +42 -0
- package/templates/components/cms/BlockRenderer.tsx +37 -0
- package/templates/components/cms/CmsStage.tsx +116 -0
- package/templates/components/cms/EditProvider.tsx +121 -0
- package/templates/components/cms/Editor.tsx +954 -0
- package/templates/components/cms/FormGenerator.tsx +611 -0
- package/templates/components/cms/SEO.tsx +35 -0
- package/templates/components/cms/SiteFooter.tsx +39 -0
- package/templates/components/cms/SiteHeader.tsx +43 -0
- package/templates/components/cms/VisualWrapper.tsx +128 -0
- package/templates/components/cms/fields/ColorPicker.tsx +71 -0
- package/templates/components/cms/fields/IconPicker.tsx +67 -0
- package/templates/components/cms/fields/ImageUpload.tsx +120 -0
- package/templates/components/cms/fields/MediaGallery.tsx +176 -0
- package/templates/components/cms/fields/MultiReferencePicker.tsx +83 -0
- package/templates/components/cms/fields/ReferencePicker.tsx +75 -0
- package/templates/components/cms/fields/RichText.tsx +121 -0
- package/templates/lib/cms/auditor.ts +307 -0
- package/templates/lib/cms/auth-nextauth.ts +26 -0
- package/templates/lib/cms/engine.ts +3 -0
- package/templates/lib/cms/registry.ts +12 -0
- package/templates/lib/cms/sanitize.ts +18 -0
- package/templates/lib/cms/schema.ts +51 -0
- package/templates/lib/cms/store.ts +59 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
// src/components/cms/FormGenerator.tsx
|
|
2
|
+
import React, { useRef, useState } from 'react';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { Input, Textarea, Label, Switch, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, Tooltip, TooltipContent, TooltipTrigger } from '@vibecms/core';
|
|
5
|
+
import { getVibeUIType, getReferenceCollection, getConditionalLogic } from '@/lib/cms/schema';
|
|
6
|
+
import { ImageUpload } from '@/components/cms/fields/ImageUpload';
|
|
7
|
+
import { RichText } from '@/components/cms/fields/RichText';
|
|
8
|
+
import { ColorPicker } from '@/components/cms/fields/ColorPicker';
|
|
9
|
+
import { IconPicker } from '@/components/cms/fields/IconPicker';
|
|
10
|
+
import { ReferencePicker } from '@/components/cms/fields/ReferencePicker';
|
|
11
|
+
import { MultiReferencePicker } from '@/components/cms/fields/MultiReferencePicker';
|
|
12
|
+
import { setFocusedField, layoutWarnings, clipboardBuffer } from '@/lib/cms/store';
|
|
13
|
+
import { useStore } from '@nanostores/react';
|
|
14
|
+
import { ChevronRight, Blocks, Plus, Copy, ClipboardPaste, GripVertical } from 'lucide-react';
|
|
15
|
+
import { toast } from 'sonner';
|
|
16
|
+
import {
|
|
17
|
+
DndContext,
|
|
18
|
+
closestCenter,
|
|
19
|
+
KeyboardSensor,
|
|
20
|
+
PointerSensor,
|
|
21
|
+
useSensor,
|
|
22
|
+
useSensors,
|
|
23
|
+
DragEndEvent
|
|
24
|
+
} from '@dnd-kit/core';
|
|
25
|
+
import {
|
|
26
|
+
arrayMove,
|
|
27
|
+
SortableContext,
|
|
28
|
+
sortableKeyboardCoordinates,
|
|
29
|
+
verticalListSortingStrategy,
|
|
30
|
+
useSortable
|
|
31
|
+
} from '@dnd-kit/sortable';
|
|
32
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
33
|
+
import { HelpCircle } from 'lucide-react';
|
|
34
|
+
|
|
35
|
+
interface FormGeneratorProps {
|
|
36
|
+
schema: z.ZodTypeAny;
|
|
37
|
+
data: any;
|
|
38
|
+
onChange: (path: string, value: any) => void;
|
|
39
|
+
path?: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
activePath?: string;
|
|
42
|
+
setActivePath?: (path: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function unwrapZodSchema(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
46
|
+
let resolved = schema;
|
|
47
|
+
while (
|
|
48
|
+
resolved instanceof z.ZodLazy ||
|
|
49
|
+
resolved instanceof z.ZodDefault ||
|
|
50
|
+
resolved instanceof z.ZodOptional ||
|
|
51
|
+
resolved instanceof z.ZodNullable
|
|
52
|
+
) {
|
|
53
|
+
if (resolved instanceof z.ZodLazy) {
|
|
54
|
+
resolved = (resolved as any).schema;
|
|
55
|
+
} else if (resolved instanceof z.ZodDefault) {
|
|
56
|
+
resolved = (resolved as any)._def.innerType;
|
|
57
|
+
} else if (resolved instanceof z.ZodOptional || resolved instanceof z.ZodNullable) {
|
|
58
|
+
resolved = (resolved as any).unwrap();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function SortableItem({ id, children, isActive }: { id: string; children: React.ReactNode; isActive: boolean }) {
|
|
65
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
|
66
|
+
|
|
67
|
+
const style = {
|
|
68
|
+
transform: CSS.Transform.toString(transform),
|
|
69
|
+
transition,
|
|
70
|
+
zIndex: isDragging ? 50 : 1,
|
|
71
|
+
opacity: isDragging ? 0.5 : 1,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div ref={setNodeRef} style={style} className={isActive ? "relative group border border-neutral-200 bg-white shadow-sm p-3 pl-10 rounded-md mt-2" : "relative group pl-10 mt-2"}>
|
|
76
|
+
<div {...attributes} {...listeners} className="absolute left-0 top-1/2 -translate-y-1/2 p-2 text-neutral-300 hover:text-indigo-500 cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition z-10 bg-white/50 rounded drop-shadow-sm outline-none">
|
|
77
|
+
<GripVertical className="w-5 h-5" />
|
|
78
|
+
</div>
|
|
79
|
+
{children}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function FormGenerator({
|
|
85
|
+
schema,
|
|
86
|
+
data,
|
|
87
|
+
onChange,
|
|
88
|
+
path = '',
|
|
89
|
+
label = '',
|
|
90
|
+
activePath = '',
|
|
91
|
+
setActivePath = () => {},
|
|
92
|
+
}: FormGeneratorProps) {
|
|
93
|
+
const [blockModalOpen, setBlockModalOpen] = useState(false);
|
|
94
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
95
|
+
|
|
96
|
+
const insertTypography = (insertion: string) => {
|
|
97
|
+
if (inputRef.current) {
|
|
98
|
+
const val = String(data || '');
|
|
99
|
+
const start = inputRef.current.selectionStart || val.length;
|
|
100
|
+
const end = inputRef.current.selectionEnd || val.length;
|
|
101
|
+
const newVal = val.substring(0, start) + insertion + val.substring(end);
|
|
102
|
+
onChange(path, newVal);
|
|
103
|
+
|
|
104
|
+
// Restore focus & cursor position after React paints
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
if (inputRef.current) {
|
|
107
|
+
inputRef.current.focus();
|
|
108
|
+
inputRef.current.setSelectionRange(start + insertion.length, start + insertion.length);
|
|
109
|
+
}
|
|
110
|
+
}, 0);
|
|
111
|
+
} else {
|
|
112
|
+
onChange(path, String(data || '') + insertion);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const isAncestor = path !== '' && activePath.startsWith(`${path}.`);
|
|
117
|
+
const isActive = path === activePath;
|
|
118
|
+
|
|
119
|
+
const resolvedSchema = unwrapZodSchema(schema);
|
|
120
|
+
|
|
121
|
+
const activeWarnings = useStore(layoutWarnings);
|
|
122
|
+
const buffer = useStore(clipboardBuffer);
|
|
123
|
+
|
|
124
|
+
const isWarned = !!activeWarnings[path];
|
|
125
|
+
|
|
126
|
+
const uiType = getVibeUIType(resolvedSchema);
|
|
127
|
+
|
|
128
|
+
// If we are not the active path, and not an ancestor of the active path, render a drill-down button instead of children IF complex
|
|
129
|
+
const isComplex = resolvedSchema instanceof z.ZodObject || resolvedSchema instanceof z.ZodArray || resolvedSchema instanceof z.ZodDiscriminatedUnion;
|
|
130
|
+
|
|
131
|
+
// 1. Ancestor Passthrough (Skip rendering this wrapper, just render the specific child leading to activePath)
|
|
132
|
+
// We'll handle this dynamically inside Object/Array blocks.
|
|
133
|
+
|
|
134
|
+
if (resolvedSchema instanceof z.ZodObject) {
|
|
135
|
+
const shape = resolvedSchema.shape;
|
|
136
|
+
|
|
137
|
+
// If not active and not ancestor, render a Drill-Down button
|
|
138
|
+
if (!isActive && !isAncestor && path !== '') {
|
|
139
|
+
return (
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => setActivePath(path)}
|
|
142
|
+
className="w-full flex items-center justify-between p-3 my-1 bg-white hover:bg-neutral-50 border border-neutral-200 hover:border-indigo-300 rounded-lg transition-all text-sm font-medium text-neutral-900 shadow-sm group"
|
|
143
|
+
>
|
|
144
|
+
<span>{label || 'Edit Object'}</span>
|
|
145
|
+
<ChevronRight className="w-4 h-4 text-neutral-400 group-hover:text-indigo-500 transition-colors" />
|
|
146
|
+
</button>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Active or Ancestor Rendering
|
|
151
|
+
return (
|
|
152
|
+
<div className={isActive ? "flex flex-col gap-4 py-2" : "flex flex-col"} id={path ? `editor-field-${path}` : undefined}>
|
|
153
|
+
{isActive && label && <h4 className="font-semibold text-neutral-900 text-sm tracking-wide hidden">{label}</h4>}
|
|
154
|
+
{Object.entries(shape).map(([key, childSchema]) => {
|
|
155
|
+
if (key === '_type') return null;
|
|
156
|
+
|
|
157
|
+
// Conditional Logic Evaluation
|
|
158
|
+
const condition = getConditionalLogic(childSchema as z.ZodTypeAny);
|
|
159
|
+
if (condition) {
|
|
160
|
+
const dependentValue = data?.[condition.field];
|
|
161
|
+
// Stringify to compare securely between 'true'/true, '1'/1 etc
|
|
162
|
+
if (String(dependentValue) !== String(condition.value)) return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
166
|
+
|
|
167
|
+
// If we are an ancestor, ONLY render the child that leads to activePath
|
|
168
|
+
if (isAncestor && !activePath.startsWith(childPath)) return null;
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<FormGenerator
|
|
172
|
+
key={key}
|
|
173
|
+
schema={childSchema as z.ZodTypeAny}
|
|
174
|
+
data={data?.[key]}
|
|
175
|
+
onChange={onChange}
|
|
176
|
+
path={childPath}
|
|
177
|
+
label={key.replace(/^_+/, '').replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase()).trim()}
|
|
178
|
+
activePath={activePath}
|
|
179
|
+
setActivePath={setActivePath}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Handle Polymorphic Block Rendering
|
|
188
|
+
if (resolvedSchema instanceof z.ZodDiscriminatedUnion) {
|
|
189
|
+
const discriminator = (resolvedSchema as any)._def.discriminator as string;
|
|
190
|
+
const typeVal = data?.[discriminator];
|
|
191
|
+
const options = (resolvedSchema as any).options as any[];
|
|
192
|
+
const matchedSchema = options.find(opt => {
|
|
193
|
+
const shapeFn = opt._def.shape;
|
|
194
|
+
const shape = typeof shapeFn === 'function' ? shapeFn() : shapeFn;
|
|
195
|
+
return shape[discriminator].value === typeVal;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (matchedSchema) {
|
|
199
|
+
return (
|
|
200
|
+
<FormGenerator
|
|
201
|
+
schema={matchedSchema}
|
|
202
|
+
data={data}
|
|
203
|
+
onChange={onChange}
|
|
204
|
+
path={path}
|
|
205
|
+
label={label || `${typeVal.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase()).trim()} Block`}
|
|
206
|
+
activePath={activePath}
|
|
207
|
+
setActivePath={setActivePath}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return <div className="text-red-500 text-xs">Unknown block type: {typeVal}</div>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (resolvedSchema instanceof z.ZodArray) {
|
|
215
|
+
if (uiType === 'reference-array') {
|
|
216
|
+
const collection = getReferenceCollection(resolvedSchema) || 'pages';
|
|
217
|
+
return (
|
|
218
|
+
<div id={`editor-field-${path}`} onClick={() => setFocusedField(path, false)} className="w-full">
|
|
219
|
+
<Label className="text-neutral-500 text-xs font-mono uppercase tracking-wider flex items-center gap-2 mb-1.5" htmlFor={`input-${path}`}>
|
|
220
|
+
{label || path}
|
|
221
|
+
</Label>
|
|
222
|
+
<MultiReferencePicker
|
|
223
|
+
value={data || []}
|
|
224
|
+
onChange={(val) => onChange(path, val)}
|
|
225
|
+
label={label || path}
|
|
226
|
+
collection={collection}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const elementSchema = resolvedSchema.element;
|
|
233
|
+
const items = Array.isArray(data) ? data : [];
|
|
234
|
+
|
|
235
|
+
// If not active and not ancestor, render a Drill-Down button
|
|
236
|
+
if (!isActive && !isAncestor && path !== '') {
|
|
237
|
+
return (
|
|
238
|
+
<button
|
|
239
|
+
onClick={() => setActivePath(path)}
|
|
240
|
+
className="w-full flex items-center justify-between p-3 my-1 bg-white hover:bg-neutral-50 border border-neutral-200 hover:border-indigo-300 rounded-lg transition-all text-sm font-medium text-neutral-900 shadow-sm group"
|
|
241
|
+
>
|
|
242
|
+
<span>{label || 'Array'} ({items.length} items)</span>
|
|
243
|
+
<ChevronRight className="w-4 h-4 text-neutral-400 group-hover:text-indigo-500 transition-colors" />
|
|
244
|
+
</button>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
let canPaste = false;
|
|
250
|
+
let pasteAction = null;
|
|
251
|
+
|
|
252
|
+
const resolvedElementSchema = unwrapZodSchema(elementSchema as z.ZodTypeAny);
|
|
253
|
+
let isBlockLibrary = false;
|
|
254
|
+
let blockKeys: string[] = [];
|
|
255
|
+
let blockDiscriminator = '';
|
|
256
|
+
|
|
257
|
+
if (resolvedElementSchema instanceof z.ZodDiscriminatedUnion) {
|
|
258
|
+
isBlockLibrary = true;
|
|
259
|
+
blockDiscriminator = (resolvedElementSchema as any)._def.discriminator as string;
|
|
260
|
+
const options = (resolvedElementSchema as any).options as any[];
|
|
261
|
+
blockKeys = options.map(opt => {
|
|
262
|
+
const shapeFn = opt._def.shape;
|
|
263
|
+
const shape = typeof shapeFn === 'function' ? shapeFn() : shapeFn;
|
|
264
|
+
return shape[blockDiscriminator].value as string;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (buffer) {
|
|
269
|
+
if (isBlockLibrary && blockKeys.includes(buffer.type)) {
|
|
270
|
+
canPaste = true;
|
|
271
|
+
} else if (!isBlockLibrary && buffer.type === 'array-item') {
|
|
272
|
+
canPaste = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (canPaste && buffer && !isBlockLibrary) {
|
|
277
|
+
pasteAction = (
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
onClick={() => {
|
|
281
|
+
const deepClone = JSON.parse(JSON.stringify(buffer.data));
|
|
282
|
+
onChange(path, [...items, deepClone]);
|
|
283
|
+
toast.success('Pasted item from Vibe Clipboard!', { icon: '📋' });
|
|
284
|
+
}}
|
|
285
|
+
title={`Paste ${buffer.type}`}
|
|
286
|
+
className="text-[10px] bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 font-medium px-2 py-1 rounded transition flex items-center gap-1 border border-blue-500/30 uppercase tracking-widest shrink-0"
|
|
287
|
+
>
|
|
288
|
+
<ClipboardPaste className="w-3.5 h-3.5" /> Paste Formatting
|
|
289
|
+
</button>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let addButtons = (
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
className="text-xs bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-300 px-2 py-1 rounded transition-colors shrink-0"
|
|
297
|
+
onClick={() => onChange(path, [...items, typeof elementSchema === 'string' ? '' : {}])}
|
|
298
|
+
>
|
|
299
|
+
+ Add Item
|
|
300
|
+
</button>
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (isBlockLibrary) {
|
|
304
|
+
addButtons = (
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={() => setBlockModalOpen(true)}
|
|
308
|
+
className="text-xs bg-indigo-500 hover:bg-indigo-400 text-white font-medium px-3 py-1.5 rounded-md transition-all shadow-[0_0_15px_rgba(99,102,241,0.3)] flex items-center gap-1.5 shrink-0"
|
|
309
|
+
>
|
|
310
|
+
<Plus className="w-3.5 h-3.5" /> Add Block
|
|
311
|
+
</button>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sensors = useSensors(
|
|
316
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
317
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const itemIds = items.map((item: any, idx: number) => {
|
|
321
|
+
if (typeof item === 'object' && item !== null && !item._vibe_id) {
|
|
322
|
+
item._vibe_id = crypto.randomUUID();
|
|
323
|
+
}
|
|
324
|
+
return (typeof item === 'object' && item._vibe_id) ? item._vibe_id : `item-${idx}`;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
328
|
+
const { active, over } = event;
|
|
329
|
+
if (over && active.id !== over.id) {
|
|
330
|
+
const oldIndex = itemIds.indexOf(active.id as string);
|
|
331
|
+
const newIndex = itemIds.indexOf(over.id as string);
|
|
332
|
+
if (oldIndex !== -1 && newIndex !== -1) {
|
|
333
|
+
const newItems = arrayMove(items, oldIndex, newIndex);
|
|
334
|
+
onChange(path, newItems);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className={isActive ? "flex flex-col gap-4 py-2" : "flex flex-col"} id={path ? `editor-field-${path}` : undefined}>
|
|
341
|
+
{isActive && (
|
|
342
|
+
<div className="flex justify-between items-center mb-4 pb-4 border-b border-neutral-200 gap-2">
|
|
343
|
+
<h4 className="font-semibold text-neutral-900 text-sm tracking-wide hidden">{label}</h4>
|
|
344
|
+
<div className="flex gap-2 items-center flex-wrap ml-auto">
|
|
345
|
+
{pasteAction}
|
|
346
|
+
{addButtons}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{isBlockLibrary && (
|
|
352
|
+
<CommandDialog open={blockModalOpen} onOpenChange={setBlockModalOpen}>
|
|
353
|
+
<CommandInput placeholder="Search blocks..." />
|
|
354
|
+
<CommandList>
|
|
355
|
+
<CommandEmpty>No blocks found.</CommandEmpty>
|
|
356
|
+
{canPaste && buffer && (
|
|
357
|
+
<CommandGroup heading="Clipboard">
|
|
358
|
+
<CommandItem
|
|
359
|
+
onSelect={() => {
|
|
360
|
+
const deepClone = JSON.parse(JSON.stringify(buffer.data));
|
|
361
|
+
onChange(path, [...items, deepClone]);
|
|
362
|
+
setBlockModalOpen(false);
|
|
363
|
+
toast.success('Pasted layout block from Vibe Clipboard!', { icon: '📋' });
|
|
364
|
+
}}
|
|
365
|
+
className="cursor-pointer flex items-center gap-3 py-3 !text-blue-400 hover:!bg-blue-500/10 aria-selected:!bg-blue-500/10 aria-selected:!text-blue-400"
|
|
366
|
+
>
|
|
367
|
+
<div className="p-2 rounded bg-blue-500/20 text-blue-400">
|
|
368
|
+
<ClipboardPaste className="w-4 h-4" />
|
|
369
|
+
</div>
|
|
370
|
+
<div>
|
|
371
|
+
<p className="font-medium capitalize">Paste {buffer.type} Block</p>
|
|
372
|
+
<p className="text-xs opacity-70">Paste from your Vibe Clipboard</p>
|
|
373
|
+
</div>
|
|
374
|
+
</CommandItem>
|
|
375
|
+
</CommandGroup>
|
|
376
|
+
)}
|
|
377
|
+
<CommandGroup heading="Available Blocks">
|
|
378
|
+
{blockKeys.map((typeKey) => (
|
|
379
|
+
<CommandItem
|
|
380
|
+
key={typeKey}
|
|
381
|
+
onSelect={() => {
|
|
382
|
+
onChange(path, [...items, { [blockDiscriminator]: typeKey }]);
|
|
383
|
+
setBlockModalOpen(false);
|
|
384
|
+
}}
|
|
385
|
+
className="cursor-pointer flex items-center gap-3 py-3"
|
|
386
|
+
>
|
|
387
|
+
<div className="p-2 rounded bg-indigo-50 text-indigo-600">
|
|
388
|
+
<Blocks className="w-4 h-4" />
|
|
389
|
+
</div>
|
|
390
|
+
<div>
|
|
391
|
+
<p className="font-medium text-neutral-900 capitalize">{typeKey} Block</p>
|
|
392
|
+
<p className="text-xs text-neutral-500">Add a beautiful {typeKey} section to your layout.</p>
|
|
393
|
+
</div>
|
|
394
|
+
</CommandItem>
|
|
395
|
+
))}
|
|
396
|
+
</CommandGroup>
|
|
397
|
+
</CommandList>
|
|
398
|
+
</CommandDialog>
|
|
399
|
+
)}
|
|
400
|
+
|
|
401
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
402
|
+
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
|
403
|
+
{items.map((item, index) => {
|
|
404
|
+
const childPath = `${path}.${index}`;
|
|
405
|
+
const itemId = itemIds[index];
|
|
406
|
+
if (isAncestor && !activePath.startsWith(childPath)) return null;
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<SortableItem key={itemId} id={itemId} isActive={isActive}>
|
|
410
|
+
{isActive && (
|
|
411
|
+
<div className="absolute -top-3 -right-2 flex gap-1 z-20 opacity-0 group-hover:opacity-100 transition">
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
className="text-xs text-indigo-600 cursor-pointer p-1.5 bg-white rounded-md shadow-sm border border-neutral-200 hover:bg-indigo-50 flex items-center gap-1.5 transition"
|
|
415
|
+
onClick={() => {
|
|
416
|
+
const payload = {
|
|
417
|
+
type: isBlockLibrary ? item[blockDiscriminator] : 'array-item',
|
|
418
|
+
data: JSON.parse(JSON.stringify(item))
|
|
419
|
+
};
|
|
420
|
+
clipboardBuffer.set(payload);
|
|
421
|
+
try {
|
|
422
|
+
localStorage.setItem('vibe-clipboard', JSON.stringify(payload));
|
|
423
|
+
} catch (e) {
|
|
424
|
+
console.warn('Failed to save to clipboard', e);
|
|
425
|
+
}
|
|
426
|
+
toast.success('Block structurally copied to Vibe Clipboard!', { icon: '✨' });
|
|
427
|
+
}}
|
|
428
|
+
title="Duplicate & Copy JSON to Clipboard"
|
|
429
|
+
>
|
|
430
|
+
<Copy className="w-3 h-3" />
|
|
431
|
+
</button>
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
className="text-xs text-red-600 cursor-pointer p-1.5 bg-white rounded-md shadow-sm border border-neutral-200 hover:bg-red-50 flex items-center gap-1.5 transition"
|
|
435
|
+
onClick={() => {
|
|
436
|
+
const copy = [...items];
|
|
437
|
+
copy.splice(index, 1);
|
|
438
|
+
onChange(path, copy);
|
|
439
|
+
}}
|
|
440
|
+
title="Delete Block"
|
|
441
|
+
>
|
|
442
|
+
✕
|
|
443
|
+
</button>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
<FormGenerator
|
|
447
|
+
schema={elementSchema as z.ZodTypeAny}
|
|
448
|
+
data={item}
|
|
449
|
+
onChange={onChange}
|
|
450
|
+
path={childPath}
|
|
451
|
+
label={item?._type ? `${item._type.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase()).trim()} ${index + 1}` : `Item ${index + 1}`}
|
|
452
|
+
activePath={activePath}
|
|
453
|
+
setActivePath={setActivePath}
|
|
454
|
+
/>
|
|
455
|
+
</SortableItem>
|
|
456
|
+
);
|
|
457
|
+
})}
|
|
458
|
+
</SortableContext>
|
|
459
|
+
</DndContext>
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (resolvedSchema instanceof z.ZodString) {
|
|
465
|
+
if (uiType === 'image') {
|
|
466
|
+
return (
|
|
467
|
+
<div id={`editor-field-${path}`} onClick={() => setFocusedField(path, false)}>
|
|
468
|
+
<ImageUpload
|
|
469
|
+
value={data || ''}
|
|
470
|
+
onChange={(val) => onChange(path, val)}
|
|
471
|
+
label={label || path}
|
|
472
|
+
/>
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (uiType === 'rich-text') {
|
|
478
|
+
return (
|
|
479
|
+
<div id={`editor-field-${path}`} onClick={() => setFocusedField(path, false)}>
|
|
480
|
+
<RichText
|
|
481
|
+
value={data || ''}
|
|
482
|
+
onChange={(val) => onChange(path, val)}
|
|
483
|
+
label={label || path}
|
|
484
|
+
/>
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (uiType === 'color-picker') {
|
|
490
|
+
return (
|
|
491
|
+
<div id={`editor-field-${path}`} onClick={() => setFocusedField(path, false)} className="w-full">
|
|
492
|
+
<ColorPicker
|
|
493
|
+
value={data || ''}
|
|
494
|
+
onChange={(val) => onChange(path, val)}
|
|
495
|
+
label={label || path}
|
|
496
|
+
/>
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (uiType === 'icon-picker') {
|
|
502
|
+
return (
|
|
503
|
+
<div id={`editor-field-${path}`} onClick={() => setFocusedField(path, false)} className="w-full">
|
|
504
|
+
<IconPicker
|
|
505
|
+
value={data || ''}
|
|
506
|
+
onChange={(val) => onChange(path, val)}
|
|
507
|
+
label={label || path}
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (uiType === 'reference') {
|
|
514
|
+
const collection = getReferenceCollection(resolvedSchema) || 'pages';
|
|
515
|
+
return (
|
|
516
|
+
<div id={`editor-field-${path}`} onClick={() => setFocusedField(path, false)} className="w-full">
|
|
517
|
+
<ReferencePicker
|
|
518
|
+
value={data || ''}
|
|
519
|
+
onChange={(val) => onChange(path, val)}
|
|
520
|
+
label={label || path}
|
|
521
|
+
collection={collection}
|
|
522
|
+
/>
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<div className={`flex flex-col gap-1.5 p-2 rounded-lg transition-colors ${isWarned ? 'bg-amber-50 border border-amber-200' : ''}`} id={`editor-field-${path}`}>
|
|
529
|
+
<div className="flex justify-between items-center">
|
|
530
|
+
<Label className="text-neutral-500 text-xs font-mono uppercase tracking-wider flex items-center gap-2" htmlFor={`input-${path}`}>
|
|
531
|
+
{label || path}
|
|
532
|
+
{isWarned && <span className="text-[10px] bg-amber-100 text-amber-700 font-bold px-1.5 py-0.5 rounded flex items-center gap-1">⚠ Layout Warning</span>}
|
|
533
|
+
</Label>
|
|
534
|
+
<div className="flex gap-1">
|
|
535
|
+
<Popover>
|
|
536
|
+
<Tooltip>
|
|
537
|
+
<TooltipTrigger className="text-[10px] font-mono bg-neutral-100 hover:bg-neutral-200 text-neutral-500 px-2 py-0.5 rounded transition flex items-center gap-1.5 cursor-pointer">
|
|
538
|
+
<PopoverTrigger className="contents">
|
|
539
|
+
<span className="font-serif italic font-bold text-[11px]">A</span> Typography
|
|
540
|
+
</PopoverTrigger>
|
|
541
|
+
</TooltipTrigger>
|
|
542
|
+
<TooltipContent side="top">
|
|
543
|
+
Advanced text formatting for responsive breakouts.
|
|
544
|
+
</TooltipContent>
|
|
545
|
+
</Tooltip>
|
|
546
|
+
<PopoverContent className="w-56 bg-white border-neutral-200 p-2 z-50 shadow-lg">
|
|
547
|
+
<div className="flex flex-col gap-1">
|
|
548
|
+
<button onClick={() => insertTypography('­')} title="Insert Soft Hyphen (Mobile Text Break)" type="button" className="text-xs text-left text-neutral-700 hover:bg-neutral-50 hover:text-indigo-600 font-medium p-2 rounded transition">
|
|
549
|
+
Insert Soft Hyphen (&shy;)
|
|
550
|
+
</button>
|
|
551
|
+
<button onClick={() => insertTypography('<br />')} title="Insert Manual Line Break" type="button" className="text-xs text-left text-neutral-700 hover:bg-neutral-50 hover:text-indigo-600 font-medium p-2 rounded transition">
|
|
552
|
+
Insert Line Break (<br>)
|
|
553
|
+
</button>
|
|
554
|
+
</div>
|
|
555
|
+
</PopoverContent>
|
|
556
|
+
</Popover>
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
<Input
|
|
560
|
+
ref={inputRef}
|
|
561
|
+
id={`input-${path}`}
|
|
562
|
+
value={data || ''}
|
|
563
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(path, e.target.value)}
|
|
564
|
+
onFocus={() => setFocusedField(path, false)}
|
|
565
|
+
className={`bg-white text-neutral-900 font-medium text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/20 placeholder:text-neutral-400 ${isWarned ? 'border-amber-300 focus-visible:ring-amber-500/20' : 'border-neutral-300'}`}
|
|
566
|
+
/>
|
|
567
|
+
</div>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (resolvedSchema instanceof z.ZodBoolean) {
|
|
572
|
+
return (
|
|
573
|
+
<div className="flex items-center space-x-2" id={`editor-field-${path}`} onFocus={() => setFocusedField(path, false)} onClick={() => setFocusedField(path, false)}>
|
|
574
|
+
<Switch
|
|
575
|
+
id={`input-${path}`}
|
|
576
|
+
checked={!!data}
|
|
577
|
+
onCheckedChange={(checked: boolean) => onChange(path, checked)}
|
|
578
|
+
/>
|
|
579
|
+
<Label className="text-neutral-500 text-xs font-mono uppercase tracking-wider flex items-center gap-2" htmlFor={`input-${path}`}>
|
|
580
|
+
{label || path}
|
|
581
|
+
{label === 'Is Published' && (
|
|
582
|
+
<Tooltip>
|
|
583
|
+
<TooltipTrigger>
|
|
584
|
+
<HelpCircle className="w-3 h-3 text-neutral-400 hover:text-neutral-600 transition-colors cursor-help" />
|
|
585
|
+
</TooltipTrigger>
|
|
586
|
+
<TooltipContent side="right" className="max-w-xs">
|
|
587
|
+
Toggle visibility: When OFF, this page returns a 404 in Production (but remains editable here).
|
|
588
|
+
</TooltipContent>
|
|
589
|
+
</Tooltip>
|
|
590
|
+
)}
|
|
591
|
+
</Label>
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return (
|
|
597
|
+
<div className={`flex flex-col gap-1.5 p-2 rounded-lg transition-colors ${isWarned ? 'bg-amber-50 border border-amber-200' : ''}`} id={`editor-field-${path}`}>
|
|
598
|
+
<Label className="text-neutral-500 text-xs font-mono uppercase tracking-wider flex items-center gap-2" htmlFor={`input-${path}`}>
|
|
599
|
+
{label || path}
|
|
600
|
+
{isWarned && <span className="text-[10px] bg-amber-100 text-amber-700 font-bold px-1.5 py-0.5 rounded">⚠ Layout Warning</span>}
|
|
601
|
+
</Label>
|
|
602
|
+
<Input
|
|
603
|
+
id={`input-${path}`}
|
|
604
|
+
value={data || ''}
|
|
605
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(path, e.target.value)}
|
|
606
|
+
onFocus={() => setFocusedField(path, false)}
|
|
607
|
+
className={`bg-white text-neutral-900 font-medium text-sm focus-visible:ring-2 focus-visible:ring-indigo-500/20 placeholder:text-neutral-400 ${isWarned ? 'border-amber-300 focus-visible:ring-amber-500/20' : 'border-neutral-300'}`}
|
|
608
|
+
/>
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Metadata } from 'next';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates SEO tags dynamically merging page specific info with site.json defaults.
|
|
7
|
+
* Can only be used in App Router Server Components (generateMetadata).
|
|
8
|
+
*/
|
|
9
|
+
export async function generateVibeMetadata(
|
|
10
|
+
pageSeo?: { title?: string; description?: string }
|
|
11
|
+
): Promise<Metadata> {
|
|
12
|
+
const settingsPath = path.join(process.cwd(), 'src/content/settings/site.json');
|
|
13
|
+
let siteTitleTemplate = '%s';
|
|
14
|
+
let siteDesc = '';
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const raw = await fs.readFile(settingsPath, 'utf8');
|
|
18
|
+
const settings = JSON.parse(raw);
|
|
19
|
+
if (settings.seo) {
|
|
20
|
+
siteTitleTemplate = settings.seo.titleTemplate || '%s';
|
|
21
|
+
siteDesc = settings.seo.defaultDescription || '';
|
|
22
|
+
}
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.warn('[VibeCMS] Could not load site.json for metadata generation.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const title = pageSeo?.title
|
|
28
|
+
? siteTitleTemplate.replace('%s', pageSeo.title)
|
|
29
|
+
: siteTitleTemplate.replace('%s', siteTitleTemplate.includes('|') ? siteTitleTemplate.split('|')[1].trim() : 'Home');
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
title,
|
|
33
|
+
description: pageSeo?.description || siteDesc,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { VisualWrapper } from '@/components/cms/VisualWrapper';
|
|
6
|
+
|
|
7
|
+
// Directly import the fallback JSON state
|
|
8
|
+
import siteData from '@/content/settings/site.json';
|
|
9
|
+
|
|
10
|
+
export function SiteFooter() {
|
|
11
|
+
return (
|
|
12
|
+
<footer className="w-full border-t border-neutral-200 bg-neutral-50 overflow-hidden z-10 relative mt-auto" aria-labelledby="footer-heading">
|
|
13
|
+
<h2 id="footer-heading" className="sr-only">Site Legal Footer</h2>
|
|
14
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex flex-col md:flex-row justify-between items-center gap-6">
|
|
15
|
+
|
|
16
|
+
<div className="text-sm font-medium text-neutral-500">
|
|
17
|
+
<VisualWrapper fieldPath="legal.companyName" fallback={siteData.legal?.companyName || "Company Name"}>
|
|
18
|
+
{(value) => <span>© {new Date().getFullYear()} {String(value)}. All rights reserved.</span>}
|
|
19
|
+
</VisualWrapper>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<nav aria-label="Legal" className="flex flex-wrap gap-6 text-sm text-neutral-500">
|
|
23
|
+
{/* German Impressum requirement - standard static route, content configured globally */}
|
|
24
|
+
<Link href="/impressum" className="hover:text-neutral-900 transition">
|
|
25
|
+
Impressum
|
|
26
|
+
</Link>
|
|
27
|
+
|
|
28
|
+
<VisualWrapper fieldPath="legal.privacyUrl" fallback={siteData.legal?.privacyUrl || "/privacy-policy"}>
|
|
29
|
+
{(value) => (
|
|
30
|
+
<Link href={String(value) || '/privacy-policy'} className="hover:text-neutral-900 transition">
|
|
31
|
+
Datenschutzerklärung
|
|
32
|
+
</Link>
|
|
33
|
+
)}
|
|
34
|
+
</VisualWrapper>
|
|
35
|
+
</nav>
|
|
36
|
+
</div>
|
|
37
|
+
</footer>
|
|
38
|
+
);
|
|
39
|
+
}
|