@edgedev/create-edge-app 1.1.27 → 1.1.28

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.
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { useVModel } from '@vueuse/core'
3
- import { ImagePlus, Plus } from 'lucide-vue-next'
3
+ import { ImagePlus, Loader2, Plus, Sparkles } from 'lucide-vue-next'
4
4
  const props = defineProps({
5
5
  modelValue: {
6
6
  type: Object,
@@ -49,6 +49,7 @@ function extractFieldsInOrder(template) {
49
49
  }
50
50
 
51
51
  const modelValue = useVModel(props, 'modelValue', emit)
52
+ const blockFormRef = ref(null)
52
53
 
53
54
  const state = reactive({
54
55
  open: false,
@@ -61,8 +62,23 @@ const state = reactive({
61
62
  loading: true,
62
63
  afterLoad: false,
63
64
  imageOpen: false,
65
+ imageOpenByField: {},
66
+ aiDialogOpen: false,
67
+ aiInstructions: '',
68
+ aiSelectedFields: {},
69
+ aiGenerating: false,
70
+ aiError: '',
71
+ validationErrors: [],
64
72
  })
65
73
 
74
+ const isLightName = (value) => {
75
+ if (!value)
76
+ return false
77
+ return String(value).toLowerCase().includes('light')
78
+ }
79
+
80
+ const previewBackgroundClass = value => (isLightName(value) ? 'bg-neutral-900/90' : 'bg-neutral-100')
81
+
66
82
  const ensureQueryItemsDefaults = (meta) => {
67
83
  Object.keys(meta || {}).forEach((key) => {
68
84
  const cfg = meta[key]
@@ -159,19 +175,59 @@ const openEditor = async () => {
159
175
  }
160
176
  }
161
177
  modelValue.value.blockUpdatedAt = new Date().toISOString()
178
+ state.validationErrors = []
162
179
  state.open = true
163
180
  state.afterLoad = true
164
181
  }
165
182
 
166
- const save = () => {
167
- const updated = {
168
- ...modelValue.value,
169
- values: JSON.parse(JSON.stringify(state.draft)),
170
- meta: sanitizeQueryItems(state.meta),
183
+ const normalizeValidationNumber = (value) => {
184
+ if (value === null || value === undefined || value === '')
185
+ return null
186
+ const parsed = Number(value)
187
+ return Number.isNaN(parsed) ? null : parsed
188
+ }
189
+
190
+ const stringLength = (value) => {
191
+ if (value === null || value === undefined)
192
+ return 0
193
+ return String(value).trim().length
194
+ }
195
+
196
+ const validateValueAgainstRules = (value, rules, label, typeHint) => {
197
+ if (!rules || typeof rules !== 'object')
198
+ return []
199
+
200
+ const errors = []
201
+ if (rules.required) {
202
+ const isEmptyArray = Array.isArray(value) && value.length === 0
203
+ const isEmptyString = typeof value === 'string' && stringLength(value) === 0
204
+ if (value === null || value === undefined || isEmptyArray || isEmptyString) {
205
+ errors.push(`${label} is required.`)
206
+ return errors
207
+ }
171
208
  }
172
- modelValue.value = updated
173
- state.open = false
209
+
210
+ if (typeHint === 'number') {
211
+ const numericValue = normalizeValidationNumber(value)
212
+ if (numericValue !== null) {
213
+ if (rules.min !== undefined && numericValue < rules.min)
214
+ errors.push(`${label} must be at least ${rules.min}.`)
215
+ if (rules.max !== undefined && numericValue > rules.max)
216
+ errors.push(`${label} must be ${rules.max} or less.`)
217
+ }
218
+ return errors
219
+ }
220
+
221
+ const length = Array.isArray(value) ? value.length : stringLength(value)
222
+ if (rules.min !== undefined && length < rules.min) {
223
+ errors.push(`${label} must be at least ${rules.min} ${Array.isArray(value) ? 'items' : 'characters'}.`)
224
+ }
225
+ if (rules.max !== undefined && length > rules.max) {
226
+ errors.push(`${label} must be ${rules.max} ${Array.isArray(value) ? 'items' : 'characters'} or less.`)
227
+ }
228
+ return errors
174
229
  }
230
+
175
231
  const orderedMeta = computed(() => {
176
232
  const metaObj = state.metaUpdate || {}
177
233
  const tpl = modelValue.value?.content || ''
@@ -209,6 +265,161 @@ const genTitleFromField = (field) => {
209
265
  .replace(/([a-z])([A-Z])/g, '$1 $2')
210
266
  .replace(/^./, str => str.toUpperCase())
211
267
  }
268
+
269
+ const collectValidationErrors = () => {
270
+ const errors = []
271
+ for (const entry of orderedMeta.value) {
272
+ const label = genTitleFromField(entry)
273
+ const value = state.draft?.[entry.field]
274
+
275
+ if (entry.meta?.type === 'array' && !entry.meta?.api && !entry.meta?.collection) {
276
+ const itemCount = Array.isArray(value) ? value.length : 0
277
+ if (itemCount < 1) {
278
+ errors.push(`${label} requires at least one item.`)
279
+ }
280
+
281
+ if (Array.isArray(value) && entry.meta?.schema) {
282
+ value.forEach((item, index) => {
283
+ for (const schemaItem of entry.meta.schema) {
284
+ const itemLabel = `${label} ${index + 1} · ${genTitleFromField(schemaItem)}`
285
+ const itemValue = item?.[schemaItem.field]
286
+ errors.push(...validateValueAgainstRules(itemValue, schemaItem.validation, itemLabel, schemaItem.type))
287
+ }
288
+ })
289
+ }
290
+ }
291
+
292
+ const topLevelErrors = validateValueAgainstRules(value, entry.meta?.validation, label, entry.meta?.type)
293
+ errors.push(...topLevelErrors)
294
+ }
295
+ return errors
296
+ }
297
+
298
+ const save = () => {
299
+ const validationErrors = collectValidationErrors()
300
+ if (validationErrors.length) {
301
+ state.validationErrors = validationErrors
302
+ return
303
+ }
304
+ state.validationErrors = []
305
+ const updated = {
306
+ ...modelValue.value,
307
+ values: JSON.parse(JSON.stringify(state.draft)),
308
+ meta: sanitizeQueryItems(state.meta),
309
+ }
310
+ modelValue.value = updated
311
+ state.open = false
312
+ }
313
+
314
+ const aiFieldOptions = computed(() => {
315
+ return orderedMeta.value
316
+ .map(entry => ({
317
+ id: entry.field,
318
+ label: genTitleFromField(entry),
319
+ type: entry.meta?.type || 'text',
320
+ }))
321
+ .filter(option => option.type !== 'image' && option.type !== 'color' && !/url/i.test(option.id) && !/color/i.test(option.id))
322
+ })
323
+
324
+ const selectedAiFieldIds = computed(() => {
325
+ return aiFieldOptions.value
326
+ .filter(option => state.aiSelectedFields?.[option.id])
327
+ .map(option => option.id)
328
+ })
329
+
330
+ const allAiFieldsSelected = computed({
331
+ get: () => {
332
+ if (!aiFieldOptions.value.length)
333
+ return false
334
+ return aiFieldOptions.value.every(option => state.aiSelectedFields?.[option.id])
335
+ },
336
+ set: (value) => {
337
+ const next = {}
338
+ aiFieldOptions.value.forEach((option) => {
339
+ next[option.id] = value
340
+ })
341
+ state.aiSelectedFields = next
342
+ },
343
+ })
344
+
345
+ const resetAiSelections = () => {
346
+ const next = {}
347
+ aiFieldOptions.value.forEach((option) => {
348
+ next[option.id] = true
349
+ })
350
+ state.aiSelectedFields = next
351
+ }
352
+
353
+ const openAiDialog = () => {
354
+ state.aiError = ''
355
+ state.aiInstructions = ''
356
+ resetAiSelections()
357
+ state.aiDialogOpen = true
358
+ }
359
+
360
+ const closeAiDialog = () => {
361
+ state.aiDialogOpen = false
362
+ }
363
+
364
+ const generateWithAi = async () => {
365
+ if (state.aiGenerating)
366
+ return
367
+ const selectedFields = selectedAiFieldIds.value
368
+ if (!selectedFields.length) {
369
+ state.aiError = 'Select at least one field for AI generation.'
370
+ return
371
+ }
372
+
373
+ state.aiGenerating = true
374
+ state.aiError = ''
375
+
376
+ try {
377
+ const fields = aiFieldOptions.value.filter(option => selectedFields.includes(option.id))
378
+ const currentValues = selectedFields.reduce((acc, field) => {
379
+ acc[field] = state.draft?.[field]
380
+ return acc
381
+ }, {})
382
+ const meta = selectedFields.reduce((acc, field) => {
383
+ acc[field] = state.meta?.[field]
384
+ return acc
385
+ }, {})
386
+
387
+ const response = await edgeFirebase.runFunction('cms-generateBlockFields', {
388
+ orgId: edgeGlobal.edgeState.currentOrganization,
389
+ uid: edgeFirebase?.user?.uid || '',
390
+ blockId: modelValue.value?.blockId || props.blockId,
391
+ blockName: modelValue.value?.name || '',
392
+ content: modelValue.value?.content || '',
393
+ instructions: state.aiInstructions || '',
394
+ fields,
395
+ currentValues,
396
+ meta,
397
+ })
398
+
399
+ const aiFields = response?.data?.fields || {}
400
+ Object.keys(aiFields).forEach((field) => {
401
+ if (selectedFields.includes(field)) {
402
+ state.draft[field] = aiFields[field]
403
+ blockFormRef.value?.setFieldValue?.(field, aiFields[field])
404
+ }
405
+ })
406
+
407
+ const missingFields = selectedFields.filter(field => !(field in aiFields))
408
+ if (missingFields.length) {
409
+ state.aiError = `AI skipped: ${missingFields.join(', ')}`
410
+ return
411
+ }
412
+
413
+ closeAiDialog()
414
+ }
415
+ catch (error) {
416
+ console.error('Failed to generate block fields with AI', error)
417
+ state.aiError = 'AI generation failed. Try again.'
418
+ }
419
+ finally {
420
+ state.aiGenerating = false
421
+ }
422
+ }
212
423
  const addToArray = async (field) => {
213
424
  state.reload = true
214
425
  state.draft[field].push(JSON.parse(JSON.stringify(state.arrayItems[field])))
@@ -317,13 +528,28 @@ const getTagsFromPosts = computed(() => {
317
528
  <Sheet v-model:open="state.open">
318
529
  <edge-cms-block-sheet-content v-if="state.afterLoad" class="w-full md:w-1/2 max-w-none sm:max-w-none max-w-2xl">
319
530
  <SheetHeader>
320
- <SheetTitle>Edit Block</SheetTitle>
321
- <SheetDescription v-if="modelValue.synced" class="text-sm text-red-500">
322
- This is a synced block. Changes made here will be reflected across all instances of this block on your site.
323
- </SheetDescription>
531
+ <div class="flex flex-col gap-3 pr-10 md:flex-row md:items-start md:justify-between">
532
+ <div class="min-w-0">
533
+ <SheetTitle>Edit Block</SheetTitle>
534
+ <SheetDescription v-if="modelValue.synced" class="text-sm text-red-500">
535
+ This is a synced block. Changes made here will be reflected across all instances of this block on your site.
536
+ </SheetDescription>
537
+ </div>
538
+ <edge-shad-button
539
+ type="button"
540
+ size="sm"
541
+ class="h-8 gap-2 md:self-start"
542
+ variant="outline"
543
+ :disabled="!aiFieldOptions.length"
544
+ @click="openAiDialog"
545
+ >
546
+ <Sparkles class="w-4 h-4" />
547
+ Generate with AI
548
+ </edge-shad-button>
549
+ </div>
324
550
  </SheetHeader>
325
551
 
326
- <edge-shad-form>
552
+ <edge-shad-form ref="blockFormRef">
327
553
  <div v-if="orderedMeta.length === 0">
328
554
  <Alert variant="info" class="mt-4 mb-4">
329
555
  <AlertTitle>No editable fields found</AlertTitle>
@@ -463,9 +689,12 @@ const getTagsFromPosts = computed(() => {
463
689
  </div>
464
690
  </div>
465
691
  <div v-else-if="entry.meta?.type === 'image'" class="w-full">
466
- <div class="relative bg-muted py-2 rounded-md">
692
+ <div class="mb-2 text-sm font-medium text-foreground">
693
+ {{ genTitleFromField(entry) }}
694
+ </div>
695
+ <div class="relative py-2 rounded-md flex items-center justify-center" :class="previewBackgroundClass(state.draft[entry.field])">
467
696
  <div class="bg-black/80 absolute left-0 top-0 w-full h-full opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center z-10 cursor-pointer">
468
- <Dialog v-model:open="state.imageOpen">
697
+ <Dialog v-model:open="state.imageOpenByField[entry.field]">
469
698
  <DialogTrigger as-child>
470
699
  <edge-shad-button variant="outline" class="bg-white text-black hover:bg-gray-200">
471
700
  <ImagePlus class="h-5 w-5 mr-2" />
@@ -482,18 +711,22 @@ const getTagsFromPosts = computed(() => {
482
711
  :site="props.siteId"
483
712
  :select-mode="true"
484
713
  :default-tags="entry.meta.tags"
485
- @select="(url) => { state.draft[entry.field] = url; state.imageOpen = false; }"
714
+ @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
486
715
  />
487
716
  <edge-cms-media-manager
488
717
  v-else
489
718
  :site="props.siteId"
490
719
  :select-mode="true"
491
- @select="(url) => { state.draft[entry.field] = url; state.imageOpen = false; }"
720
+ @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
492
721
  />
493
722
  </DialogContent>
494
723
  </Dialog>
495
724
  </div>
496
- <img v-if="state.draft[entry.field]" :src="state.draft[entry.field]" class="mb-2 max-h-40 mx-auto object-contain">
725
+ <img
726
+ v-if="state.draft[entry.field]"
727
+ :src="state.draft[entry.field]"
728
+ class="max-h-40 max-w-full h-auto w-auto object-contain"
729
+ >
497
730
  </div>
498
731
  </div>
499
732
  <div v-else-if="entry.meta?.option">
@@ -514,15 +747,90 @@ const getTagsFromPosts = computed(() => {
514
747
  </template>
515
748
  </div>
516
749
 
517
- <SheetFooter class="pt-2 flex justify-between">
518
- <edge-shad-button variant="destructive" class="text-white" @click="state.open = false">
519
- Cancel
520
- </edge-shad-button>
521
- <edge-shad-button class=" bg-slate-800 hover:bg-slate-400 w-full" @click="save">
522
- Save changes
523
- </edge-shad-button>
524
- </SheetFooter>
750
+ <div class="sticky bottom-0 bg-background px-6 pb-4 pt-2">
751
+ <Alert v-if="state.validationErrors.length" variant="destructive" class="mb-3">
752
+ <AlertTitle>Fix the highlighted fields</AlertTitle>
753
+ <AlertDescription class="text-sm">
754
+ <div v-for="(error, index) in state.validationErrors" :key="`${error}-${index}`">
755
+ {{ error }}
756
+ </div>
757
+ </AlertDescription>
758
+ </Alert>
759
+ <SheetFooter class="flex justify-between">
760
+ <edge-shad-button variant="destructive" class="text-white" @click="state.open = false">
761
+ Cancel
762
+ </edge-shad-button>
763
+ <edge-shad-button class=" bg-slate-800 hover:bg-slate-400 w-full" @click="save">
764
+ Save changes
765
+ </edge-shad-button>
766
+ </SheetFooter>
767
+ </div>
525
768
  </edge-shad-form>
769
+
770
+ <edge-shad-dialog v-model="state.aiDialogOpen">
771
+ <DialogContent class="max-w-[640px]">
772
+ <DialogHeader>
773
+ <DialogTitle>Generate with AI</DialogTitle>
774
+ <DialogDescription>
775
+ Choose which fields the AI should fill and add any optional instructions.
776
+ </DialogDescription>
777
+ </DialogHeader>
778
+ <div class="space-y-4">
779
+ <edge-shad-textarea
780
+ v-model="state.aiInstructions"
781
+ name="aiInstructions"
782
+ label="Instructions (Optional)"
783
+ placeholder="Share tone, audience, and any details the AI should include."
784
+ />
785
+ <div class="space-y-2">
786
+ <div class="flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-muted-foreground">
787
+ <span>Fields</span>
788
+ <span>{{ selectedAiFieldIds.length }} selected</span>
789
+ </div>
790
+ <edge-shad-checkbox v-model="allAiFieldsSelected" name="aiSelectAll">
791
+ Select all fields
792
+ </edge-shad-checkbox>
793
+ <div v-if="aiFieldOptions.length" class="grid gap-2 md:grid-cols-2">
794
+ <edge-shad-checkbox
795
+ v-for="option in aiFieldOptions"
796
+ :key="option.id"
797
+ v-model="state.aiSelectedFields[option.id]"
798
+ :name="`ai-field-${option.id}`"
799
+ >
800
+ {{ option.label }}
801
+ <span class="ml-2 text-xs text-muted-foreground">({{ option.type }})</span>
802
+ </edge-shad-checkbox>
803
+ </div>
804
+ <Alert v-else variant="info">
805
+ <AlertTitle>No editable fields</AlertTitle>
806
+ <AlertDescription class="text-sm">
807
+ Add editable fields to this block to enable AI generation.
808
+ </AlertDescription>
809
+ </Alert>
810
+ </div>
811
+ <Alert v-if="state.aiError" variant="destructive">
812
+ <AlertTitle>AI generation failed</AlertTitle>
813
+ <AlertDescription class="text-sm">
814
+ {{ state.aiError }}
815
+ </AlertDescription>
816
+ </Alert>
817
+ </div>
818
+ <DialogFooter class="pt-4 flex justify-between">
819
+ <edge-shad-button type="button" variant="destructive" class="text-white" @click="closeAiDialog">
820
+ Cancel
821
+ </edge-shad-button>
822
+ <edge-shad-button
823
+ type="button"
824
+ class="w-full"
825
+ :disabled="state.aiGenerating || !selectedAiFieldIds.length"
826
+ @click="generateWithAi"
827
+ >
828
+ <Loader2 v-if="state.aiGenerating" class="w-4 h-4 mr-2 animate-spin" />
829
+ Generate
830
+ </edge-shad-button>
831
+ </DialogFooter>
832
+ </DialogContent>
833
+ </edge-shad-dialog>
526
834
  </edge-cms-block-sheet-content>
527
835
  </Sheet>
528
836
  </div>
@@ -38,6 +38,7 @@ const state = reactive({
38
38
  initialBlocksSeeded: false,
39
39
  seedingInitialBlocks: false,
40
40
  previewViewport: 'full',
41
+ previewBlock: null,
41
42
  })
42
43
 
43
44
  const blockSchema = toTypedSchema(z.object({
@@ -95,6 +96,8 @@ const PLACEHOLDERS = {
95
96
 
96
97
  const contentEditorRef = ref(null)
97
98
 
99
+ const ignorePreviewDelete = () => {}
100
+
98
101
  const BLOCK_CONTENT_SNIPPETS = [
99
102
  {
100
103
  label: 'Text Field',
@@ -339,6 +342,8 @@ function handleEditorLineClick(payload, workingDoc) {
339
342
  const tag = findTagAtOffset(workingDoc.content, offset)
340
343
  if (!tag)
341
344
  return
345
+ if (tag.type === 'if')
346
+ return
342
347
 
343
348
  const parsedCfg = safeParseConfig(tag.rawCfg)
344
349
  state.jsonEditorError = ''
@@ -416,6 +421,42 @@ function handleJsonEditorSave() {
416
421
  closeJsonEditor()
417
422
  }
418
423
 
424
+ const buildPreviewBlock = (workingDoc, parsed) => {
425
+ const content = workingDoc?.content || ''
426
+ const nextValues = {}
427
+ const previousValues = state.previewBlock?.values || {}
428
+ Object.keys(parsed.values || {}).forEach((field) => {
429
+ if (previousValues[field] !== undefined)
430
+ nextValues[field] = previousValues[field]
431
+ else
432
+ nextValues[field] = parsed.values[field]
433
+ })
434
+
435
+ const previousMeta = state.previewBlock?.meta || {}
436
+ const nextMeta = {}
437
+ Object.keys(parsed.meta || {}).forEach((field) => {
438
+ if (previousMeta[field]) {
439
+ nextMeta[field] = {
440
+ ...previousMeta[field],
441
+ ...parsed.meta[field],
442
+ }
443
+ }
444
+ else {
445
+ nextMeta[field] = parsed.meta[field]
446
+ }
447
+ })
448
+
449
+ return {
450
+ id: state.previewBlock?.id || 'preview',
451
+ blockId: props.blockId,
452
+ name: workingDoc?.name || state.previewBlock?.name || '',
453
+ content,
454
+ values: nextValues,
455
+ meta: nextMeta,
456
+ synced: !!workingDoc?.synced,
457
+ }
458
+ }
459
+
419
460
  const theme = computed(() => {
420
461
  const theme = edgeGlobal.edgeState.blockEditorTheme || ''
421
462
  let themeContents = null
@@ -443,7 +484,9 @@ watch(headObject, (newHeadElements) => {
443
484
  }, { immediate: true, deep: true })
444
485
 
445
486
  const editorDocUpdates = (workingDoc) => {
446
- state.workingDoc = blockModel(workingDoc.content)
487
+ const parsed = blockModel(workingDoc.content)
488
+ state.workingDoc = parsed
489
+ state.previewBlock = buildPreviewBlock(workingDoc, parsed)
447
490
  console.log('Editor workingDoc update:', state.workingDoc)
448
491
  }
449
492
 
@@ -670,11 +713,15 @@ const getTagsFromBlocks = computed(() => {
670
713
  class="w-full mx-auto bg-card border border-border rounded-lg shadow-sm md:shadow-md"
671
714
  :style="previewViewportStyle"
672
715
  >
673
- <edge-cms-block-picker
716
+ <edge-cms-block
717
+ v-if="state.previewBlock"
718
+ v-model="state.previewBlock"
674
719
  :site-id="edgeGlobal.edgeState.blockEditorSite"
675
720
  :theme="theme"
676
- :block-override="{ content: slotProps.workingDoc.content, values: state.workingDoc.values, meta: state.workingDoc.meta }"
721
+ :edit-mode="true"
677
722
  :viewport-mode="previewViewportMode"
723
+ :block-id="state.previewBlock.id"
724
+ @delete="ignorePreviewDelete"
678
725
  />
679
726
  </div>
680
727
  </div>
@@ -166,6 +166,12 @@ const redo = () => {
166
166
 
167
167
  const editorCompRef = ref(null)
168
168
  const editorInstanceRef = shallowRef(null)
169
+ let editorDomNode = null
170
+
171
+ const stopEnterPropagation = (event) => {
172
+ if (event.key === 'Enter')
173
+ event.stopPropagation()
174
+ }
169
175
 
170
176
  const setCursor = () => {
171
177
  const editor = editorInstanceRef.value
@@ -226,6 +232,9 @@ const runChatGpt = async () => {
226
232
  const handleMount = (editor) => {
227
233
  editorInstanceRef.value = editor
228
234
  editorInstanceRef.value?.getAction('editor.action.formatDocument').run()
235
+ editorDomNode = editor.getDomNode?.()
236
+ if (editorDomNode)
237
+ editorDomNode.addEventListener('keydown', stopEnterPropagation)
229
238
  editor.onMouseDown((event) => {
230
239
  const position = event?.target?.position
231
240
  const model = editor.getModel?.()
@@ -314,6 +323,12 @@ const getChanges = () => {
314
323
  formatCode()
315
324
  }
316
325
  }
326
+
327
+ onBeforeUnmount(() => {
328
+ if (editorDomNode)
329
+ editorDomNode.removeEventListener('keydown', stopEnterPropagation)
330
+ editorDomNode = null
331
+ })
317
332
  </script>
318
333
 
319
334
  <template>