@edgedev/create-edge-app 1.2.32 → 1.2.34

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