@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
@@ -1,10 +1,22 @@
1
1
  <script setup>
2
+ const emit = defineEmits(['head'])
2
3
  const edgeFirebase = inject('edgeFirebase')
4
+ const { blocks: blockNewDocSchema } = useCmsNewDocs()
3
5
  const state = reactive({
4
6
  filter: '',
5
7
  mounted: false,
6
8
  picksFilter: [],
7
9
  themesFilter: [],
10
+ selectedBlockDocIds: [],
11
+ bulkDeleteDialogOpen: false,
12
+ bulkDeleting: false,
13
+ importingJson: false,
14
+ importDocIdDialogOpen: false,
15
+ importDocIdValue: '',
16
+ importConflictDialogOpen: false,
17
+ importConflictDocId: '',
18
+ importErrorDialogOpen: false,
19
+ importErrorMessage: '',
8
20
  })
9
21
 
10
22
  const rawInitBlockFiles = import.meta.glob('./init_blocks/*.html', {
@@ -28,6 +40,11 @@ const INITIAL_BLOCKS = Object.entries(rawInitBlockFiles).map(([path, content]) =
28
40
  })
29
41
 
30
42
  const router = useRouter()
43
+ const blockImportInputRef = ref(null)
44
+ const blockImportDocIdResolver = ref(null)
45
+ const blockImportConflictResolver = ref(null)
46
+ const DEFAULT_BLOCK_IMPORT_ERROR_MESSAGE = 'Failed to import block JSON.'
47
+ const OPTIONAL_BLOCK_IMPORT_KEYS = new Set(['previewType'])
31
48
 
32
49
  const seedInitialBlocks = async () => {
33
50
  console.log('Seeding initial blocks...')
@@ -50,6 +67,7 @@ const seedInitialBlocks = async () => {
50
67
  docId: block.docId,
51
68
  name: block.name,
52
69
  content: block.content,
70
+ previewType: 'light',
53
71
  tags: [],
54
72
  themes: [],
55
73
  synced: false,
@@ -67,17 +85,27 @@ const seedInitialBlocks = async () => {
67
85
  }
68
86
 
69
87
  const getThemeFromId = (themeId) => {
70
- const theme = edgeFirebase.data[`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`]?.[themeId]
71
- console.log('getThemeFromId', themeId, theme.name)
88
+ const theme = edgeFirebase.data?.[`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`]?.[themeId]
72
89
  return theme?.name || 'Unknown'
73
90
  }
74
91
 
92
+ const normalizePreviewType = (value) => {
93
+ return value === 'dark' ? 'dark' : 'light'
94
+ }
95
+
96
+ const previewSurfaceClass = (value) => {
97
+ return normalizePreviewType(value) === 'dark'
98
+ ? 'preview-surface-dark'
99
+ : 'preview-surface-light'
100
+ }
101
+
75
102
  const loadingRender = (content) => {
76
103
  const safeContent = typeof content === 'string' ? content : ''
77
104
  return safeContent.replaceAll('{{loading}}', '').replaceAll('{{loaded}}', 'hidden')
78
105
  }
79
106
 
80
107
  const FILTER_STORAGE_KEY = 'edge.blocks.filters'
108
+ const NO_TAGS_FILTER_VALUE = '__no_tags__'
81
109
 
82
110
  const restoreFilters = () => {
83
111
  if (typeof localStorage === 'undefined')
@@ -128,7 +156,8 @@ const tagOptions = computed(() => {
128
156
  if (Array.isArray(block.tags))
129
157
  block.tags.forEach(tag => tagsSet.add(tag))
130
158
  })
131
- return Array.from(tagsSet).sort((a, b) => a.localeCompare(b)).map(tag => ({ name: tag, title: tag }))
159
+ const tagItems = Array.from(tagsSet).sort((a, b) => a.localeCompare(b)).map(tag => ({ name: tag, title: tag }))
160
+ return [{ name: NO_TAGS_FILTER_VALUE, title: 'No Tags' }, ...tagItems]
132
161
  })
133
162
 
134
163
  const themeOptions = computed(() => {
@@ -138,14 +167,505 @@ const themeOptions = computed(() => {
138
167
  .sort((a, b) => a.title.localeCompare(b.title))
139
168
  })
140
169
 
170
+ const themesCollection = computed(() => {
171
+ return edgeFirebase.data?.[`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`] || {}
172
+ })
173
+
174
+ const parseHeadJson = (raw) => {
175
+ if (!raw)
176
+ return {}
177
+ if (typeof raw === 'object' && !Array.isArray(raw))
178
+ return raw
179
+ if (typeof raw !== 'string')
180
+ return {}
181
+ try {
182
+ return JSON.parse(raw)
183
+ }
184
+ catch {
185
+ return {}
186
+ }
187
+ }
188
+
189
+ const dedupeHeadEntries = (entries) => {
190
+ const seen = new Set()
191
+ return entries.filter((entry) => {
192
+ const key = JSON.stringify(entry || {})
193
+ if (seen.has(key))
194
+ return false
195
+ seen.add(key)
196
+ return true
197
+ })
198
+ }
199
+
200
+ const mergedThemeHeadObject = computed(() => {
201
+ const themes = Object.values(themesCollection.value || {})
202
+ const link = []
203
+ const script = []
204
+ const style = []
205
+ const meta = []
206
+
207
+ themes.forEach((themeDoc) => {
208
+ const parsedHead = parseHeadJson(themeDoc?.headJSON)
209
+ if (Array.isArray(parsedHead?.link))
210
+ link.push(...parsedHead.link)
211
+ if (Array.isArray(parsedHead?.script))
212
+ script.push(...parsedHead.script)
213
+ if (Array.isArray(parsedHead?.style))
214
+ style.push(...parsedHead.style)
215
+ if (Array.isArray(parsedHead?.meta))
216
+ meta.push(...parsedHead.meta)
217
+ })
218
+
219
+ return {
220
+ link: dedupeHeadEntries(link),
221
+ script: dedupeHeadEntries(script),
222
+ style: dedupeHeadEntries(style),
223
+ meta: dedupeHeadEntries(meta),
224
+ }
225
+ })
226
+
227
+ watch(mergedThemeHeadObject, (newHeadElements) => {
228
+ emit('head', newHeadElements || {})
229
+ }, { immediate: true, deep: true })
230
+
231
+ const parsedThemesById = computed(() => {
232
+ const parsed = {}
233
+ for (const [themeId, themeDoc] of Object.entries(themesCollection.value || {})) {
234
+ const rawTheme = themeDoc?.theme
235
+ if (!rawTheme)
236
+ continue
237
+ const extraCSS = typeof themeDoc?.extraCSS === 'string' ? themeDoc.extraCSS : ''
238
+ if (typeof rawTheme === 'string') {
239
+ try {
240
+ const parsedTheme = JSON.parse(rawTheme)
241
+ if (!parsedTheme || typeof parsedTheme !== 'object' || Array.isArray(parsedTheme))
242
+ continue
243
+ parsed[themeId] = { ...parsedTheme, extraCSS }
244
+ }
245
+ catch {
246
+ continue
247
+ }
248
+ continue
249
+ }
250
+ if (typeof rawTheme === 'object' && !Array.isArray(rawTheme))
251
+ parsed[themeId] = { ...rawTheme, extraCSS }
252
+ }
253
+ return parsed
254
+ })
255
+
256
+ const firstThemeId = computed(() => themeOptions.value?.[0]?.name || '')
257
+
258
+ const getPreviewThemeForBlock = (block) => {
259
+ const allowedThemeIds = Array.isArray(block?.themes)
260
+ ? block.themes.map(themeId => String(themeId || '').trim()).filter(Boolean)
261
+ : []
262
+
263
+ let preferredThemeId = allowedThemeIds.find(themeId => !!parsedThemesById.value?.[themeId]) || ''
264
+ if (!preferredThemeId)
265
+ preferredThemeId = firstThemeId.value
266
+ if (!preferredThemeId)
267
+ return null
268
+
269
+ return parsedThemesById.value?.[preferredThemeId] || null
270
+ }
271
+
141
272
  const listFilters = computed(() => {
142
273
  const filters = []
143
- if (state.picksFilter.length)
144
- filters.push({ filterFields: ['tags'], value: state.picksFilter })
145
274
  if (state.themesFilter.length)
146
275
  filters.push({ filterFields: ['themes'], value: state.themesFilter })
147
276
  return filters
148
277
  })
278
+
279
+ const applyTagSelectionFilter = (items = []) => {
280
+ const selectedFilters = Array.isArray(state.picksFilter) ? state.picksFilter : []
281
+ if (!selectedFilters.length)
282
+ return items
283
+
284
+ const includeNoTags = selectedFilters.includes(NO_TAGS_FILTER_VALUE)
285
+ const selectedTags = selectedFilters.filter(value => value !== NO_TAGS_FILTER_VALUE)
286
+
287
+ return items.filter((item) => {
288
+ const tags = Array.isArray(item?.tags) ? item.tags : []
289
+ const hasNoTags = tags.length === 0
290
+ const hasSelectedTag = selectedTags.length > 0
291
+ ? tags.some(tag => selectedTags.includes(tag))
292
+ : false
293
+
294
+ if (includeNoTags && selectedTags.length > 0)
295
+ return hasNoTags || hasSelectedTag
296
+ if (includeNoTags)
297
+ return hasNoTags
298
+ return hasSelectedTag
299
+ })
300
+ }
301
+
302
+ const blockCollectionPath = computed(() => `${edgeGlobal.edgeState.organizationDocPath}/blocks`)
303
+ const blocksCollection = computed(() => edgeFirebase.data?.[blockCollectionPath.value] || {})
304
+ const selectedBlockSet = computed(() => new Set(state.selectedBlockDocIds))
305
+ const selectedBlockCount = computed(() => state.selectedBlockDocIds.length)
306
+
307
+ const normalizeDocId = value => String(value || '').trim()
308
+
309
+ const isBlockSelected = docId => selectedBlockSet.value.has(normalizeDocId(docId))
310
+
311
+ const setBlockSelection = (docId, checked) => {
312
+ const normalizedDocId = normalizeDocId(docId)
313
+ if (!normalizedDocId)
314
+ return
315
+ const shouldSelect = checked === true || checked === 'indeterminate'
316
+ if (shouldSelect) {
317
+ if (!selectedBlockSet.value.has(normalizedDocId))
318
+ state.selectedBlockDocIds = [...state.selectedBlockDocIds, normalizedDocId]
319
+ return
320
+ }
321
+ state.selectedBlockDocIds = state.selectedBlockDocIds.filter(id => id !== normalizedDocId)
322
+ }
323
+
324
+ const getVisibleSelectionState = (visibleItems = []) => {
325
+ if (!visibleItems.length)
326
+ return false
327
+ let selectedVisibleCount = 0
328
+ for (const item of visibleItems) {
329
+ if (isBlockSelected(item?.docId))
330
+ selectedVisibleCount += 1
331
+ }
332
+ if (selectedVisibleCount === 0)
333
+ return false
334
+ if (selectedVisibleCount === visibleItems.length)
335
+ return true
336
+ return 'indeterminate'
337
+ }
338
+
339
+ const toggleVisibleBlockSelection = (visibleItems = [], checked) => {
340
+ if (!visibleItems.length)
341
+ return
342
+ const shouldSelect = checked === true || checked === 'indeterminate'
343
+ const visibleDocIds = visibleItems.map(item => normalizeDocId(item?.docId)).filter(Boolean)
344
+ if (shouldSelect) {
345
+ state.selectedBlockDocIds = [...new Set([...state.selectedBlockDocIds, ...visibleDocIds])]
346
+ return
347
+ }
348
+ const visibleDocIdSet = new Set(visibleDocIds)
349
+ state.selectedBlockDocIds = state.selectedBlockDocIds.filter(id => !visibleDocIdSet.has(id))
350
+ }
351
+
352
+ const clearSelectedBlocks = () => {
353
+ state.selectedBlockDocIds = []
354
+ }
355
+
356
+ const openBulkDeleteDialog = () => {
357
+ if (!selectedBlockCount.value)
358
+ return
359
+ state.bulkDeleteDialogOpen = true
360
+ }
361
+
362
+ const bulkDeleteAction = async () => {
363
+ if (state.bulkDeleting || !selectedBlockCount.value) {
364
+ state.bulkDeleteDialogOpen = false
365
+ return
366
+ }
367
+
368
+ state.bulkDeleting = true
369
+ const selectedDocIds = [...state.selectedBlockDocIds]
370
+ const blocks = blocksCollection.value || {}
371
+ const failedDocIds = []
372
+ let deletedCount = 0
373
+
374
+ try {
375
+ for (const docId of selectedDocIds) {
376
+ if (!blocks[docId])
377
+ continue
378
+ try {
379
+ await edgeFirebase.removeDoc(blockCollectionPath.value, docId)
380
+ deletedCount += 1
381
+ }
382
+ catch (error) {
383
+ failedDocIds.push(docId)
384
+ console.error(`Failed to delete block "${docId}"`, error)
385
+ }
386
+ }
387
+
388
+ state.selectedBlockDocIds = failedDocIds
389
+ state.bulkDeleteDialogOpen = false
390
+
391
+ if (deletedCount)
392
+ edgeFirebase?.toast?.success?.(`Deleted ${deletedCount} block${deletedCount === 1 ? '' : 's'}.`)
393
+ if (failedDocIds.length)
394
+ edgeFirebase?.toast?.error?.(`Failed to delete ${failedDocIds.length} block${failedDocIds.length === 1 ? '' : 's'}.`)
395
+ }
396
+ finally {
397
+ state.bulkDeleting = false
398
+ }
399
+ }
400
+
401
+ watch(blocksCollection, (collection) => {
402
+ const validDocIds = new Set(Object.keys(collection || {}))
403
+ state.selectedBlockDocIds = state.selectedBlockDocIds.filter(docId => validDocIds.has(docId))
404
+ }, { deep: true })
405
+
406
+ const readTextFile = file => new Promise((resolve, reject) => {
407
+ if (typeof FileReader === 'undefined') {
408
+ reject(new Error('File import is only available in the browser.'))
409
+ return
410
+ }
411
+ const reader = new FileReader()
412
+ reader.onload = () => resolve(String(reader.result || ''))
413
+ reader.onerror = () => reject(new Error('Could not read the selected file.'))
414
+ reader.readAsText(file)
415
+ })
416
+
417
+ const normalizeImportedDoc = (payload, fallbackDocId = '') => {
418
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
419
+ throw new Error('Invalid JSON payload. Expected an object.')
420
+
421
+ if (payload.document && typeof payload.document === 'object' && !Array.isArray(payload.document)) {
422
+ const normalized = { ...payload.document }
423
+ if (!normalized.docId && payload.docId)
424
+ normalized.docId = payload.docId
425
+ if (!normalized.docId && fallbackDocId)
426
+ normalized.docId = fallbackDocId
427
+ return normalized
428
+ }
429
+
430
+ const normalized = { ...payload }
431
+ if (!normalized.docId && fallbackDocId)
432
+ normalized.docId = fallbackDocId
433
+ return normalized
434
+ }
435
+
436
+ const isPlainObject = value => !!value && typeof value === 'object' && !Array.isArray(value)
437
+
438
+ const cloneSchemaValue = (value) => {
439
+ if (isPlainObject(value) || Array.isArray(value))
440
+ return edgeGlobal.dupObject(value)
441
+ return value
442
+ }
443
+
444
+ const getDocDefaultsFromSchema = (schema = {}) => {
445
+ const defaults = {}
446
+ for (const [key, schemaEntry] of Object.entries(schema || {})) {
447
+ const hasValueProp = isPlainObject(schemaEntry) && Object.prototype.hasOwnProperty.call(schemaEntry, 'value')
448
+ const baseValue = hasValueProp ? schemaEntry.value : schemaEntry
449
+ defaults[key] = cloneSchemaValue(baseValue)
450
+ }
451
+ return defaults
452
+ }
453
+
454
+ const getBlockDocDefaults = () => getDocDefaultsFromSchema(blockNewDocSchema.value || {})
455
+
456
+ const validateImportedBlockDoc = (doc) => {
457
+ if (!isPlainObject(doc))
458
+ throw new Error('Invalid block document. Expected an object.')
459
+
460
+ const requiredKeys = Object.keys(blockNewDocSchema.value || {})
461
+ .filter(key => !OPTIONAL_BLOCK_IMPORT_KEYS.has(key))
462
+ const missing = requiredKeys.filter(key => !Object.prototype.hasOwnProperty.call(doc, key))
463
+ if (missing.length)
464
+ throw new Error(`Missing required block key(s): ${missing.join(', ')}`)
465
+
466
+ return doc
467
+ }
468
+
469
+ const validateImportedBlockThemes = (doc) => {
470
+ if (Object.prototype.hasOwnProperty.call(doc || {}, 'themes') && !Array.isArray(doc?.themes)) {
471
+ throw new Error('Invalid "themes" value. Expected an array of theme docIds.')
472
+ }
473
+
474
+ const importedThemes = Array.isArray(doc?.themes) ? doc.themes : []
475
+ if (!importedThemes.length)
476
+ return doc
477
+
478
+ const orgThemes = edgeFirebase.data?.[`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`] || {}
479
+ const missingThemeIds = []
480
+ let hasEmptyThemeId = false
481
+ const normalizedThemes = []
482
+ for (const themeId of importedThemes) {
483
+ const normalizedThemeId = String(themeId || '').trim()
484
+ if (!normalizedThemeId) {
485
+ hasEmptyThemeId = true
486
+ continue
487
+ }
488
+ if (!orgThemes[normalizedThemeId]) {
489
+ missingThemeIds.push(normalizedThemeId)
490
+ continue
491
+ }
492
+ normalizedThemes.push(normalizedThemeId)
493
+ }
494
+
495
+ if (hasEmptyThemeId)
496
+ throw new Error('Themes include an empty theme id.')
497
+ if (missingThemeIds.length)
498
+ throw new Error(`Theme id(s) not found in this organization: ${[...new Set(missingThemeIds)].join(', ')}`)
499
+
500
+ doc.themes = [...new Set(normalizedThemes)]
501
+ return doc
502
+ }
503
+
504
+ const makeUniqueDocId = (baseDocId, docsMap = {}) => {
505
+ const cleanBase = String(baseDocId || '').trim() || 'block'
506
+ let nextDocId = `${cleanBase}-copy`
507
+ let suffix = 2
508
+ while (docsMap[nextDocId]) {
509
+ nextDocId = `${cleanBase}-copy-${suffix}`
510
+ suffix += 1
511
+ }
512
+ return nextDocId
513
+ }
514
+
515
+ const requestBlockImportDocId = (initialValue = '') => {
516
+ state.importDocIdValue = String(initialValue || '')
517
+ state.importDocIdDialogOpen = true
518
+ return new Promise((resolve) => {
519
+ blockImportDocIdResolver.value = resolve
520
+ })
521
+ }
522
+
523
+ const resolveBlockImportDocId = (value = '') => {
524
+ const resolver = blockImportDocIdResolver.value
525
+ blockImportDocIdResolver.value = null
526
+ state.importDocIdDialogOpen = false
527
+ if (resolver)
528
+ resolver(String(value || '').trim())
529
+ }
530
+
531
+ const requestBlockImportConflict = (docId) => {
532
+ state.importConflictDocId = String(docId || '')
533
+ state.importConflictDialogOpen = true
534
+ return new Promise((resolve) => {
535
+ blockImportConflictResolver.value = resolve
536
+ })
537
+ }
538
+
539
+ const resolveBlockImportConflict = (action = 'cancel') => {
540
+ const resolver = blockImportConflictResolver.value
541
+ blockImportConflictResolver.value = null
542
+ state.importConflictDialogOpen = false
543
+ if (resolver)
544
+ resolver(action)
545
+ }
546
+
547
+ watch(() => state.importDocIdDialogOpen, (open) => {
548
+ if (!open && blockImportDocIdResolver.value) {
549
+ const resolver = blockImportDocIdResolver.value
550
+ blockImportDocIdResolver.value = null
551
+ resolver('')
552
+ }
553
+ })
554
+
555
+ watch(() => state.importConflictDialogOpen, (open) => {
556
+ if (!open && blockImportConflictResolver.value) {
557
+ const resolver = blockImportConflictResolver.value
558
+ blockImportConflictResolver.value = null
559
+ resolver('cancel')
560
+ }
561
+ })
562
+
563
+ const getImportDocId = async (incomingDoc, fallbackDocId = '') => {
564
+ let nextDocId = String(incomingDoc?.docId || '').trim()
565
+ if (!nextDocId)
566
+ nextDocId = await requestBlockImportDocId(fallbackDocId)
567
+ if (!nextDocId)
568
+ throw new Error('Import canceled. A docId is required.')
569
+ if (nextDocId.includes('/'))
570
+ throw new Error('docId cannot include "/".')
571
+ return nextDocId
572
+ }
573
+
574
+ const openImportErrorDialog = (message) => {
575
+ state.importErrorMessage = String(message || DEFAULT_BLOCK_IMPORT_ERROR_MESSAGE)
576
+ state.importErrorDialogOpen = true
577
+ }
578
+
579
+ const getBlockImportFailureReason = (error, message) => {
580
+ if (error instanceof SyntaxError)
581
+ return `Invalid JSON syntax: ${String(error?.message || 'Unable to parse JSON.')}`
582
+ if (/^Import canceled\./i.test(message))
583
+ return message
584
+ return message
585
+ }
586
+
587
+ const logBlockImportFailure = (file, error, message) => {
588
+ const fileName = String(file?.name || 'unknown-file')
589
+ const reason = getBlockImportFailureReason(error, message)
590
+ console.error(`[BlocksManager] Block import failed for "${fileName}". Reason: ${reason}`, {
591
+ fileName,
592
+ reason,
593
+ errorMessage: message,
594
+ error,
595
+ })
596
+ }
597
+
598
+ const triggerBlockImport = () => {
599
+ blockImportInputRef.value?.click()
600
+ }
601
+
602
+ const importSingleBlockFile = async (file, existingBlocks = {}) => {
603
+ const fileText = await readTextFile(file)
604
+ const parsed = JSON.parse(fileText)
605
+ const importedDoc = validateImportedBlockThemes(validateImportedBlockDoc(normalizeImportedDoc(parsed, '')))
606
+ const incomingDocId = await getImportDocId(importedDoc, '')
607
+ let targetDocId = incomingDocId
608
+ let importDecision = 'create'
609
+
610
+ if (existingBlocks[targetDocId]) {
611
+ const decision = await requestBlockImportConflict(targetDocId)
612
+ if (decision === 'cancel')
613
+ return
614
+ if (decision === 'new') {
615
+ targetDocId = makeUniqueDocId(targetDocId, existingBlocks)
616
+ if (typeof importedDoc.name === 'string' && importedDoc.name.trim() && !/\(Copy\)$/i.test(importedDoc.name.trim()))
617
+ importedDoc.name = `${importedDoc.name} (Copy)`
618
+ importDecision = 'new'
619
+ }
620
+ else {
621
+ importDecision = 'overwrite'
622
+ }
623
+ }
624
+
625
+ const payload = { ...getBlockDocDefaults(), ...importedDoc, docId: targetDocId }
626
+ await edgeFirebase.storeDoc(blockCollectionPath.value, payload, targetDocId)
627
+ existingBlocks[targetDocId] = payload
628
+
629
+ if (importDecision === 'overwrite')
630
+ edgeFirebase?.toast?.success?.(`Overwrote block "${targetDocId}".`)
631
+ else if (importDecision === 'new')
632
+ edgeFirebase?.toast?.success?.(`Imported block as new "${targetDocId}".`)
633
+ else
634
+ edgeFirebase?.toast?.success?.(`Imported block "${targetDocId}".`)
635
+ }
636
+
637
+ const handleBlockImport = async (event) => {
638
+ const input = event?.target
639
+ const files = Array.from(input?.files || [])
640
+ if (!files.length)
641
+ return
642
+
643
+ state.importingJson = true
644
+ const existingBlocks = { ...(blocksCollection.value || {}) }
645
+ try {
646
+ const themesCollectionPath = `organizations/${edgeGlobal.edgeState.currentOrganization}/themes`
647
+ if (!edgeFirebase.data?.[themesCollectionPath])
648
+ await edgeFirebase.startSnapshot(themesCollectionPath)
649
+
650
+ for (const file of files) {
651
+ try {
652
+ await importSingleBlockFile(file, existingBlocks)
653
+ }
654
+ catch (error) {
655
+ const message = error?.message || DEFAULT_BLOCK_IMPORT_ERROR_MESSAGE
656
+ logBlockImportFailure(file, error, message)
657
+ if (/^Import canceled\./i.test(message))
658
+ continue
659
+ openImportErrorDialog(getBlockImportFailureReason(error, message))
660
+ }
661
+ }
662
+ }
663
+ finally {
664
+ state.importingJson = false
665
+ if (input)
666
+ input.value = ''
667
+ }
668
+ }
149
669
  </script>
150
670
 
151
671
  <template>
@@ -172,7 +692,7 @@ const listFilters = computed(() => {
172
692
  </template>
173
693
  <template #header-center>
174
694
  <edge-shad-form class="w-full">
175
- <div class="w-full px-4 md:px-6 pb-2 flex flex-col gap-3 md:flex-row md:items-center">
695
+ <div class="w-full px-4 md:px-6 flex flex-col gap-2 md:flex-row md:items-center">
176
696
  <div class="grow">
177
697
  <edge-shad-input
178
698
  v-model="state.filter"
@@ -200,80 +720,252 @@ const listFilters = computed(() => {
200
720
  </div>
201
721
  </edge-shad-form>
202
722
  </template>
723
+ <template #header-end>
724
+ <div class="flex items-center gap-2">
725
+ <input
726
+ ref="blockImportInputRef"
727
+ type="file"
728
+ multiple
729
+ accept=".json,application/json"
730
+ class="hidden"
731
+ @change="handleBlockImport"
732
+ >
733
+ <edge-shad-button
734
+ type="button"
735
+ size="icon"
736
+ variant="outline"
737
+ class="h-9 w-9"
738
+ :disabled="state.importingJson"
739
+ title="Import Blocks"
740
+ aria-label="Import Blocks"
741
+ @click="triggerBlockImport"
742
+ >
743
+ <Loader2 v-if="state.importingJson" class="h-4 w-4 animate-spin" />
744
+ <Upload v-else class="h-4 w-4" />
745
+ </edge-shad-button>
746
+ <edge-shad-button class="uppercase bg-primary" to="/app/dashboard/blocks/new">
747
+ Add Block
748
+ </edge-shad-button>
749
+ </div>
750
+ </template>
203
751
  <template #list="slotProps">
204
- <div
205
- class="grid gap-4 pt-4 w-full"
206
- style="grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));"
207
- >
752
+ <div class="w-full pt-4 space-y-3">
753
+ <div class="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border/50 bg-background/60 px-3 py-2">
754
+ <div class="flex items-center gap-2">
755
+ <Checkbox
756
+ :model-value="getVisibleSelectionState(applyTagSelectionFilter(slotProps.filtered))"
757
+ aria-label="Select visible blocks"
758
+ class="border-border bg-background/90 shadow-sm data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
759
+ @click.stop
760
+ @update:model-value="toggleVisibleBlockSelection(applyTagSelectionFilter(slotProps.filtered), $event)"
761
+ />
762
+ <span class="text-xs text-muted-foreground">
763
+ Select visible ({{ applyTagSelectionFilter(slotProps.filtered).length }})
764
+ </span>
765
+ </div>
766
+ <div class="flex items-center gap-2">
767
+ <span class="text-xs text-muted-foreground">{{ selectedBlockCount }} selected</span>
768
+ <edge-shad-button
769
+ variant="outline"
770
+ class="h-8 text-xs"
771
+ :disabled="selectedBlockCount === 0"
772
+ @click="clearSelectedBlocks"
773
+ >
774
+ Clear
775
+ </edge-shad-button>
776
+ <edge-shad-button
777
+ variant="destructive"
778
+ class="h-8 text-xs text-white"
779
+ :disabled="selectedBlockCount === 0"
780
+ @click="openBulkDeleteDialog"
781
+ >
782
+ Delete selected
783
+ </edge-shad-button>
784
+ </div>
785
+ </div>
208
786
  <div
209
- v-for="item in slotProps.filtered"
210
- :key="item.docId"
211
- role="button"
212
- tabindex="0"
213
- class="w-full h-full"
214
- @click="router.push(`/app/dashboard/blocks/${item.docId}`)"
215
- @keyup.enter="router.push(`/app/dashboard/blocks/${item.docId}`)"
787
+ class="grid gap-4 w-full"
788
+ style="grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));"
216
789
  >
217
- <Card class="h-full cursor-pointer border border-white/5 bg-gradient-to-br from-slate-950/85 via-slate-950/65 to-slate-900/60 hover:border-primary/50 hover:shadow-[0_22px_55px_-24px_rgba(0,0,0,0.7)] transition">
218
- <CardContent class="flex flex-col gap-1 p-4 sm:p-5">
219
- <div class="flex items-start justify-between gap-3">
220
- <p class="text-lg font-semibold leading-snug line-clamp-2 text-white">
221
- {{ item.name }}
222
- </p>
223
- <edge-shad-button
224
- size="icon"
225
- variant="ghost"
226
- class="h-8 w-8 text-white/80 hover:text-white hover:bg-white/10"
227
- @click.stop="slotProps.deleteItem(item.docId)"
228
- >
229
- <Trash class="h-4 w-4" />
230
- </edge-shad-button>
231
- </div>
232
- <div v-if="item.content" class="block-preview">
233
- <div class="scale-wrapper">
234
- <div class="scale-inner scale p-4">
235
- <edge-cms-block-render
236
- :content="loadingRender(item.content)"
237
- :values="item.values"
238
- :meta="item.meta"
790
+ <div
791
+ v-for="item in applyTagSelectionFilter(slotProps.filtered)"
792
+ :key="item.docId"
793
+ role="button"
794
+ tabindex="0"
795
+ class="w-full h-full"
796
+ @click="router.push(`/app/dashboard/blocks/${item.docId}`)"
797
+ @keyup.enter="router.push(`/app/dashboard/blocks/${item.docId}`)"
798
+ >
799
+ <Card
800
+ class="h-full cursor-pointer border border-border/60 bg-card/40 hover:border-primary/50 hover:shadow-[0_22px_55px_-24px_rgba(0,0,0,0.4)] transition"
801
+ :class="isBlockSelected(item.docId) ? 'border-primary/70 ring-2 ring-primary/50' : 'border-white/5'"
802
+ >
803
+ <CardContent class="flex flex-col gap-1 p-4 sm:p-5">
804
+ <div class="flex items-start gap-3">
805
+ <div class="pt-1" @click.stop>
806
+ <Checkbox
807
+ :model-value="isBlockSelected(item.docId)"
808
+ :aria-label="`Select block ${item.name || item.docId}`"
809
+ class="border-border bg-background/90 shadow-sm data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
810
+ @click.stop
811
+ @update:model-value="setBlockSelection(item.docId, $event)"
239
812
  />
240
813
  </div>
814
+ <p class="text-lg font-semibold leading-snug line-clamp-2 text-foreground flex-1">
815
+ {{ item.name }}
816
+ </p>
817
+ <edge-shad-button
818
+ size="icon"
819
+ variant="ghost"
820
+ class="h-8 w-8 text-foreground/75 hover:text-foreground hover:bg-muted/80"
821
+ @click.stop="slotProps.deleteItem(item.docId)"
822
+ >
823
+ <Trash class="h-4 w-4" />
824
+ </edge-shad-button>
241
825
  </div>
242
- <div class="preview-overlay" />
243
- </div>
244
- <div v-else class="block-preview-empty">
245
- Preview unavailable for this block.
246
- </div>
247
- <div class="flex flex-wrap items-center gap-1 text-[11px] text-slate-300 uppercase tracking-wide overflow-hidden">
248
- <edge-chip
249
- v-for="tag in item.tags?.slice(0, 3) ?? []"
250
- :key="tag"
251
- class="bg-primary/40 text-white px-2 py-0.5 text-[10px]"
252
- >
253
- {{ tag }}
254
- </edge-chip>
255
- <span v-if="item.tags?.length > 3" class="text-white/60">+{{ item.tags.length - 3 }}</span>
256
- <edge-chip
257
- v-for="theme in item.themes?.slice(0, 2) ?? []"
258
- :key="theme"
259
- class="bg-slate-800 text-white px-2 py-0.5 text-[10px]"
260
- >
261
- {{ getThemeFromId(theme) }}
262
- </edge-chip>
263
- <span v-if="item.themes?.length > 2" class="text-white/60">+{{ item.themes.length - 2 }}</span>
264
- <span
265
- v-if="!(item.tags?.length) && !(item.themes?.length)"
266
- class="text-slate-500 lowercase"
267
- >
268
- none
269
- </span>
270
- </div>
271
- </CardContent>
272
- </Card>
826
+ <div v-if="item.content" class="block-preview" :class="previewSurfaceClass(item.previewType)">
827
+ <div class="scale-wrapper">
828
+ <div class="scale-inner scale p-4">
829
+ <edge-cms-block-render
830
+ :content="loadingRender(item.content)"
831
+ :values="item.values"
832
+ :meta="item.meta"
833
+ :theme="getPreviewThemeForBlock(item)"
834
+ />
835
+ </div>
836
+ </div>
837
+ <div class="preview-overlay" />
838
+ </div>
839
+ <div v-else class="block-preview-empty" :class="previewSurfaceClass(item.previewType)">
840
+ Preview unavailable for this block.
841
+ </div>
842
+ <div class="flex flex-wrap items-center gap-1 text-[11px] text-slate-300 uppercase tracking-wide overflow-hidden">
843
+ <edge-chip
844
+ v-for="tag in item.tags?.slice(0, 3) ?? []"
845
+ :key="tag"
846
+ class="bg-primary/40 text-white px-2 py-0.5 text-[10px]"
847
+ >
848
+ {{ tag }}
849
+ </edge-chip>
850
+ <span v-if="item.tags?.length > 3" class="text-white/60">+{{ item.tags.length - 3 }}</span>
851
+ <edge-chip
852
+ v-for="theme in item.themes?.slice(0, 2) ?? []"
853
+ :key="theme"
854
+ class="bg-slate-800 text-white px-2 py-0.5 text-[10px]"
855
+ >
856
+ {{ getThemeFromId(theme) }}
857
+ </edge-chip>
858
+ <span v-if="item.themes?.length > 2" class="text-white/60">+{{ item.themes.length - 2 }}</span>
859
+ <span
860
+ v-if="!(item.tags?.length) && !(item.themes?.length)"
861
+ class="text-slate-500 lowercase"
862
+ >
863
+ none
864
+ </span>
865
+ </div>
866
+ </CardContent>
867
+ </Card>
868
+ </div>
273
869
  </div>
274
870
  </div>
275
871
  </template>
276
872
  </edge-dashboard>
873
+ <edge-shad-dialog v-model="state.importDocIdDialogOpen">
874
+ <DialogContent class="pt-8">
875
+ <DialogHeader>
876
+ <DialogTitle class="text-left">
877
+ Enter Block Doc ID
878
+ </DialogTitle>
879
+ <DialogDescription>
880
+ This JSON file does not include a <code>docId</code>. Enter the doc ID you want to import into.
881
+ </DialogDescription>
882
+ </DialogHeader>
883
+ <edge-shad-input
884
+ v-model="state.importDocIdValue"
885
+ name="block-import-doc-id"
886
+ label="Doc ID"
887
+ placeholder="example-block-id"
888
+ />
889
+ <DialogFooter class="pt-2 flex justify-between">
890
+ <edge-shad-button variant="outline" @click="resolveBlockImportDocId('')">
891
+ Cancel
892
+ </edge-shad-button>
893
+ <edge-shad-button @click="resolveBlockImportDocId(state.importDocIdValue)">
894
+ Continue
895
+ </edge-shad-button>
896
+ </DialogFooter>
897
+ </DialogContent>
898
+ </edge-shad-dialog>
899
+ <edge-shad-dialog v-model="state.importConflictDialogOpen">
900
+ <DialogContent class="pt-8">
901
+ <DialogHeader>
902
+ <DialogTitle class="text-left">
903
+ Block Already Exists
904
+ </DialogTitle>
905
+ <DialogDescription>
906
+ <code>{{ state.importConflictDocId }}</code> already exists. Choose to overwrite it or import as a new block.
907
+ </DialogDescription>
908
+ </DialogHeader>
909
+ <DialogFooter class="pt-2 flex justify-between">
910
+ <edge-shad-button variant="outline" @click="resolveBlockImportConflict('cancel')">
911
+ Cancel
912
+ </edge-shad-button>
913
+ <edge-shad-button variant="outline" @click="resolveBlockImportConflict('new')">
914
+ Add As New
915
+ </edge-shad-button>
916
+ <edge-shad-button @click="resolveBlockImportConflict('overwrite')">
917
+ Overwrite
918
+ </edge-shad-button>
919
+ </DialogFooter>
920
+ </DialogContent>
921
+ </edge-shad-dialog>
922
+ <edge-shad-dialog v-model="state.importErrorDialogOpen">
923
+ <DialogContent class="pt-8">
924
+ <DialogHeader>
925
+ <DialogTitle class="text-left">
926
+ Import Failed
927
+ </DialogTitle>
928
+ <DialogDescription class="text-left">
929
+ {{ state.importErrorMessage }}
930
+ </DialogDescription>
931
+ </DialogHeader>
932
+ <DialogFooter class="pt-2">
933
+ <edge-shad-button @click="state.importErrorDialogOpen = false">
934
+ Close
935
+ </edge-shad-button>
936
+ </DialogFooter>
937
+ </DialogContent>
938
+ </edge-shad-dialog>
939
+ <edge-shad-dialog v-model="state.bulkDeleteDialogOpen">
940
+ <DialogContent class="pt-8">
941
+ <DialogHeader>
942
+ <DialogTitle class="text-left">
943
+ Delete Selected Blocks?
944
+ </DialogTitle>
945
+ <DialogDescription class="text-left">
946
+ This action cannot be undone. {{ selectedBlockCount }} block{{ selectedBlockCount === 1 ? '' : 's' }} will be permanently deleted.
947
+ </DialogDescription>
948
+ </DialogHeader>
949
+ <DialogFooter class="pt-2 flex justify-between">
950
+ <edge-shad-button
951
+ class="text-white bg-slate-800 hover:bg-slate-400"
952
+ :disabled="state.bulkDeleting"
953
+ @click="state.bulkDeleteDialogOpen = false"
954
+ >
955
+ Cancel
956
+ </edge-shad-button>
957
+ <edge-shad-button
958
+ variant="destructive"
959
+ class="text-white w-full"
960
+ :disabled="state.bulkDeleting || selectedBlockCount === 0"
961
+ @click="bulkDeleteAction"
962
+ >
963
+ <Loader2 v-if="state.bulkDeleting" class="h-4 w-4 animate-spin" />
964
+ <span v-else>Delete Selected</span>
965
+ </edge-shad-button>
966
+ </DialogFooter>
967
+ </DialogContent>
968
+ </edge-shad-dialog>
277
969
  </div>
278
970
  </template>
279
971
 
@@ -282,15 +974,22 @@ const listFilters = computed(() => {
282
974
  position: relative;
283
975
  height: 220px;
284
976
  border-radius: 14px;
285
- border: 1px solid rgba(255, 255, 255, 0.06);
286
- background:
287
- radial-gradient(140% 120% at 15% 15%, rgba(96, 165, 250, 0.08), transparent),
288
- radial-gradient(120% 120% at 85% 0%, rgba(168, 85, 247, 0.07), transparent),
289
- linear-gradient(145deg, rgba(10, 14, 26, 0.95), rgba(17, 24, 39, 0.7));
977
+ border: 1px solid rgba(148, 163, 184, 0.28);
978
+ background: transparent;
290
979
  overflow: hidden;
291
- box-shadow:
292
- inset 0 1px 0 rgba(255, 255, 255, 0.02),
293
- 0 18px 38px rgba(0, 0, 0, 0.35);
980
+ box-shadow: none;
981
+ }
982
+
983
+ .block-preview.preview-surface-light {
984
+ background: #ffffff;
985
+ color: #0f172a;
986
+ border-color: rgba(15, 23, 42, 0.15);
987
+ }
988
+
989
+ .block-preview.preview-surface-dark {
990
+ background: #020617;
991
+ color: #f8fafc;
992
+ border-color: rgba(248, 250, 252, 0.18);
294
993
  }
295
994
 
296
995
  .block-preview-empty {
@@ -305,11 +1004,20 @@ const listFilters = computed(() => {
305
1004
  letter-spacing: 0.01em;
306
1005
  }
307
1006
 
1007
+ .block-preview-empty.preview-surface-light {
1008
+ border-color: rgba(15, 23, 42, 0.18);
1009
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(241, 245, 249, 0.95));
1010
+ color: rgba(15, 23, 42, 0.72);
1011
+ }
1012
+
1013
+ .block-preview-empty.preview-surface-dark {
1014
+ border-color: rgba(255, 255, 255, 0.12);
1015
+ background: linear-gradient(135deg, rgba(10, 14, 26, 0.65), rgba(17, 24, 39, 0.5));
1016
+ color: rgba(255, 255, 255, 0.6);
1017
+ }
1018
+
308
1019
  .preview-overlay {
309
- pointer-events: none;
310
- position: absolute;
311
- inset: 0;
312
- background: linear-gradient(180deg, rgba(15, 23, 42, 0) 20%, rgba(15, 23, 42, 0.35) 100%);
1020
+ display: none;
313
1021
  }
314
1022
 
315
1023
  .scale-wrapper {