@edgedev/create-edge-app 1.1.27 → 1.1.29

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 (35) hide show
  1. package/edge/components/auth/register.vue +51 -0
  2. package/edge/components/cms/block.vue +363 -42
  3. package/edge/components/cms/blockEditor.vue +50 -3
  4. package/edge/components/cms/codeEditor.vue +39 -2
  5. package/edge/components/cms/htmlContent.vue +10 -2
  6. package/edge/components/cms/init_blocks/footer.html +111 -19
  7. package/edge/components/cms/init_blocks/image.html +8 -0
  8. package/edge/components/cms/init_blocks/post_content.html +3 -2
  9. package/edge/components/cms/init_blocks/post_title_header.html +8 -6
  10. package/edge/components/cms/init_blocks/posts_list.html +6 -5
  11. package/edge/components/cms/mediaCard.vue +13 -2
  12. package/edge/components/cms/mediaManager.vue +35 -5
  13. package/edge/components/cms/menu.vue +384 -61
  14. package/edge/components/cms/optionsSelect.vue +20 -3
  15. package/edge/components/cms/page.vue +160 -18
  16. package/edge/components/cms/site.vue +548 -374
  17. package/edge/components/cms/siteSettingsForm.vue +623 -0
  18. package/edge/components/cms/themeDefaultMenu.vue +258 -22
  19. package/edge/components/cms/themeEditor.vue +95 -11
  20. package/edge/components/editor.vue +1 -0
  21. package/edge/components/formSubtypes/myOrgs.vue +112 -1
  22. package/edge/components/imagePicker.vue +126 -0
  23. package/edge/components/myAccount.vue +1 -0
  24. package/edge/components/myProfile.vue +345 -61
  25. package/edge/components/orgSwitcher.vue +1 -1
  26. package/edge/components/organizationMembers.vue +620 -235
  27. package/edge/components/shad/html.vue +6 -0
  28. package/edge/components/shad/number.vue +2 -2
  29. package/edge/components/sideBar.vue +7 -4
  30. package/edge/components/sideBarContent.vue +1 -1
  31. package/edge/components/userMenu.vue +50 -14
  32. package/edge/composables/global.ts +4 -1
  33. package/edge/composables/siteSettingsTemplate.js +79 -0
  34. package/edge/composables/structuredDataTemplates.js +36 -0
  35. package/package.json +1 -1
@@ -77,6 +77,47 @@ const register = reactive({
77
77
  requestedOrgId: '',
78
78
  })
79
79
 
80
+ const resolveAuthEmail = () => {
81
+ return (
82
+ edgeFirebase?.user?.email
83
+ || edgeFirebase?.user?.firebaseUser?.email
84
+ || edgeFirebase?.user?.firebaseUser?.providerData?.find(p => p?.email)?.email
85
+ || ''
86
+ )
87
+ }
88
+
89
+ const waitForUserSnapshot = async (timeoutMs = 8000) => {
90
+ const findUser = () => {
91
+ const users = Object.values(edgeFirebase.state?.users || {})
92
+ const stagedDocId = edgeFirebase?.user?.stagedDocId
93
+ const uid = edgeFirebase?.user?.uid
94
+ return users.find(u => (stagedDocId && u?.docId === stagedDocId) || (uid && u?.userId === uid))
95
+ }
96
+
97
+ if (findUser())
98
+ return true
99
+
100
+ return await new Promise((resolve) => {
101
+ let timeoutId = null
102
+ const stop = watch(
103
+ () => edgeFirebase.state?.users,
104
+ () => {
105
+ if (findUser()) {
106
+ stop()
107
+ if (timeoutId)
108
+ clearTimeout(timeoutId)
109
+ resolve(true)
110
+ }
111
+ },
112
+ { immediate: true, deep: true },
113
+ )
114
+ timeoutId = setTimeout(() => {
115
+ stop()
116
+ resolve(false)
117
+ }, timeoutMs)
118
+ })
119
+ }
120
+
80
121
  const onSubmit = async () => {
81
122
  state.registering = true
82
123
 
@@ -108,6 +149,9 @@ const onSubmit = async () => {
108
149
  if (state.showRegistrationCode || !props.registrationCode) {
109
150
  register.registrationCode = state.registrationCode
110
151
  }
152
+ if (state.provider === 'email' && register.email) {
153
+ register.meta.email = register.email
154
+ }
111
155
  const result = await edgeFirebase.registerUser(register, state.provider)
112
156
  state.error.error = !result.success
113
157
  if (result.message === `${props.requestedOrgIdLabel} already exists.`) {
@@ -118,6 +162,13 @@ const onSubmit = async () => {
118
162
  result.message = `${orgLabel} already exists. Please choose another.`
119
163
  }
120
164
  state.error.message = result.message.code ? result.message.code : result.message
165
+ if (result.success) {
166
+ const authEmail = resolveAuthEmail()
167
+ if (authEmail && (!register.meta.email || register.meta.email !== authEmail)) {
168
+ await waitForUserSnapshot()
169
+ await edgeFirebase.setUserMeta({ email: authEmail })
170
+ }
171
+ }
121
172
  }
122
173
 
123
174
  state.registering = false
@@ -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]
@@ -106,11 +122,13 @@ const sanitizeQueryItems = (meta) => {
106
122
  return cleaned
107
123
  }
108
124
 
109
- const resetArrayItems = (field) => {
125
+ const resetArrayItems = (field, metaSource = null) => {
126
+ const meta = metaSource || modelValue.value?.meta || {}
127
+ const fieldMeta = meta?.[field]
110
128
  if (!state.arrayItems?.[field]) {
111
129
  state.arrayItems[field] = {}
112
130
  }
113
- for (const schemaItem of modelValue.value.meta[field].schema) {
131
+ for (const schemaItem of (fieldMeta?.schema || [])) {
114
132
  if (schemaItem.type === 'text') {
115
133
  state.arrayItems[field][schemaItem.field] = ''
116
134
  }
@@ -132,25 +150,35 @@ const resetArrayItems = (field) => {
132
150
  const openEditor = async () => {
133
151
  if (!props.editMode)
134
152
  return
135
- for (const key of Object.keys(modelValue.value?.meta || {})) {
136
- if (modelValue.value.meta[key]?.type === 'array' && modelValue.value.meta[key]?.schema) {
137
- if (!modelValue.value.meta[key]?.api) {
138
- resetArrayItems(key)
139
- }
153
+ const blockData = edgeFirebase.data[`${edgeGlobal.edgeState.organizationDocPath}/blocks`]?.[modelValue.value.blockId]
154
+ const templateMeta = blockData?.meta || modelValue.value?.meta || {}
155
+ const storedMeta = modelValue.value?.meta || {}
156
+ const mergedMeta = edgeGlobal.dupObject(templateMeta) || {}
157
+
158
+ for (const key of Object.keys(mergedMeta)) {
159
+ const storedField = storedMeta?.[key]
160
+ if (!storedField || typeof storedField !== 'object')
161
+ continue
162
+ if (storedField.queryItems && typeof storedField.queryItems === 'object') {
163
+ mergedMeta[key].queryItems = edgeGlobal.dupObject(storedField.queryItems)
164
+ }
165
+ if (storedField.limit !== undefined) {
166
+ mergedMeta[key].limit = storedField.limit
140
167
  }
141
168
  }
142
- state.draft = JSON.parse(JSON.stringify(modelValue.value?.values || {}))
143
- state.meta = JSON.parse(JSON.stringify(modelValue.value?.meta || {}))
144
- ensureQueryItemsDefaults(state.meta)
145
- const blockData = edgeFirebase.data[`${edgeGlobal.edgeState.organizationDocPath}/blocks`]?.[modelValue.value.blockId]
146
- state.metaUpdate = edgeGlobal.dupObject(modelValue.value?.meta) || {}
147
- if (blockData?.meta) {
148
- for (const key of Object.keys(blockData.meta)) {
149
- if (!(key in state.metaUpdate)) {
150
- state.metaUpdate[key] = blockData.meta[key]
169
+
170
+ for (const key of Object.keys(mergedMeta || {})) {
171
+ if (mergedMeta[key]?.type === 'array' && mergedMeta[key]?.schema) {
172
+ if (!mergedMeta[key]?.api) {
173
+ resetArrayItems(key, mergedMeta)
151
174
  }
152
175
  }
153
176
  }
177
+
178
+ state.draft = JSON.parse(JSON.stringify(modelValue.value?.values || {}))
179
+ state.meta = JSON.parse(JSON.stringify(mergedMeta || {}))
180
+ ensureQueryItemsDefaults(state.meta)
181
+ state.metaUpdate = edgeGlobal.dupObject(mergedMeta) || {}
154
182
  if (blockData?.values) {
155
183
  for (const key of Object.keys(blockData.values)) {
156
184
  if (!(key in state.draft)) {
@@ -159,19 +187,59 @@ const openEditor = async () => {
159
187
  }
160
188
  }
161
189
  modelValue.value.blockUpdatedAt = new Date().toISOString()
190
+ state.validationErrors = []
162
191
  state.open = true
163
192
  state.afterLoad = true
164
193
  }
165
194
 
166
- const save = () => {
167
- const updated = {
168
- ...modelValue.value,
169
- values: JSON.parse(JSON.stringify(state.draft)),
170
- meta: sanitizeQueryItems(state.meta),
195
+ const normalizeValidationNumber = (value) => {
196
+ if (value === null || value === undefined || value === '')
197
+ return null
198
+ const parsed = Number(value)
199
+ return Number.isNaN(parsed) ? null : parsed
200
+ }
201
+
202
+ const stringLength = (value) => {
203
+ if (value === null || value === undefined)
204
+ return 0
205
+ return String(value).trim().length
206
+ }
207
+
208
+ const validateValueAgainstRules = (value, rules, label, typeHint) => {
209
+ if (!rules || typeof rules !== 'object')
210
+ return []
211
+
212
+ const errors = []
213
+ if (rules.required) {
214
+ const isEmptyArray = Array.isArray(value) && value.length === 0
215
+ const isEmptyString = typeof value === 'string' && stringLength(value) === 0
216
+ if (value === null || value === undefined || isEmptyArray || isEmptyString) {
217
+ errors.push(`${label} is required.`)
218
+ return errors
219
+ }
171
220
  }
172
- modelValue.value = updated
173
- state.open = false
221
+
222
+ if (typeHint === 'number') {
223
+ const numericValue = normalizeValidationNumber(value)
224
+ if (numericValue !== null) {
225
+ if (rules.min !== undefined && numericValue < rules.min)
226
+ errors.push(`${label} must be at least ${rules.min}.`)
227
+ if (rules.max !== undefined && numericValue > rules.max)
228
+ errors.push(`${label} must be ${rules.max} or less.`)
229
+ }
230
+ return errors
231
+ }
232
+
233
+ const length = Array.isArray(value) ? value.length : stringLength(value)
234
+ if (rules.min !== undefined && length < rules.min) {
235
+ errors.push(`${label} must be at least ${rules.min} ${Array.isArray(value) ? 'items' : 'characters'}.`)
236
+ }
237
+ if (rules.max !== undefined && length > rules.max) {
238
+ errors.push(`${label} must be ${rules.max} ${Array.isArray(value) ? 'items' : 'characters'} or less.`)
239
+ }
240
+ return errors
174
241
  }
242
+
175
243
  const orderedMeta = computed(() => {
176
244
  const metaObj = state.metaUpdate || {}
177
245
  const tpl = modelValue.value?.content || ''
@@ -209,6 +277,161 @@ const genTitleFromField = (field) => {
209
277
  .replace(/([a-z])([A-Z])/g, '$1 $2')
210
278
  .replace(/^./, str => str.toUpperCase())
211
279
  }
280
+
281
+ const collectValidationErrors = () => {
282
+ const errors = []
283
+ for (const entry of orderedMeta.value) {
284
+ const label = genTitleFromField(entry)
285
+ const value = state.draft?.[entry.field]
286
+
287
+ if (entry.meta?.type === 'array' && !entry.meta?.api && !entry.meta?.collection) {
288
+ const itemCount = Array.isArray(value) ? value.length : 0
289
+ if (itemCount < 1) {
290
+ errors.push(`${label} requires at least one item.`)
291
+ }
292
+
293
+ if (Array.isArray(value) && entry.meta?.schema) {
294
+ value.forEach((item, index) => {
295
+ for (const schemaItem of entry.meta.schema) {
296
+ const itemLabel = `${label} ${index + 1} · ${genTitleFromField(schemaItem)}`
297
+ const itemValue = item?.[schemaItem.field]
298
+ errors.push(...validateValueAgainstRules(itemValue, schemaItem.validation, itemLabel, schemaItem.type))
299
+ }
300
+ })
301
+ }
302
+ }
303
+
304
+ const topLevelErrors = validateValueAgainstRules(value, entry.meta?.validation, label, entry.meta?.type)
305
+ errors.push(...topLevelErrors)
306
+ }
307
+ return errors
308
+ }
309
+
310
+ const save = () => {
311
+ const validationErrors = collectValidationErrors()
312
+ if (validationErrors.length) {
313
+ state.validationErrors = validationErrors
314
+ return
315
+ }
316
+ state.validationErrors = []
317
+ const updated = {
318
+ ...modelValue.value,
319
+ values: JSON.parse(JSON.stringify(state.draft)),
320
+ meta: sanitizeQueryItems(state.meta),
321
+ }
322
+ modelValue.value = updated
323
+ state.open = false
324
+ }
325
+
326
+ const aiFieldOptions = computed(() => {
327
+ return orderedMeta.value
328
+ .map(entry => ({
329
+ id: entry.field,
330
+ label: genTitleFromField(entry),
331
+ type: entry.meta?.type || 'text',
332
+ }))
333
+ .filter(option => option.type !== 'image' && option.type !== 'color' && !/url/i.test(option.id) && !/color/i.test(option.id))
334
+ })
335
+
336
+ const selectedAiFieldIds = computed(() => {
337
+ return aiFieldOptions.value
338
+ .filter(option => state.aiSelectedFields?.[option.id])
339
+ .map(option => option.id)
340
+ })
341
+
342
+ const allAiFieldsSelected = computed({
343
+ get: () => {
344
+ if (!aiFieldOptions.value.length)
345
+ return false
346
+ return aiFieldOptions.value.every(option => state.aiSelectedFields?.[option.id])
347
+ },
348
+ set: (value) => {
349
+ const next = {}
350
+ aiFieldOptions.value.forEach((option) => {
351
+ next[option.id] = value
352
+ })
353
+ state.aiSelectedFields = next
354
+ },
355
+ })
356
+
357
+ const resetAiSelections = () => {
358
+ const next = {}
359
+ aiFieldOptions.value.forEach((option) => {
360
+ next[option.id] = true
361
+ })
362
+ state.aiSelectedFields = next
363
+ }
364
+
365
+ const openAiDialog = () => {
366
+ state.aiError = ''
367
+ state.aiInstructions = ''
368
+ resetAiSelections()
369
+ state.aiDialogOpen = true
370
+ }
371
+
372
+ const closeAiDialog = () => {
373
+ state.aiDialogOpen = false
374
+ }
375
+
376
+ const generateWithAi = async () => {
377
+ if (state.aiGenerating)
378
+ return
379
+ const selectedFields = selectedAiFieldIds.value
380
+ if (!selectedFields.length) {
381
+ state.aiError = 'Select at least one field for AI generation.'
382
+ return
383
+ }
384
+
385
+ state.aiGenerating = true
386
+ state.aiError = ''
387
+
388
+ try {
389
+ const fields = aiFieldOptions.value.filter(option => selectedFields.includes(option.id))
390
+ const currentValues = selectedFields.reduce((acc, field) => {
391
+ acc[field] = state.draft?.[field]
392
+ return acc
393
+ }, {})
394
+ const meta = selectedFields.reduce((acc, field) => {
395
+ acc[field] = state.meta?.[field]
396
+ return acc
397
+ }, {})
398
+
399
+ const response = await edgeFirebase.runFunction('cms-generateBlockFields', {
400
+ orgId: edgeGlobal.edgeState.currentOrganization,
401
+ uid: edgeFirebase?.user?.uid || '',
402
+ blockId: modelValue.value?.blockId || props.blockId,
403
+ blockName: modelValue.value?.name || '',
404
+ content: modelValue.value?.content || '',
405
+ instructions: state.aiInstructions || '',
406
+ fields,
407
+ currentValues,
408
+ meta,
409
+ })
410
+
411
+ const aiFields = response?.data?.fields || {}
412
+ Object.keys(aiFields).forEach((field) => {
413
+ if (selectedFields.includes(field)) {
414
+ state.draft[field] = aiFields[field]
415
+ blockFormRef.value?.setFieldValue?.(field, aiFields[field])
416
+ }
417
+ })
418
+
419
+ const missingFields = selectedFields.filter(field => !(field in aiFields))
420
+ if (missingFields.length) {
421
+ state.aiError = `AI skipped: ${missingFields.join(', ')}`
422
+ return
423
+ }
424
+
425
+ closeAiDialog()
426
+ }
427
+ catch (error) {
428
+ console.error('Failed to generate block fields with AI', error)
429
+ state.aiError = 'AI generation failed. Try again.'
430
+ }
431
+ finally {
432
+ state.aiGenerating = false
433
+ }
434
+ }
212
435
  const addToArray = async (field) => {
213
436
  state.reload = true
214
437
  state.draft[field].push(JSON.parse(JSON.stringify(state.arrayItems[field])))
@@ -317,13 +540,28 @@ const getTagsFromPosts = computed(() => {
317
540
  <Sheet v-model:open="state.open">
318
541
  <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
542
  <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>
543
+ <div class="flex flex-col gap-3 pr-10 md:flex-row md:items-start md:justify-between">
544
+ <div class="min-w-0">
545
+ <SheetTitle>Edit Block</SheetTitle>
546
+ <SheetDescription v-if="modelValue.synced" class="text-sm text-red-500">
547
+ This is a synced block. Changes made here will be reflected across all instances of this block on your site.
548
+ </SheetDescription>
549
+ </div>
550
+ <edge-shad-button
551
+ type="button"
552
+ size="sm"
553
+ class="h-8 gap-2 md:self-start"
554
+ variant="outline"
555
+ :disabled="!aiFieldOptions.length"
556
+ @click="openAiDialog"
557
+ >
558
+ <Sparkles class="w-4 h-4" />
559
+ Generate with AI
560
+ </edge-shad-button>
561
+ </div>
324
562
  </SheetHeader>
325
563
 
326
- <edge-shad-form>
564
+ <edge-shad-form ref="blockFormRef">
327
565
  <div v-if="orderedMeta.length === 0">
328
566
  <Alert variant="info" class="mt-4 mb-4">
329
567
  <AlertTitle>No editable fields found</AlertTitle>
@@ -456,6 +694,7 @@ const getTagsFromPosts = computed(() => {
456
694
  v-model="state.meta[entry.field].queryItems[option.field]"
457
695
  :option="option"
458
696
  :label="genTitleFromField(option)"
697
+ :multiple="option?.multiple || false"
459
698
  />
460
699
  </div>
461
700
  </template>
@@ -463,9 +702,12 @@ const getTagsFromPosts = computed(() => {
463
702
  </div>
464
703
  </div>
465
704
  <div v-else-if="entry.meta?.type === 'image'" class="w-full">
466
- <div class="relative bg-muted py-2 rounded-md">
705
+ <div class="mb-2 text-sm font-medium text-foreground">
706
+ {{ genTitleFromField(entry) }}
707
+ </div>
708
+ <div class="relative py-2 rounded-md flex items-center justify-center" :class="previewBackgroundClass(state.draft[entry.field])">
467
709
  <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">
710
+ <Dialog v-model:open="state.imageOpenByField[entry.field]">
469
711
  <DialogTrigger as-child>
470
712
  <edge-shad-button variant="outline" class="bg-white text-black hover:bg-gray-200">
471
713
  <ImagePlus class="h-5 w-5 mr-2" />
@@ -482,18 +724,22 @@ const getTagsFromPosts = computed(() => {
482
724
  :site="props.siteId"
483
725
  :select-mode="true"
484
726
  :default-tags="entry.meta.tags"
485
- @select="(url) => { state.draft[entry.field] = url; state.imageOpen = false; }"
727
+ @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
486
728
  />
487
729
  <edge-cms-media-manager
488
730
  v-else
489
731
  :site="props.siteId"
490
732
  :select-mode="true"
491
- @select="(url) => { state.draft[entry.field] = url; state.imageOpen = false; }"
733
+ @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
492
734
  />
493
735
  </DialogContent>
494
736
  </Dialog>
495
737
  </div>
496
- <img v-if="state.draft[entry.field]" :src="state.draft[entry.field]" class="mb-2 max-h-40 mx-auto object-contain">
738
+ <img
739
+ v-if="state.draft[entry.field]"
740
+ :src="state.draft[entry.field]"
741
+ class="max-h-40 max-w-full h-auto w-auto object-contain"
742
+ >
497
743
  </div>
498
744
  </div>
499
745
  <div v-else-if="entry.meta?.option">
@@ -514,15 +760,90 @@ const getTagsFromPosts = computed(() => {
514
760
  </template>
515
761
  </div>
516
762
 
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>
763
+ <div class="sticky bottom-0 bg-background px-6 pb-4 pt-2">
764
+ <Alert v-if="state.validationErrors.length" variant="destructive" class="mb-3">
765
+ <AlertTitle>Fix the highlighted fields</AlertTitle>
766
+ <AlertDescription class="text-sm">
767
+ <div v-for="(error, index) in state.validationErrors" :key="`${error}-${index}`">
768
+ {{ error }}
769
+ </div>
770
+ </AlertDescription>
771
+ </Alert>
772
+ <SheetFooter class="flex justify-between">
773
+ <edge-shad-button variant="destructive" class="text-white" @click="state.open = false">
774
+ Cancel
775
+ </edge-shad-button>
776
+ <edge-shad-button class=" bg-slate-800 hover:bg-slate-400 w-full" @click="save">
777
+ Save changes
778
+ </edge-shad-button>
779
+ </SheetFooter>
780
+ </div>
525
781
  </edge-shad-form>
782
+
783
+ <edge-shad-dialog v-model="state.aiDialogOpen">
784
+ <DialogContent class="max-w-[640px]">
785
+ <DialogHeader>
786
+ <DialogTitle>Generate with AI</DialogTitle>
787
+ <DialogDescription>
788
+ Choose which fields the AI should fill and add any optional instructions.
789
+ </DialogDescription>
790
+ </DialogHeader>
791
+ <div class="space-y-4">
792
+ <edge-shad-textarea
793
+ v-model="state.aiInstructions"
794
+ name="aiInstructions"
795
+ label="Instructions (Optional)"
796
+ placeholder="Share tone, audience, and any details the AI should include."
797
+ />
798
+ <div class="space-y-2">
799
+ <div class="flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-muted-foreground">
800
+ <span>Fields</span>
801
+ <span>{{ selectedAiFieldIds.length }} selected</span>
802
+ </div>
803
+ <edge-shad-checkbox v-model="allAiFieldsSelected" name="aiSelectAll">
804
+ Select all fields
805
+ </edge-shad-checkbox>
806
+ <div v-if="aiFieldOptions.length" class="grid gap-2 md:grid-cols-2">
807
+ <edge-shad-checkbox
808
+ v-for="option in aiFieldOptions"
809
+ :key="option.id"
810
+ v-model="state.aiSelectedFields[option.id]"
811
+ :name="`ai-field-${option.id}`"
812
+ >
813
+ {{ option.label }}
814
+ <span class="ml-2 text-xs text-muted-foreground">({{ option.type }})</span>
815
+ </edge-shad-checkbox>
816
+ </div>
817
+ <Alert v-else variant="info">
818
+ <AlertTitle>No editable fields</AlertTitle>
819
+ <AlertDescription class="text-sm">
820
+ Add editable fields to this block to enable AI generation.
821
+ </AlertDescription>
822
+ </Alert>
823
+ </div>
824
+ <Alert v-if="state.aiError" variant="destructive">
825
+ <AlertTitle>AI generation failed</AlertTitle>
826
+ <AlertDescription class="text-sm">
827
+ {{ state.aiError }}
828
+ </AlertDescription>
829
+ </Alert>
830
+ </div>
831
+ <DialogFooter class="pt-4 flex justify-between">
832
+ <edge-shad-button type="button" variant="destructive" class="text-white" @click="closeAiDialog">
833
+ Cancel
834
+ </edge-shad-button>
835
+ <edge-shad-button
836
+ type="button"
837
+ class="w-full"
838
+ :disabled="state.aiGenerating || !selectedAiFieldIds.length"
839
+ @click="generateWithAi"
840
+ >
841
+ <Loader2 v-if="state.aiGenerating" class="w-4 h-4 mr-2 animate-spin" />
842
+ Generate
843
+ </edge-shad-button>
844
+ </DialogFooter>
845
+ </DialogContent>
846
+ </edge-shad-dialog>
526
847
  </edge-cms-block-sheet-content>
527
848
  </Sheet>
528
849
  </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>