@edgedev/create-edge-app 1.2.33 → 1.2.35

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 (45) hide show
  1. package/README.md +1 -0
  2. package/agents.md +95 -2
  3. package/deploy.sh +136 -0
  4. package/edge/components/cms/block.vue +977 -305
  5. package/edge/components/cms/blockApi.vue +3 -3
  6. package/edge/components/cms/blockEditor.vue +688 -86
  7. package/edge/components/cms/blockPicker.vue +31 -5
  8. package/edge/components/cms/blockRender.vue +3 -3
  9. package/edge/components/cms/blocksManager.vue +790 -82
  10. package/edge/components/cms/codeEditor.vue +15 -6
  11. package/edge/components/cms/fontUpload.vue +318 -2
  12. package/edge/components/cms/htmlContent.vue +825 -93
  13. package/edge/components/cms/init_blocks/contact_us.html +55 -47
  14. package/edge/components/cms/init_blocks/newsletter.html +56 -96
  15. package/edge/components/cms/menu.vue +96 -34
  16. package/edge/components/cms/page.vue +902 -58
  17. package/edge/components/cms/posts.vue +13 -4
  18. package/edge/components/cms/site.vue +638 -87
  19. package/edge/components/cms/siteSettingsForm.vue +19 -9
  20. package/edge/components/cms/sitesManager.vue +5 -4
  21. package/edge/components/cms/themeDefaultMenu.vue +20 -2
  22. package/edge/components/cms/themeEditor.vue +196 -162
  23. package/edge/components/editor.vue +5 -1
  24. package/edge/composables/global.ts +37 -5
  25. package/edge/composables/siteSettingsTemplate.js +2 -0
  26. package/edge/composables/useCmsNewDocs.js +100 -0
  27. package/edge/composables/useEdgeCmsDialogPositionFix.js +19 -0
  28. package/edge/routes/cms/dashboard/blocks/[block].vue +5 -0
  29. package/edge/routes/cms/dashboard/blocks/index.vue +12 -1
  30. package/edge/routes/cms/dashboard/media/index.vue +5 -0
  31. package/edge/routes/cms/dashboard/sites/[site]/[[page]].vue +4 -0
  32. package/edge/routes/cms/dashboard/sites/[site].vue +4 -0
  33. package/edge/routes/cms/dashboard/sites/index.vue +4 -0
  34. package/edge/routes/cms/dashboard/templates/[page].vue +4 -0
  35. package/edge/routes/cms/dashboard/templates/index.vue +4 -0
  36. package/edge/routes/cms/dashboard/themes/[theme].vue +5 -0
  37. package/edge/routes/cms/dashboard/themes/index.vue +330 -1
  38. package/edge-pull.sh +16 -2
  39. package/edge-push.sh +9 -1
  40. package/edge-remote.sh +20 -0
  41. package/edge-status.sh +9 -5
  42. package/edge-update-all.sh +127 -0
  43. package/firebase.json +4 -0
  44. package/nuxt.config.ts +1 -1
  45. package/package.json +2 -2
@@ -26,33 +26,140 @@ const props = defineProps({
26
26
  type: String,
27
27
  default: 'auto',
28
28
  },
29
+ allowDelete: {
30
+ type: Boolean,
31
+ default: true,
32
+ },
33
+ containFixed: {
34
+ type: Boolean,
35
+ default: false,
36
+ },
37
+ disableInteractivePreviewInEdit: {
38
+ type: Boolean,
39
+ default: true,
40
+ },
41
+ overrideClicksInEditMode: {
42
+ type: Boolean,
43
+ default: false,
44
+ },
45
+ allowPreviewContentEdit: {
46
+ type: Boolean,
47
+ default: false,
48
+ },
29
49
  })
30
50
  const emit = defineEmits(['update:modelValue', 'delete'])
31
51
  const edgeFirebase = inject('edgeFirebase')
52
+
53
+ function normalizeConfigLiteral(str) {
54
+ return str
55
+ .replace(/(\{|,)\s*([A-Za-z_][\w-]*)\s*:/g, '$1"$2":')
56
+ .replace(/'/g, '"')
57
+ }
58
+
59
+ function safeParseTagConfig(raw) {
60
+ try {
61
+ return JSON.parse(normalizeConfigLiteral(raw))
62
+ }
63
+ catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ function findMatchingBrace(str, startIdx) {
69
+ let depth = 0
70
+ let inString = false
71
+ let quote = null
72
+ let escape = false
73
+
74
+ for (let i = startIdx; i < str.length; i++) {
75
+ const ch = str[i]
76
+ if (inString) {
77
+ if (escape) {
78
+ escape = false
79
+ continue
80
+ }
81
+ if (ch === '\\') {
82
+ escape = true
83
+ continue
84
+ }
85
+ if (ch === quote) {
86
+ inString = false
87
+ quote = null
88
+ }
89
+ continue
90
+ }
91
+
92
+ if (ch === '"' || ch === '\'') {
93
+ inString = true
94
+ quote = ch
95
+ continue
96
+ }
97
+ if (ch === '{')
98
+ depth++
99
+ else if (ch === '}') {
100
+ depth--
101
+ if (depth === 0)
102
+ return i
103
+ }
104
+ }
105
+
106
+ return -1
107
+ }
108
+
32
109
  function extractFieldsInOrder(template) {
33
110
  if (!template || typeof template !== 'string')
34
111
  return []
112
+
35
113
  const fields = []
36
114
  const seen = new Set()
37
- const TAG_RE = /\{\{\{#[^\s]+\s+(\{[\s\S]*?\})\}\}\}/g
38
- let m = TAG_RE.exec(template)
39
- while (m) {
40
- const cfg = m[1]
41
- const fm = cfg.match(/"field"\s*:\s*"([^"]+)"/)
42
- if (fm && !seen.has(fm[1])) {
43
- fields.push(fm[1])
44
- seen.add(fm[1])
115
+
116
+ const TAG_START_RE = /\{\{\{\#([A-Za-z0-9_-]+)\s*\{/g
117
+ TAG_START_RE.lastIndex = 0
118
+
119
+ for (;;) {
120
+ const m = TAG_START_RE.exec(template)
121
+ if (!m)
122
+ break
123
+
124
+ const configStart = TAG_START_RE.lastIndex - 1
125
+ if (configStart < 0 || template[configStart] !== '{')
126
+ continue
127
+
128
+ const configEnd = findMatchingBrace(template, configStart)
129
+ if (configEnd === -1)
130
+ continue
131
+
132
+ const rawCfg = template.slice(configStart, configEnd + 1)
133
+ const parsedCfg = safeParseTagConfig(rawCfg)
134
+
135
+ let field = typeof parsedCfg?.field === 'string'
136
+ ? parsedCfg.field.trim()
137
+ : ''
138
+
139
+ if (!field) {
140
+ const fm = rawCfg.match(/["']?field["']?\s*:\s*["']([^"']+)["']/)
141
+ field = fm?.[1]?.trim() || ''
45
142
  }
46
- m = TAG_RE.exec(template)
143
+
144
+ if (field && !seen.has(field)) {
145
+ fields.push(field)
146
+ seen.add(field)
147
+ }
148
+
149
+ const closeTriple = template.indexOf('}}}', configEnd)
150
+ TAG_START_RE.lastIndex = closeTriple !== -1 ? closeTriple + 3 : configEnd + 1
47
151
  }
152
+
48
153
  return fields
49
154
  }
50
155
 
51
156
  const modelValue = useVModel(props, 'modelValue', emit)
52
157
  const blockFormRef = ref(null)
158
+ const previewContentEditorRef = ref(null)
53
159
 
54
160
  const state = reactive({
55
161
  open: false,
162
+ editorMode: 'fields',
56
163
  draft: {},
57
164
  delete: false,
58
165
  meta: {},
@@ -69,6 +176,91 @@ const state = reactive({
69
176
  aiGenerating: false,
70
177
  aiError: '',
71
178
  validationErrors: [],
179
+ blockContentDraft: '',
180
+ blockContentDocId: '',
181
+ blockContentUpdating: false,
182
+ blockContentError: '',
183
+ })
184
+
185
+ const INTERACTIVE_CLICK_SELECTOR = [
186
+ '[data-cms-interactive]',
187
+ '.cms-block-interactive',
188
+ '.cms-nav-toggle',
189
+ '.cms-nav-overlay',
190
+ '.cms-nav-panel',
191
+ '.cms-nav-close',
192
+ '.cms-nav-link',
193
+ '.cms-nav-folder-toggle',
194
+ '.cms-nav-folder-menu',
195
+ '[data-cms-nav-folder-toggle]',
196
+ '[data-cms-nav-folder-menu]',
197
+ ].join(', ')
198
+ const EDITOR_CONTROL_CLICK_SELECTOR = [
199
+ '[data-cms-block-control]',
200
+ '[data-cms-block-ignore-editor-click]',
201
+ ].join(', ')
202
+
203
+ const hasFixedPositionInContent = computed(() => {
204
+ const content = String(modelValue.value?.content || '')
205
+ return /\bfixed\b/.test(content)
206
+ })
207
+
208
+ const normalizePreviewType = (value) => {
209
+ return value === 'dark' ? 'dark' : 'light'
210
+ }
211
+
212
+ const resolvedPreviewType = computed(() => normalizePreviewType(modelValue.value?.previewType))
213
+ const sourceBlockDocId = computed(() => {
214
+ const direct = String(modelValue.value?.blockId || '').trim()
215
+ if (direct)
216
+ return direct
217
+ return String(props.blockId || '').trim()
218
+ })
219
+
220
+ const inheritedPreviewType = computed(() => {
221
+ const explicit = modelValue.value?.previewType
222
+ if (explicit === 'light' || explicit === 'dark')
223
+ return explicit
224
+ const docId = sourceBlockDocId.value
225
+ if (!docId)
226
+ return null
227
+ const blockDoc = edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/blocks`]?.[docId]
228
+ const inherited = blockDoc?.previewType
229
+ return (inherited === 'light' || inherited === 'dark') ? inherited : null
230
+ })
231
+
232
+ const effectivePreviewType = computed(() => {
233
+ return normalizePreviewType(inheritedPreviewType.value ?? resolvedPreviewType.value)
234
+ })
235
+
236
+ const canOpenFieldEditor = computed(() => props.editMode)
237
+ const canOpenPreviewContentEditor = computed(() => !props.editMode && props.allowPreviewContentEdit)
238
+ const canOpenEditor = computed(() => canOpenFieldEditor.value || canOpenPreviewContentEditor.value)
239
+
240
+ const shouldContainFixedPreview = computed(() => {
241
+ return (props.editMode || props.containFixed) && hasFixedPositionInContent.value
242
+ })
243
+
244
+ const shouldDisableInteractivePreview = computed(() => {
245
+ return props.editMode && props.disableInteractivePreviewInEdit
246
+ })
247
+
248
+ const blockWrapperClass = computed(() => ({
249
+ 'overflow-visible': shouldContainFixedPreview.value,
250
+ 'min-h-[88px]': props.editMode && shouldContainFixedPreview.value && shouldDisableInteractivePreview.value,
251
+ 'min-h-[calc(100vh-360px)]': props.editMode && shouldContainFixedPreview.value && !shouldDisableInteractivePreview.value,
252
+ 'z-30': shouldContainFixedPreview.value,
253
+ 'bg-white text-black': props.editMode && effectivePreviewType.value === 'light',
254
+ 'bg-neutral-950 text-neutral-50': props.editMode && effectivePreviewType.value === 'dark',
255
+ 'cms-nav-edit-static': shouldDisableInteractivePreview.value,
256
+ }))
257
+
258
+ const blockWrapperStyle = computed(() => {
259
+ if (!shouldContainFixedPreview.value || !props.editMode)
260
+ return null
261
+ return {
262
+ transform: 'translateZ(0)',
263
+ }
72
264
  })
73
265
 
74
266
  const isLightName = (value) => {
@@ -79,6 +271,94 @@ const isLightName = (value) => {
79
271
 
80
272
  const previewBackgroundClass = value => (isLightName(value) ? 'bg-neutral-900/90' : 'bg-neutral-100')
81
273
 
274
+ const PLACEHOLDERS = {
275
+ text: 'Lorem ipsum dolor sit amet.',
276
+ textarea: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
277
+ richtext: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>',
278
+ arrayItem: [
279
+ 'Lorem ipsum dolor sit amet.',
280
+ 'Consectetur adipiscing elit.',
281
+ 'Sed do eiusmod tempor incididunt.',
282
+ ],
283
+ image: 'https://imagedelivery.net/h7EjKG0X9kOxmLp41mxOng/f1f7f610-dfa9-4011-08a3-7a98d95e7500/thumbnail',
284
+ }
285
+
286
+ const BLOCK_CONTENT_SNIPPETS = [
287
+ {
288
+ label: 'Text Field',
289
+ snippet: '{{{#text {"field": "fieldName", "value": "" }}}}',
290
+ description: 'Simple text field placeholder',
291
+ },
292
+ {
293
+ label: 'Text with Options',
294
+ snippet: '{{{#text {"field":"fieldName","title":"Field Label","option":{"field":"fieldName","options":[{"title":"Option 1","name":"option1"},{"title":"Option 2","name":"option2"}],"optionsKey":"title","optionsValue":"name"},"value":"option1"}}}}',
295
+ description: 'Text field with selectable options',
296
+ },
297
+ {
298
+ label: 'Text Area',
299
+ snippet: '{{{#textarea {"field": "fieldName", "value": "" }}}}',
300
+ description: 'Textarea field placeholder',
301
+ },
302
+ {
303
+ label: 'Rich Text',
304
+ snippet: '{{{#richtext {"field": "content", "value": "" }}}}',
305
+ description: 'Rich text field placeholder',
306
+ },
307
+ {
308
+ label: 'Image',
309
+ snippet: '{{{#image {"field": "imageField", "value": "", "tags": ["Backgrounds"] }}}}',
310
+ description: 'Image field placeholder',
311
+ },
312
+ {
313
+ label: 'Array (Basic)',
314
+ snippet: `{{{#array {"field": "items", "value": [] }}}}
315
+ <!-- iterate with {{item}} -->
316
+ {{{/array}}}`,
317
+ description: 'Basic repeating array block',
318
+ },
319
+ {
320
+ label: 'Subarray',
321
+ snippet: `{{{#subarray:child {"field": "item.children", "limit": 0 }}}}
322
+ {{child}}
323
+ {{{/subarray}}}`,
324
+ description: 'Nested array inside an array item',
325
+ },
326
+ {
327
+ label: 'If / Else',
328
+ snippet: `{{{#if {"cond": "condition" }}}}
329
+ <!-- content when condition is true -->
330
+ {{{#else}}}
331
+ <!-- content when condition is false -->
332
+ {{{/if}}}`,
333
+ description: 'Conditional block with optional else',
334
+ },
335
+ ]
336
+
337
+ const insertPreviewSnippet = (snippet) => {
338
+ if (!snippet)
339
+ return
340
+ const editor = previewContentEditorRef.value
341
+ if (!editor || typeof editor.insertSnippet !== 'function')
342
+ return
343
+ editor.insertSnippet(snippet)
344
+ }
345
+
346
+ const previewBlockDisplayName = computed(() => {
347
+ const blockDocId = String(state.blockContentDocId || sourceBlockDocId.value || '').trim()
348
+ const blockDoc = blockDocId
349
+ ? edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/blocks`]?.[blockDocId]
350
+ : null
351
+
352
+ const candidates = [
353
+ blockDoc?.name,
354
+ modelValue.value?.name,
355
+ state.blockContentDocId,
356
+ sourceBlockDocId.value,
357
+ ]
358
+ const found = candidates.find(value => String(value || '').trim())
359
+ return String(found || '').trim() || 'Block'
360
+ })
361
+
82
362
  const ensureQueryItemsDefaults = (meta) => {
83
363
  Object.keys(meta || {}).forEach((key) => {
84
364
  const cfg = meta[key]
@@ -147,8 +427,241 @@ const resetArrayItems = (field, metaSource = null) => {
147
427
  }
148
428
  }
149
429
 
150
- const openEditor = async () => {
151
- if (!props.editMode)
430
+ const TAG_START_RE = /\{\{\{\#([A-Za-z0-9_-]+)\s*\{/g
431
+
432
+ function* iterateTags(html) {
433
+ TAG_START_RE.lastIndex = 0
434
+ for (;;) {
435
+ const m = TAG_START_RE.exec(html)
436
+ if (!m)
437
+ break
438
+
439
+ const type = m[1]
440
+ const configStart = TAG_START_RE.lastIndex - 1
441
+ if (configStart < 0 || html[configStart] !== '{')
442
+ continue
443
+
444
+ const configEnd = findMatchingBrace(html, configStart)
445
+ if (configEnd === -1)
446
+ continue
447
+
448
+ const rawCfg = html.slice(configStart, configEnd + 1)
449
+ const closeTriple = html.indexOf('}}}', configEnd)
450
+ const tagEnd = closeTriple !== -1 ? closeTriple + 3 : configEnd + 1
451
+
452
+ yield { type, rawCfg, tagEnd }
453
+
454
+ TAG_START_RE.lastIndex = tagEnd
455
+ }
456
+ }
457
+
458
+ const parseBlockContentModel = (html) => {
459
+ const values = {}
460
+ const meta = {}
461
+ if (!html)
462
+ return { values, meta }
463
+
464
+ for (const { type, rawCfg } of iterateTags(html)) {
465
+ const cfg = safeParseTagConfig(rawCfg)
466
+ if (!cfg || !cfg.field)
467
+ continue
468
+
469
+ const field = String(cfg.field)
470
+ const title = cfg.title != null ? String(cfg.title) : ''
471
+ const { value: _omitValue, field: _omitField, ...rest } = cfg
472
+ meta[field] = { type, ...rest, title }
473
+
474
+ let val = cfg.value
475
+ if (type === 'image')
476
+ val = !val ? PLACEHOLDERS.image : String(val)
477
+ else if (type === 'text')
478
+ val = !val ? PLACEHOLDERS.text : String(val)
479
+ else if (type === 'array') {
480
+ if (meta[field]?.limit > 0) {
481
+ val = Array(meta[field].limit).fill('placeholder')
482
+ }
483
+ else {
484
+ if (Array.isArray(val)) {
485
+ if (val.length === 0)
486
+ val = PLACEHOLDERS.arrayItem
487
+ }
488
+ else {
489
+ val = PLACEHOLDERS.arrayItem
490
+ }
491
+ }
492
+ }
493
+ else if (type === 'textarea')
494
+ val = !val ? PLACEHOLDERS.textarea : String(val)
495
+ else if (type === 'richtext')
496
+ val = !val ? PLACEHOLDERS.richtext : String(val)
497
+
498
+ values[field] = val
499
+ }
500
+
501
+ return { values, meta }
502
+ }
503
+
504
+ const buildUpdatedBlockDocFromContent = (content, sourceDoc = {}) => {
505
+ const parsed = parseBlockContentModel(content)
506
+ const previousValues = sourceDoc?.values || {}
507
+ const previousMeta = sourceDoc?.meta || {}
508
+ const nextValues = {}
509
+ const nextMeta = {}
510
+
511
+ Object.keys(parsed.values || {}).forEach((field) => {
512
+ if (previousValues[field] !== undefined)
513
+ nextValues[field] = previousValues[field]
514
+ else
515
+ nextValues[field] = parsed.values[field]
516
+ })
517
+
518
+ Object.keys(parsed.meta || {}).forEach((field) => {
519
+ if (previousMeta[field]) {
520
+ nextMeta[field] = {
521
+ ...previousMeta[field],
522
+ ...parsed.meta[field],
523
+ }
524
+ }
525
+ else {
526
+ nextMeta[field] = parsed.meta[field]
527
+ }
528
+ })
529
+
530
+ return { values: nextValues, meta: nextMeta }
531
+ }
532
+
533
+ const blockContentSourceDoc = computed(() => {
534
+ const blockDocId = String(state.blockContentDocId || sourceBlockDocId.value || '').trim()
535
+ if (!blockDocId)
536
+ return null
537
+ return edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/blocks`]?.[blockDocId] || null
538
+ })
539
+
540
+ const blockContentPreviewBlock = computed(() => {
541
+ const content = String(state.blockContentDraft ?? '')
542
+ const sourceDoc = modelValue.value || blockContentSourceDoc.value || {}
543
+ const { values, meta } = buildUpdatedBlockDocFromContent(content, sourceDoc)
544
+ const previewType = modelValue.value?.previewType ?? blockContentSourceDoc.value?.previewType
545
+ return {
546
+ id: modelValue.value?.id || 'preview-content',
547
+ blockId: String(state.blockContentDocId || sourceBlockDocId.value || '').trim(),
548
+ name: previewBlockDisplayName.value,
549
+ previewType: normalizePreviewType(previewType),
550
+ content,
551
+ values,
552
+ meta,
553
+ synced: !!sourceDoc?.synced,
554
+ }
555
+ })
556
+
557
+ const previewContentSurfaceClass = computed(() => {
558
+ const previewType = normalizePreviewType(blockContentPreviewBlock.value?.previewType)
559
+ return previewType === 'light'
560
+ ? 'bg-white text-black'
561
+ : 'bg-neutral-950 text-neutral-50'
562
+ })
563
+
564
+ const previewContentCanvasClass = computed(() => {
565
+ const content = String(blockContentPreviewBlock.value?.content || '')
566
+ const hasFixedContent = /\bfixed\b/.test(content)
567
+ return hasFixedContent ? 'min-h-[calc(100vh-380px)]' : 'min-h-[220px]'
568
+ })
569
+
570
+ const openPreviewContentEditor = async () => {
571
+ const blockDocId = sourceBlockDocId.value
572
+ if (!blockDocId)
573
+ return
574
+
575
+ const blocksPath = `${edgeGlobal.edgeState.organizationDocPath}/blocks`
576
+ if (!edgeFirebase.data?.[blocksPath])
577
+ await edgeFirebase.startSnapshot(blocksPath)
578
+
579
+ const blockData = edgeFirebase.data?.[blocksPath]?.[blockDocId]
580
+ if (!blockData) {
581
+ state.blockContentError = 'Unable to load block content.'
582
+ edgeFirebase?.toast?.error?.('Unable to load block content.')
583
+ return
584
+ }
585
+
586
+ state.editorMode = 'content'
587
+ state.blockContentDocId = blockDocId
588
+ state.blockContentDraft = String(modelValue.value?.content || blockData.content || '')
589
+ state.blockContentError = ''
590
+ state.blockContentUpdating = false
591
+ state.validationErrors = []
592
+ state.open = true
593
+ state.afterLoad = true
594
+ }
595
+
596
+ const updateBlockContent = async () => {
597
+ if (state.blockContentUpdating)
598
+ return
599
+ const blockDocId = String(state.blockContentDocId || sourceBlockDocId.value || '').trim()
600
+ if (!blockDocId)
601
+ return
602
+
603
+ const blocksPath = `${edgeGlobal.edgeState.organizationDocPath}/blocks`
604
+ const blockData = edgeFirebase.data?.[blocksPath]?.[blockDocId] || {}
605
+ const nextContent = String(state.blockContentDraft || '')
606
+ // Update shared block defaults from the source block doc.
607
+ const { values: blockValues, meta: blockMeta } = buildUpdatedBlockDocFromContent(nextContent, blockData)
608
+ // Preserve page/block-instance values when editing block content from preview mode.
609
+ const { values: instanceValues, meta: instanceMeta } = buildUpdatedBlockDocFromContent(nextContent, modelValue.value || {})
610
+ const blockUpdatedAt = new Date().toISOString()
611
+
612
+ const previousModelValue = edgeGlobal.dupObject(modelValue.value || {})
613
+ modelValue.value = {
614
+ ...(modelValue.value || {}),
615
+ content: nextContent,
616
+ values: instanceValues,
617
+ meta: instanceMeta,
618
+ blockUpdatedAt,
619
+ blockId: blockData?.docId || blockDocId,
620
+ }
621
+
622
+ state.blockContentError = ''
623
+ state.blockContentUpdating = true
624
+ try {
625
+ const results = await edgeFirebase.changeDoc(blocksPath, blockDocId, {
626
+ content: nextContent,
627
+ values: blockValues,
628
+ meta: blockMeta,
629
+ blockUpdatedAt,
630
+ })
631
+ if (results?.success === false) {
632
+ throw new Error(results?.error || 'Failed to update block content.')
633
+ }
634
+ edgeFirebase?.toast?.success?.('Block content updated.')
635
+ state.open = false
636
+ }
637
+ catch (error) {
638
+ modelValue.value = previousModelValue
639
+ state.blockContentError = error?.message || 'Unable to save block content.'
640
+ edgeFirebase?.toast?.error?.(state.blockContentError)
641
+ }
642
+ finally {
643
+ state.blockContentUpdating = false
644
+ }
645
+ }
646
+
647
+ const openEditor = async (event) => {
648
+ if (!canOpenEditor.value)
649
+ return
650
+ const target = event?.target
651
+ if (target?.closest?.(EDITOR_CONTROL_CLICK_SELECTOR))
652
+ return
653
+ const shouldOverrideEditClicks = props.editMode && props.overrideClicksInEditMode
654
+ if (shouldOverrideEditClicks) {
655
+ event?.preventDefault?.()
656
+ event?.stopPropagation?.()
657
+ }
658
+ if (canOpenPreviewContentEditor.value) {
659
+ event?.preventDefault?.()
660
+ event?.stopPropagation?.()
661
+ await openPreviewContentEditor()
662
+ return
663
+ }
664
+ if (!shouldOverrideEditClicks && target?.closest?.(INTERACTIVE_CLICK_SELECTOR))
152
665
  return
153
666
  const blockData = edgeFirebase.data[`${edgeGlobal.edgeState.organizationDocPath}/blocks`]?.[modelValue.value.blockId]
154
667
  const templateMeta = blockData?.meta || modelValue.value?.meta || {}
@@ -188,10 +701,16 @@ const openEditor = async () => {
188
701
  }
189
702
  modelValue.value.blockUpdatedAt = new Date().toISOString()
190
703
  state.validationErrors = []
704
+ state.editorMode = 'fields'
191
705
  state.open = true
192
706
  state.afterLoad = true
193
707
  }
194
708
 
709
+ const isLimitOne = (field) => {
710
+ const limit = Number(state.meta?.[field]?.limit)
711
+ return Number.isFinite(limit) && limit === 1
712
+ }
713
+
195
714
  const normalizeValidationNumber = (value) => {
196
715
  if (value === null || value === undefined || value === '')
197
716
  return null
@@ -264,6 +783,31 @@ const orderedMeta = computed(() => {
264
783
  return out
265
784
  })
266
785
 
786
+ const hasEditableArrayControls = (entry) => {
787
+ if (!entry?.meta)
788
+ return false
789
+
790
+ // Manual arrays are editable through the schema/list UI.
791
+ if (!entry.meta?.api && !entry.meta?.collection)
792
+ return true
793
+
794
+ const collectionPath = entry.meta?.collection?.path
795
+ const supportsQueryControls = collectionPath !== 'post'
796
+ const queryOptions = Array.isArray(entry.meta?.queryOptions) ? entry.meta.queryOptions : []
797
+ const hasQueryOptions = supportsQueryControls && queryOptions.length > 0
798
+ const hasLimitControl = supportsQueryControls && !isLimitOne(entry.field)
799
+
800
+ return hasQueryOptions || hasLimitControl
801
+ }
802
+
803
+ const editableMetaEntries = computed(() => {
804
+ return orderedMeta.value.filter((entry) => {
805
+ if (entry?.meta?.type === 'array')
806
+ return hasEditableArrayControls(entry)
807
+ return true
808
+ })
809
+ })
810
+
267
811
  const genTitleFromField = (field) => {
268
812
  if (field?.title)
269
813
  return field.title
@@ -324,7 +868,7 @@ const save = () => {
324
868
  }
325
869
 
326
870
  const aiFieldOptions = computed(() => {
327
- return orderedMeta.value
871
+ return editableMetaEntries.value
328
872
  .map(entry => ({
329
873
  id: entry.field,
330
874
  label: genTitleFromField(entry),
@@ -479,27 +1023,33 @@ const getTagsFromPosts = computed(() => {
479
1023
  <template>
480
1024
  <div>
481
1025
  <div
482
- :class="{ 'cursor-pointer': props.editMode }"
483
- class="relative group "
484
- @click="openEditor"
1026
+ :class="[{ 'cursor-pointer': canOpenEditor }, blockWrapperClass]"
1027
+ :style="blockWrapperStyle"
1028
+ class="relative group"
1029
+ @click.capture="openEditor($event)"
485
1030
  >
486
1031
  <!-- Content -->
487
- <edge-cms-block-api :site-id="props.siteId" :theme="props.theme" :content="modelValue?.content" :values="modelValue?.values" :meta="modelValue?.meta" :viewport-mode="props.viewportMode" @pending="state.loading = $event" />
488
- <edge-cms-block-render
489
- v-if="state.loading"
490
- :content="loadingRender(modelValue?.content)"
491
- :values="modelValue?.values"
492
- :meta="modelValue?.meta"
493
- :theme="props.theme"
494
- :viewport-mode="props.viewportMode"
495
- />
1032
+ <div :class="props.editMode && props.overrideClicksInEditMode ? 'pointer-events-none' : ''">
1033
+ <edge-cms-block-api :site-id="props.siteId" :theme="props.theme" :content="modelValue?.content" :values="modelValue?.values" :meta="modelValue?.meta" :viewport-mode="props.viewportMode" @pending="state.loading = $event" />
1034
+ <edge-cms-block-render
1035
+ v-if="state.loading"
1036
+ :content="loadingRender(modelValue?.content)"
1037
+ :values="modelValue?.values"
1038
+ :meta="modelValue?.meta"
1039
+ :theme="props.theme"
1040
+ :viewport-mode="props.viewportMode"
1041
+ />
1042
+ </div>
496
1043
  <!-- Darken overlay on hover -->
497
- <div v-if="props.editMode" class="pointer-events-none absolute inset-0 bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 z-10" />
1044
+ <div v-if="props.editMode" class="pointer-events-none absolute inset-0 bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 z-[10000]" />
498
1045
 
499
1046
  <!-- Hover controls -->
500
- <div v-if="props.editMode" class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
1047
+ <div
1048
+ v-if="props.editMode"
1049
+ class="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-[10001]"
1050
+ >
501
1051
  <!-- Delete button top right -->
502
- <div class="absolute top-2 right-2">
1052
+ <div v-if="props.allowDelete" data-cms-block-control class="pointer-events-auto absolute top-2 right-2">
503
1053
  <edge-shad-button
504
1054
  variant="destructive"
505
1055
  size="icon"
@@ -538,313 +1088,435 @@ const getTagsFromPosts = computed(() => {
538
1088
  </edge-shad-dialog>
539
1089
 
540
1090
  <Sheet v-model:open="state.open">
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">
542
- <SheetHeader>
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>
1091
+ <edge-cms-block-sheet-content
1092
+ v-if="state.afterLoad"
1093
+ :side="state.editorMode === 'content' ? 'left' : 'right'"
1094
+ :class="state.editorMode === 'content'
1095
+ ? 'w-full max-w-none sm:max-w-none md:max-w-none'
1096
+ : 'w-full md:w-1/2 max-w-none sm:max-w-none max-w-2xl'"
1097
+ >
1098
+ <template v-if="state.editorMode === 'content'">
1099
+ <SheetHeader>
1100
+ <div class="flex flex-col gap-2 pr-10">
1101
+ <div class="min-w-0">
1102
+ <SheetTitle>Edit Block Content: {{ previewBlockDisplayName }}</SheetTitle>
1103
+ <SheetDescription class="text-sm text-muted-foreground">
1104
+ Update this block template and save it globally. Changes will sync to every page using this block.
1105
+ </SheetDescription>
1106
+ </div>
549
1107
  </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>
562
- </SheetHeader>
563
-
564
- <edge-shad-form ref="blockFormRef">
565
- <div v-if="orderedMeta.length === 0">
566
- <Alert variant="info" class="mt-4 mb-4">
567
- <AlertTitle>No editable fields found</AlertTitle>
1108
+ </SheetHeader>
1109
+ <div class="px-6 pb-1 pt-3 flex h-[calc(100vh-116px)] flex-col gap-2 overflow-hidden">
1110
+ <div class="flex-1 min-h-0 grid grid-cols-1 xl:grid-cols-2 gap-3">
1111
+ <div class="min-h-0">
1112
+ <edge-cms-code-editor
1113
+ ref="previewContentEditorRef"
1114
+ v-model="state.blockContentDraft"
1115
+ title="Block Content"
1116
+ language="handlebars"
1117
+ name="preview-block-content"
1118
+ :enable-formatting="false"
1119
+ height="calc(100vh - 295px)"
1120
+ class="h-full min-h-0"
1121
+ >
1122
+ <template #end-actions>
1123
+ <DropdownMenu>
1124
+ <DropdownMenuTrigger as-child>
1125
+ <edge-shad-button
1126
+ type="button"
1127
+ size="sm"
1128
+ variant="outline"
1129
+ class="h-8 px-2 text-[11px] uppercase tracking-wide"
1130
+ >
1131
+ Dynamic Content
1132
+ </edge-shad-button>
1133
+ </DropdownMenuTrigger>
1134
+ <DropdownMenuContent align="end" class="w-72">
1135
+ <DropdownMenuItem
1136
+ v-for="snippet in BLOCK_CONTENT_SNIPPETS"
1137
+ :key="snippet.label"
1138
+ class="cursor-pointer flex-col items-start gap-0.5"
1139
+ @click="insertPreviewSnippet(snippet.snippet)"
1140
+ >
1141
+ <span class="text-sm font-medium">{{ snippet.label }}</span>
1142
+ <span class="text-xs text-muted-foreground whitespace-normal">{{ snippet.description }}</span>
1143
+ </DropdownMenuItem>
1144
+ </DropdownMenuContent>
1145
+ </DropdownMenu>
1146
+ </template>
1147
+ </edge-cms-code-editor>
1148
+ </div>
1149
+ <div class="min-h-0 rounded-md border border-border bg-card overflow-hidden flex flex-col">
1150
+ <div class="px-3 py-2 border-b border-border text-xs font-semibold uppercase tracking-wide text-muted-foreground bg-muted/40">
1151
+ Live Preview
1152
+ </div>
1153
+ <div class="flex-1 min-h-0 overflow-y-auto p-3">
1154
+ <div class="relative overflow-visible rounded-none" :class="[previewContentSurfaceClass, previewContentCanvasClass]" style="transform: translateZ(0);">
1155
+ <edge-cms-block-api
1156
+ :site-id="props.siteId"
1157
+ :theme="props.theme"
1158
+ :content="blockContentPreviewBlock.content"
1159
+ :values="blockContentPreviewBlock.values"
1160
+ :meta="blockContentPreviewBlock.meta"
1161
+ :viewport-mode="props.viewportMode"
1162
+ />
1163
+ </div>
1164
+ </div>
1165
+ </div>
1166
+ </div>
1167
+ <Alert v-if="state.blockContentError" variant="destructive" class="mb-2">
1168
+ <AlertTitle>Save failed</AlertTitle>
568
1169
  <AlertDescription class="text-sm">
569
- This block does not have any editable fields defined.
1170
+ {{ state.blockContentError }}
570
1171
  </AlertDescription>
571
1172
  </Alert>
1173
+ <SheetFooter class="flex justify-between pt-1 pb-0">
1174
+ <edge-shad-button
1175
+ variant="destructive"
1176
+ class="text-white"
1177
+ :disabled="state.blockContentUpdating"
1178
+ @click="state.open = false"
1179
+ >
1180
+ Cancel
1181
+ </edge-shad-button>
1182
+ <edge-shad-button
1183
+ class="bg-slate-800 hover:bg-slate-400 w-full"
1184
+ :disabled="state.blockContentUpdating"
1185
+ @click="updateBlockContent"
1186
+ >
1187
+ <Loader2 v-if="state.blockContentUpdating" class="w-4 h-4 mr-2 animate-spin" />
1188
+ Update
1189
+ </edge-shad-button>
1190
+ </SheetFooter>
572
1191
  </div>
573
- <div :class="modelValue.synced ? 'h-[calc(100vh-160px)]' : 'h-[calc(100vh-130px)]'" class="p-6 space-y-4 overflow-y-auto">
574
- <template v-for="entry in orderedMeta" :key="entry.field">
575
- <div v-if="entry.meta.type === 'array'">
576
- <div v-if="!entry.meta?.api && !entry.meta?.collection">
577
- <div v-if="entry.meta?.schema">
578
- <Card v-if="!state.reload" class="mb-4 bg-white shadow-sm border border-gray-200 p-4">
579
- <CardHeader class="p-0 mb-2">
580
- <div class="relative flex items-center bg-secondary p-2 justify-between sticky top-0 z-10 bg-primary rounded">
581
- <span class="text-lg font-semibold whitespace-nowrap pr-1"> {{ genTitleFromField(entry) }}</span>
582
- <div class="flex w-full items-center">
583
- <div class="w-full border-t border-gray-300 dark:border-white/15" aria-hidden="true" />
584
- <edge-shad-button variant="text" class="hover:text-primary/50 text-xs h-[26px] text-primary" @click="state.editMode = !state.editMode">
585
- <Popover>
586
- <PopoverTrigger as-child>
587
- <edge-shad-button
588
- variant="text"
589
- type="submit"
590
- class="bg-secondary hover:text-primary/50 text-xs h-[26px] text-primary"
591
- >
592
- <Plus class="w-4 h-4" />
593
- </edge-shad-button>
594
- </PopoverTrigger>
595
- <PopoverContent class="!w-80 mr-20">
596
- <Card class="border-none shadow-none p-4">
597
- <template v-for="schemaItem in entry.meta.schema" :key="schemaItem.field">
598
- <edge-cms-block-input
599
- v-model="state.arrayItems[entry.field][schemaItem.field]"
600
- :type="schemaItem.type"
601
- :field="schemaItem.field"
602
- :schema="schemaItem"
603
- :label="genTitleFromField(schemaItem)"
604
- />
605
- </template>
606
- <CardFooter class="mt-2 flex justify-end">
607
- <edge-shad-button
608
- class="bg-secondary hover:text-white text-xs h-[26px] text-primary"
609
- @click="addToArray(entry.field)"
610
- >
611
- Add Entry
612
- </edge-shad-button>
613
- </CardFooter>
614
- </Card>
615
- </PopoverContent>
616
- </Popover>
617
- </edge-shad-button>
618
- </div>
619
- </div>
620
- </CardHeader>
621
- <draggable
622
- v-if="state.draft?.[entry.field] && state.draft[entry.field].length > 0"
623
- v-model="state.draft[entry.field]"
624
- handle=".handle"
625
- item-key="index"
626
- >
627
- <template #item="{ element, index }">
628
- <div :key="index" class="">
629
- <div class="flex gap-2 w-full items-center w-full border-1 border-dotted py-1 mb-1">
630
- <div class="text-left px-2">
631
- <Grip class="handle pointer" />
632
- </div>
633
- <div class="px-2 py-2 w-[98%] flex gap-1">
634
- <template v-for="schemaItem in entry.meta.schema" :key="schemaItem.field">
635
- <Popover>
636
- <PopoverTrigger as-child>
637
- <Alert class="w-[200px] text-xs py-1 px-2 cursor-pointer hover:bg-primary hover:text-white">
638
- <AlertTitle> {{ genTitleFromField(schemaItem) }}</AlertTitle>
639
- <AlertDescription class="text-sm truncate max-w-[200px]">
640
- {{ element[schemaItem.field] }}
641
- </AlertDescription>
642
- </Alert>
643
- </PopoverTrigger>
644
- <PopoverContent class="!w-80 mr-20">
645
- <Card class="border-none shadow-none p-4">
1192
+ </template>
1193
+ <template v-else>
1194
+ <SheetHeader>
1195
+ <div class="flex flex-col gap-3 pr-10 md:flex-row md:items-start md:justify-between">
1196
+ <div class="min-w-0">
1197
+ <SheetTitle>Edit Block</SheetTitle>
1198
+ <SheetDescription v-if="modelValue.synced" class="text-sm text-red-500">
1199
+ This is a synced block. Changes made here will be reflected across all instances of this block on your site.
1200
+ </SheetDescription>
1201
+ </div>
1202
+ <edge-shad-button
1203
+ type="button"
1204
+ size="sm"
1205
+ class="h-8 gap-2 md:self-start"
1206
+ variant="outline"
1207
+ :disabled="!aiFieldOptions.length"
1208
+ @click="openAiDialog"
1209
+ >
1210
+ <Sparkles class="w-4 h-4" />
1211
+ Generate with AI
1212
+ </edge-shad-button>
1213
+ </div>
1214
+ </SheetHeader>
1215
+
1216
+ <edge-shad-form ref="blockFormRef">
1217
+ <div v-if="editableMetaEntries.length === 0">
1218
+ <Alert variant="info" class="mt-4 mb-4">
1219
+ <AlertTitle>No editable fields found</AlertTitle>
1220
+ <AlertDescription class="text-sm">
1221
+ This block does not have any editable fields defined.
1222
+ </AlertDescription>
1223
+ </Alert>
1224
+ </div>
1225
+ <div :class="modelValue.synced ? 'h-[calc(100vh-160px)]' : 'h-[calc(100vh-130px)]'" class="p-6 space-y-4 overflow-y-auto">
1226
+ <template v-for="entry in editableMetaEntries" :key="entry.field">
1227
+ <div v-if="entry.meta.type === 'array'">
1228
+ <div v-if="!entry.meta?.api && !entry.meta?.collection">
1229
+ <div v-if="entry.meta?.schema">
1230
+ <Card v-if="!state.reload" class="mb-4 bg-white shadow-sm border border-gray-200 p-4">
1231
+ <CardHeader class="p-0 mb-2">
1232
+ <div class="relative flex items-center bg-secondary p-2 justify-between sticky top-0 z-10 bg-primary rounded">
1233
+ <span class="text-lg font-semibold whitespace-nowrap pr-1"> {{ genTitleFromField(entry) }}</span>
1234
+ <div class="flex w-full items-center">
1235
+ <div class="w-full border-t border-gray-300 dark:border-white/15" aria-hidden="true" />
1236
+ <edge-shad-button variant="text" class="hover:text-primary/50 text-xs h-[26px] text-primary" @click="state.editMode = !state.editMode">
1237
+ <Popover>
1238
+ <PopoverTrigger as-child>
1239
+ <edge-shad-button
1240
+ variant="text"
1241
+ type="submit"
1242
+ class="bg-secondary hover:text-primary/50 text-xs h-[26px] text-primary"
1243
+ >
1244
+ <Plus class="w-4 h-4" />
1245
+ </edge-shad-button>
1246
+ </PopoverTrigger>
1247
+ <PopoverContent class="!w-80 mr-20">
1248
+ <Card class="border-none shadow-none p-4">
1249
+ <template v-for="schemaItem in entry.meta.schema" :key="schemaItem.field">
646
1250
  <edge-cms-block-input
647
- v-model="element[schemaItem.field]"
1251
+ v-model="state.arrayItems[entry.field][schemaItem.field]"
648
1252
  :type="schemaItem.type"
1253
+ :field="schemaItem.field"
649
1254
  :schema="schemaItem"
650
- :field="`${schemaItem.field}-${index}-entry`"
651
1255
  :label="genTitleFromField(schemaItem)"
652
1256
  />
653
- </Card>
654
- </PopoverContent>
655
- </Popover>
656
- </template>
657
- </div>
658
- <div class="pr-2">
659
- <edge-shad-button
660
- variant="destructive"
661
- size="icon"
662
- @click="state.draft[entry.field].splice(index, 1)"
663
- >
664
- <Trash class="h-4 w-4" />
665
- </edge-shad-button>
666
- </div>
1257
+ </template>
1258
+ <CardFooter class="mt-2 flex justify-end">
1259
+ <edge-shad-button
1260
+ class="bg-secondary hover:text-white text-xs h-[26px] text-primary"
1261
+ @click="addToArray(entry.field)"
1262
+ >
1263
+ Add Entry
1264
+ </edge-shad-button>
1265
+ </CardFooter>
1266
+ </Card>
1267
+ </PopoverContent>
1268
+ </Popover>
1269
+ </edge-shad-button>
667
1270
  </div>
668
1271
  </div>
669
- </template>
670
- </draggable>
671
- </Card>
1272
+ </CardHeader>
1273
+ <draggable
1274
+ v-if="state.draft?.[entry.field] && state.draft[entry.field].length > 0"
1275
+ v-model="state.draft[entry.field]"
1276
+ handle=".handle"
1277
+ item-key="index"
1278
+ >
1279
+ <template #item="{ element, index }">
1280
+ <div :key="index" class="">
1281
+ <div class="flex gap-2 w-full items-center w-full border-1 border-dotted py-1 mb-1">
1282
+ <div class="text-left px-2">
1283
+ <Grip class="handle pointer" />
1284
+ </div>
1285
+ <div class="px-2 py-2 w-[98%] flex gap-1">
1286
+ <template v-for="schemaItem in entry.meta.schema" :key="schemaItem.field">
1287
+ <Popover>
1288
+ <PopoverTrigger as-child>
1289
+ <Alert class="w-[200px] text-xs py-1 px-2 cursor-pointer hover:bg-primary hover:text-white">
1290
+ <AlertTitle> {{ genTitleFromField(schemaItem) }}</AlertTitle>
1291
+ <AlertDescription class="text-sm truncate max-w-[200px]">
1292
+ {{ element[schemaItem.field] }}
1293
+ </AlertDescription>
1294
+ </Alert>
1295
+ </PopoverTrigger>
1296
+ <PopoverContent class="!w-80 mr-20">
1297
+ <Card class="border-none shadow-none p-4">
1298
+ <edge-cms-block-input
1299
+ v-model="element[schemaItem.field]"
1300
+ :type="schemaItem.type"
1301
+ :schema="schemaItem"
1302
+ :field="`${schemaItem.field}-${index}-entry`"
1303
+ :label="genTitleFromField(schemaItem)"
1304
+ />
1305
+ </Card>
1306
+ </PopoverContent>
1307
+ </Popover>
1308
+ </template>
1309
+ </div>
1310
+ <div class="pr-2">
1311
+ <edge-shad-button
1312
+ variant="destructive"
1313
+ size="icon"
1314
+ @click="state.draft[entry.field].splice(index, 1)"
1315
+ >
1316
+ <Trash class="h-4 w-4" />
1317
+ </edge-shad-button>
1318
+ </div>
1319
+ </div>
1320
+ </div>
1321
+ </template>
1322
+ </draggable>
1323
+ </Card>
1324
+ </div>
1325
+ <edge-cms-block-input
1326
+ v-else
1327
+ v-model="state.draft[entry.field]"
1328
+ :type="entry.meta.type"
1329
+ :field="entry.field"
1330
+ :label="genTitleFromField(entry)"
1331
+ />
1332
+ </div>
1333
+ <div v-else>
1334
+ <template v-if="entry.meta?.queryOptions">
1335
+ <div v-for="option in entry.meta.queryOptions" :key="option.field" class="mb-2">
1336
+ <edge-shad-select-tags
1337
+ v-if="entry.meta?.collection?.path === 'posts' && option.field === 'tags'"
1338
+ v-model="state.meta[entry.field].queryItems[option.field]"
1339
+ :items="getTagsFromPosts"
1340
+ :label="`${genTitleFromField(option)}`"
1341
+ :name="option.field"
1342
+ :placeholder="`Select ${genTitleFromField(option)}`"
1343
+ />
1344
+ <edge-cms-options-select
1345
+ v-else-if="entry.meta?.collection?.path !== 'post'"
1346
+ v-model="state.meta[entry.field].queryItems[option.field]"
1347
+ :option="option"
1348
+ :label="genTitleFromField(option)"
1349
+ :multiple="option?.multiple || false"
1350
+ />
1351
+ </div>
1352
+ </template>
1353
+ <edge-shad-number
1354
+ v-if="entry.meta?.collection?.path !== 'post' && !isLimitOne(entry.field)"
1355
+ v-model="state.meta[entry.field].limit"
1356
+ name="limit"
1357
+ label="Limit"
1358
+ />
672
1359
  </div>
1360
+ </div>
1361
+ <div v-else-if="entry.meta?.type === 'image'" class="w-full">
1362
+ <div class="mb-2 text-sm font-medium text-foreground">
1363
+ {{ genTitleFromField(entry) }}
1364
+ </div>
1365
+ <div class="relative py-2 rounded-md flex items-center justify-center" :class="previewBackgroundClass(state.draft[entry.field])">
1366
+ <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">
1367
+ <Dialog v-model:open="state.imageOpenByField[entry.field]">
1368
+ <DialogTrigger as-child>
1369
+ <edge-shad-button variant="outline" class="bg-white text-black hover:bg-gray-200">
1370
+ <ImagePlus class="h-5 w-5 mr-2" />
1371
+ Select Image
1372
+ </edge-shad-button>
1373
+ </DialogTrigger>
1374
+ <DialogContent class="w-full max-w-[1200px] max-h-[80vh] overflow-y-auto">
1375
+ <DialogHeader>
1376
+ <DialogTitle>Select Image</DialogTitle>
1377
+ <DialogDescription />
1378
+ </DialogHeader>
1379
+ <edge-cms-media-manager
1380
+ v-if="entry.meta?.tags && entry.meta.tags.length > 0"
1381
+ :site="props.siteId"
1382
+ :select-mode="true"
1383
+ :default-tags="entry.meta.tags"
1384
+ @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
1385
+ />
1386
+ <edge-cms-media-manager
1387
+ v-else
1388
+ :site="props.siteId"
1389
+ :select-mode="true"
1390
+ @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
1391
+ />
1392
+ </DialogContent>
1393
+ </Dialog>
1394
+ </div>
1395
+ <img
1396
+ v-if="state.draft[entry.field]"
1397
+ :src="state.draft[entry.field]"
1398
+ class="max-h-40 max-w-full h-auto w-auto object-contain"
1399
+ >
1400
+ </div>
1401
+ </div>
1402
+ <div v-else-if="entry.meta?.option">
1403
+ <edge-cms-options-select
1404
+ v-model="state.draft[entry.field]"
1405
+ :option="entry.meta.option"
1406
+ :label="genTitleFromField(entry)"
1407
+ />
1408
+ </div>
1409
+ <div v-else>
673
1410
  <edge-cms-block-input
674
- v-else
675
1411
  v-model="state.draft[entry.field]"
676
1412
  :type="entry.meta.type"
677
1413
  :field="entry.field"
678
1414
  :label="genTitleFromField(entry)"
679
1415
  />
680
1416
  </div>
681
- <div v-else>
682
- <template v-if="entry.meta?.queryOptions">
683
- <div v-for="option in entry.meta.queryOptions" :key="option.field" class="mb-2">
684
- <edge-shad-select-tags
685
- v-if="entry.meta?.collection?.path === 'posts' && option.field === 'tags'"
686
- v-model="state.meta[entry.field].queryItems[option.field]"
687
- :items="getTagsFromPosts"
688
- :label="`${genTitleFromField(option)}`"
689
- :name="option.field"
690
- :placeholder="`Select ${genTitleFromField(option)}`"
691
- />
692
- <edge-cms-options-select
693
- v-else-if="entry.meta?.collection?.path !== 'post'"
694
- v-model="state.meta[entry.field].queryItems[option.field]"
695
- :option="option"
696
- :label="genTitleFromField(option)"
697
- :multiple="option?.multiple || false"
698
- />
699
- </div>
700
- </template>
701
- <edge-shad-number v-if="entry.meta?.collection?.path !== 'post'" v-model="state.meta[entry.field].limit" name="limit" label="Limit" />
702
- </div>
703
- </div>
704
- <div v-else-if="entry.meta?.type === 'image'" class="w-full">
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])">
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">
710
- <Dialog v-model:open="state.imageOpenByField[entry.field]">
711
- <DialogTrigger as-child>
712
- <edge-shad-button variant="outline" class="bg-white text-black hover:bg-gray-200">
713
- <ImagePlus class="h-5 w-5 mr-2" />
714
- Select Image
715
- </edge-shad-button>
716
- </DialogTrigger>
717
- <DialogContent class="w-full max-w-[1200px] max-h-[80vh] overflow-y-auto">
718
- <DialogHeader>
719
- <DialogTitle>Select Image</DialogTitle>
720
- <DialogDescription />
721
- </DialogHeader>
722
- <edge-cms-media-manager
723
- v-if="entry.meta?.tags && entry.meta.tags.length > 0"
724
- :site="props.siteId"
725
- :select-mode="true"
726
- :default-tags="entry.meta.tags"
727
- @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
728
- />
729
- <edge-cms-media-manager
730
- v-else
731
- :site="props.siteId"
732
- :select-mode="true"
733
- @select="(url) => { state.draft[entry.field] = url; state.imageOpenByField[entry.field] = false }"
734
- />
735
- </DialogContent>
736
- </Dialog>
1417
+ </template>
1418
+ </div>
1419
+
1420
+ <div class="sticky bottom-0 bg-background px-6 pb-4 pt-2">
1421
+ <Alert v-if="state.validationErrors.length" variant="destructive" class="mb-3">
1422
+ <AlertTitle>Fix the highlighted fields</AlertTitle>
1423
+ <AlertDescription class="text-sm">
1424
+ <div v-for="(error, index) in state.validationErrors" :key="`${error}-${index}`">
1425
+ {{ error }}
737
1426
  </div>
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
- >
743
- </div>
744
- </div>
745
- <div v-else-if="entry.meta?.option">
746
- <edge-cms-options-select
747
- v-model="state.draft[entry.field]"
748
- :option="entry.meta.option"
749
- :label="genTitleFromField(entry)"
750
- />
751
- </div>
752
- <div v-else>
753
- <edge-cms-block-input
754
- v-model="state.draft[entry.field]"
755
- :type="entry.meta.type"
756
- :field="entry.field"
757
- :label="genTitleFromField(entry)"
758
- />
759
- </div>
760
- </template>
761
- </div>
1427
+ </AlertDescription>
1428
+ </Alert>
1429
+ <SheetFooter class="flex justify-between">
1430
+ <edge-shad-button variant="destructive" class="text-white" @click="state.open = false">
1431
+ Cancel
1432
+ </edge-shad-button>
1433
+ <edge-shad-button class=" bg-slate-800 hover:bg-slate-400 w-full" @click="save">
1434
+ Save changes
1435
+ </edge-shad-button>
1436
+ </SheetFooter>
1437
+ </div>
1438
+ </edge-shad-form>
762
1439
 
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>
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>
1440
+ <edge-shad-dialog v-model="state.aiDialogOpen">
1441
+ <DialogContent class="max-w-[640px]">
1442
+ <DialogHeader>
1443
+ <DialogTitle>Generate with AI</DialogTitle>
1444
+ <DialogDescription>
1445
+ Choose which fields the AI should fill and add any optional instructions.
1446
+ </DialogDescription>
1447
+ </DialogHeader>
1448
+ <div class="space-y-4">
1449
+ <edge-shad-textarea
1450
+ v-model="state.aiInstructions"
1451
+ name="aiInstructions"
1452
+ label="Instructions (Optional)"
1453
+ placeholder="Share tone, audience, and any details the AI should include."
1454
+ />
1455
+ <div class="space-y-2">
1456
+ <div class="flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1457
+ <span>Fields</span>
1458
+ <span>{{ selectedAiFieldIds.length }} selected</span>
1459
+ </div>
1460
+ <edge-shad-checkbox v-model="allAiFieldsSelected" name="aiSelectAll">
1461
+ Select all fields
815
1462
  </edge-shad-checkbox>
1463
+ <div v-if="aiFieldOptions.length" class="grid gap-2 md:grid-cols-2">
1464
+ <edge-shad-checkbox
1465
+ v-for="option in aiFieldOptions"
1466
+ :key="option.id"
1467
+ v-model="state.aiSelectedFields[option.id]"
1468
+ :name="`ai-field-${option.id}`"
1469
+ >
1470
+ {{ option.label }}
1471
+ <span class="ml-2 text-xs text-muted-foreground">({{ option.type }})</span>
1472
+ </edge-shad-checkbox>
1473
+ </div>
1474
+ <Alert v-else variant="info">
1475
+ <AlertTitle>No editable fields</AlertTitle>
1476
+ <AlertDescription class="text-sm">
1477
+ Add editable fields to this block to enable AI generation.
1478
+ </AlertDescription>
1479
+ </Alert>
816
1480
  </div>
817
- <Alert v-else variant="info">
818
- <AlertTitle>No editable fields</AlertTitle>
1481
+ <Alert v-if="state.aiError" variant="destructive">
1482
+ <AlertTitle>AI generation failed</AlertTitle>
819
1483
  <AlertDescription class="text-sm">
820
- Add editable fields to this block to enable AI generation.
1484
+ {{ state.aiError }}
821
1485
  </AlertDescription>
822
1486
  </Alert>
823
1487
  </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>
1488
+ <DialogFooter class="pt-4 flex justify-between">
1489
+ <edge-shad-button type="button" variant="destructive" class="text-white" @click="closeAiDialog">
1490
+ Cancel
1491
+ </edge-shad-button>
1492
+ <edge-shad-button
1493
+ type="button"
1494
+ class="w-full"
1495
+ :disabled="state.aiGenerating || !selectedAiFieldIds.length"
1496
+ @click="generateWithAi"
1497
+ >
1498
+ <Loader2 v-if="state.aiGenerating" class="w-4 h-4 mr-2 animate-spin" />
1499
+ Generate
1500
+ </edge-shad-button>
1501
+ </DialogFooter>
1502
+ </DialogContent>
1503
+ </edge-shad-dialog>
1504
+ </template>
847
1505
  </edge-cms-block-sheet-content>
848
1506
  </Sheet>
849
1507
  </div>
850
1508
  </template>
1509
+
1510
+ <style scoped>
1511
+ .cms-nav-edit-static :deep([data-cms-nav-root] .cms-nav-toggle),
1512
+ .cms-nav-edit-static :deep([data-cms-nav-root] .cms-nav-close),
1513
+ .cms-nav-edit-static :deep([data-cms-nav-root] .cms-nav-link) {
1514
+ pointer-events: none !important;
1515
+ }
1516
+
1517
+ .cms-nav-edit-static :deep([data-cms-nav-root] .cms-nav-overlay),
1518
+ .cms-nav-edit-static :deep([data-cms-nav-root] .cms-nav-panel) {
1519
+ display: none !important;
1520
+ pointer-events: none !important;
1521
+ }
1522
+ </style>