@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,5 +1,5 @@
1
1
  <script setup>
2
- import { AlertTriangle, ArrowDown, ArrowUp, Maximize2, Monitor, Smartphone, Sparkles, Tablet, UploadCloud } from 'lucide-vue-next'
2
+ import { AlertTriangle, ArrowDown, ArrowUp, Download, Maximize2, Monitor, Smartphone, Sparkles, Tablet, UploadCloud } from 'lucide-vue-next'
3
3
  import { toTypedSchema } from '@vee-validate/zod'
4
4
  import * as z from 'zod'
5
5
  const props = defineProps({
@@ -20,6 +20,7 @@ const props = defineProps({
20
20
  const emit = defineEmits(['head'])
21
21
 
22
22
  const edgeFirebase = inject('edgeFirebase')
23
+ const router = useRouter()
23
24
  const { buildPageStructuredData } = useStructuredDataTemplates()
24
25
 
25
26
  const state = reactive({
@@ -41,6 +42,13 @@ const state = reactive({
41
42
  workingDoc: {},
42
43
  seoAiLoading: false,
43
44
  seoAiError: '',
45
+ importingJson: false,
46
+ importDocIdDialogOpen: false,
47
+ importDocIdValue: '',
48
+ importConflictDialogOpen: false,
49
+ importConflictDocId: '',
50
+ importErrorDialogOpen: false,
51
+ importErrorMessage: '',
44
52
  previewViewport: 'full',
45
53
  newRowLayout: '6',
46
54
  newPostRowLayout: '6',
@@ -68,6 +76,10 @@ const state = reactive({
68
76
  },
69
77
  })
70
78
 
79
+ const pageImportInputRef = ref(null)
80
+ const pageImportDocIdResolver = ref(null)
81
+ const pageImportConflictResolver = ref(null)
82
+
71
83
  const schemas = {
72
84
  pages: toTypedSchema(z.object({
73
85
  name: z.string({
@@ -97,6 +109,14 @@ const previewViewportStyle = computed(() => {
97
109
  }
98
110
  })
99
111
 
112
+ const previewViewportContainStyle = computed(() => {
113
+ const shouldContain = !state.editMode
114
+ return {
115
+ ...(previewViewportStyle.value || {}),
116
+ ...(shouldContain ? { transform: 'translateZ(0)' } : {}),
117
+ }
118
+ })
119
+
100
120
  const setPreviewViewport = (viewportId) => {
101
121
  state.previewViewport = viewportId
102
122
  }
@@ -108,6 +128,13 @@ const previewViewportMode = computed(() => {
108
128
  })
109
129
 
110
130
  const isMobilePreview = computed(() => previewViewportMode.value === 'mobile')
131
+ const pagePreviewRenderKey = computed(() => {
132
+ const siteKey = String(props.site || '')
133
+ const pageKey = String(props.page || '')
134
+ const themeKey = String(effectiveThemeId.value || selectedThemeId.value || 'no-theme')
135
+ const modeKey = state.editMode ? 'edit' : 'preview'
136
+ return `${siteKey}:${pageKey}:${themeKey}:${modeKey}`
137
+ })
111
138
 
112
139
  const GRID_CLASSES = {
113
140
  1: 'grid grid-cols-1 gap-4',
@@ -493,6 +520,41 @@ onMounted(() => {
493
520
  }
494
521
  })
495
522
 
523
+ const previewSnapshotsBootstrapping = ref(false)
524
+
525
+ const ensurePreviewSnapshots = async () => {
526
+ const orgId = String(edgeGlobal.edgeState.currentOrganization || '').trim()
527
+ if (!orgId)
528
+ return
529
+
530
+ if (previewSnapshotsBootstrapping.value)
531
+ return
532
+ previewSnapshotsBootstrapping.value = true
533
+
534
+ const themesPath = `organizations/${orgId}/themes`
535
+ const sitesPath = `organizations/${orgId}/sites`
536
+
537
+ // Non-blocking bootstrap: never hold page render on snapshot latency.
538
+ try {
539
+ if (!edgeFirebase.data?.[themesPath]) {
540
+ await edgeFirebase.startSnapshot(themesPath)
541
+ }
542
+ if (!edgeFirebase.data?.[sitesPath]) {
543
+ await edgeFirebase.startSnapshot(sitesPath)
544
+ }
545
+ }
546
+ catch (error) {
547
+ console.error('Failed to start page preview snapshots', error)
548
+ }
549
+ finally {
550
+ previewSnapshotsBootstrapping.value = false
551
+ }
552
+ }
553
+
554
+ onBeforeMount(() => {
555
+ ensurePreviewSnapshots()
556
+ })
557
+
496
558
  const editorDocUpdates = (workingDoc) => {
497
559
  ensureStructureDefaults(workingDoc, false)
498
560
  if (workingDoc?.post || (Array.isArray(workingDoc?.postContent) && workingDoc.postContent.length > 0) || Array.isArray(workingDoc?.postStructure))
@@ -528,19 +590,204 @@ const selectedThemeId = computed(() => {
528
590
  return edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/sites`]?.[props.site]?.theme || ''
529
591
  })
530
592
 
531
- const theme = computed(() => {
532
- const themeId = selectedThemeId.value
533
- if (!themeId)
534
- return null
535
- const themeContents = edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/themes`]?.[themeId]?.theme || null
593
+ const themePreviewCache = useState('edge-cms-page-theme-preview-cache', () => ({}))
594
+ const themeCacheKey = computed(() => {
595
+ const orgId = String(edgeGlobal.edgeState.currentOrganization || 'no-org').trim() || 'no-org'
596
+ const siteKey = props.isTemplateSite ? 'templates' : String(props.site || 'no-site').trim() || 'no-site'
597
+ return `${orgId}:${siteKey}`
598
+ })
599
+
600
+ const hydrateThemeCache = () => {
601
+ const cache = themePreviewCache.value?.[themeCacheKey.value] || {}
602
+ return {
603
+ themeId: typeof cache?.themeId === 'string' ? cache.themeId : '',
604
+ theme: cache?.theme && typeof cache.theme === 'object' ? cache.theme : null,
605
+ head: cache?.head && typeof cache.head === 'object' ? cache.head : {},
606
+ }
607
+ }
608
+
609
+ const writeThemeCache = (patch = {}) => {
610
+ const current = themePreviewCache.value?.[themeCacheKey.value] || {}
611
+ themePreviewCache.value = {
612
+ ...(themePreviewCache.value || {}),
613
+ [themeCacheKey.value]: {
614
+ ...current,
615
+ ...patch,
616
+ },
617
+ }
618
+ }
619
+
620
+ const initialThemeCache = hydrateThemeCache()
621
+ const lastStableThemeId = ref(initialThemeCache.themeId)
622
+ const lastResolvedTheme = ref(initialThemeCache.theme)
623
+ const lastResolvedHead = ref(initialThemeCache.head)
624
+
625
+ const parseThemeDoc = (themeDoc) => {
626
+ const themeContents = themeDoc?.theme || null
536
627
  if (!themeContents)
537
628
  return null
629
+ const extraCSS = typeof themeDoc?.extraCSS === 'string' ? themeDoc.extraCSS : ''
538
630
  try {
539
- return typeof themeContents === 'string' ? JSON.parse(themeContents) : themeContents
631
+ const parsed = typeof themeContents === 'string' ? JSON.parse(themeContents) : themeContents
632
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
633
+ return null
634
+ return { ...parsed, extraCSS }
540
635
  }
541
- catch (e) {
636
+ catch {
637
+ return null
638
+ }
639
+ }
640
+
641
+ const parseHeadDoc = (themeDoc) => {
642
+ try {
643
+ const parsed = JSON.parse(themeDoc?.headJSON || '{}')
644
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
645
+ return parsed
646
+ }
647
+ catch {}
648
+ return {}
649
+ }
650
+
651
+ const applyResolvedTheme = (themeDoc, themeId = '') => {
652
+ const normalizedThemeId = String(themeId || themeDoc?.docId || '').trim()
653
+ if (normalizedThemeId)
654
+ lastStableThemeId.value = normalizedThemeId
655
+
656
+ const parsedTheme = parseThemeDoc(themeDoc)
657
+ if (parsedTheme && typeof parsedTheme === 'object') {
658
+ lastResolvedTheme.value = parsedTheme
659
+ writeThemeCache({ theme: parsedTheme })
660
+ }
661
+
662
+ const parsedHead = parseHeadDoc(themeDoc)
663
+ if (parsedHead && typeof parsedHead === 'object') {
664
+ lastResolvedHead.value = parsedHead
665
+ writeThemeCache({ head: parsedHead })
666
+ }
667
+
668
+ if (normalizedThemeId)
669
+ writeThemeCache({ themeId: normalizedThemeId })
670
+ }
671
+
672
+ const themeFallbackLoading = ref(false)
673
+ const loadSiteThemeFallback = async () => {
674
+ if (themeFallbackLoading.value)
675
+ return
676
+
677
+ const orgPath = String(edgeGlobal.edgeState.organizationDocPath || '').trim()
678
+ if (!orgPath)
679
+ return
680
+
681
+ const selectedId = String(selectedThemeId.value || '').trim()
682
+ if (props.isTemplateSite) {
683
+ if (!selectedId)
684
+ return
685
+ const fromSnapshot = edgeFirebase.data?.[`${orgPath}/themes`]?.[selectedId] || null
686
+ if (fromSnapshot)
687
+ applyResolvedTheme(fromSnapshot, selectedId)
688
+ return
689
+ }
690
+
691
+ const siteId = String(props.site || '').trim()
692
+ if (!siteId || siteId === 'new')
693
+ return
694
+
695
+ themeFallbackLoading.value = true
696
+ try {
697
+ let themeId = selectedId
698
+ if (!themeId) {
699
+ const siteDoc = await edgeFirebase.getDocData(`${orgPath}/sites`, siteId)
700
+ themeId = String(siteDoc?.theme || '').trim()
701
+ }
702
+ if (!themeId)
703
+ return
704
+
705
+ writeThemeCache({ themeId })
706
+ lastStableThemeId.value = themeId
707
+
708
+ const fromSnapshot = edgeFirebase.data?.[`${orgPath}/themes`]?.[themeId] || null
709
+ if (fromSnapshot) {
710
+ applyResolvedTheme(fromSnapshot, themeId)
711
+ if (lastResolvedTheme.value)
712
+ return
713
+ }
714
+
715
+ const themeDoc = await edgeFirebase.getDocData(`${orgPath}/themes`, themeId)
716
+ if (themeDoc)
717
+ applyResolvedTheme(themeDoc, themeId)
718
+ }
719
+ catch (error) {
720
+ console.error('Failed to load fallback theme for page preview', error)
721
+ }
722
+ finally {
723
+ themeFallbackLoading.value = false
724
+ }
725
+ }
726
+
727
+ watch(
728
+ () => edgeGlobal.edgeState.currentOrganization,
729
+ () => {
730
+ ensurePreviewSnapshots()
731
+ loadSiteThemeFallback()
732
+ },
733
+ { immediate: true },
734
+ )
735
+
736
+ watch(
737
+ () => [props.site, props.page, props.isTemplateSite],
738
+ () => {
739
+ loadSiteThemeFallback()
740
+ },
741
+ { immediate: true },
742
+ )
743
+
744
+ watch(
745
+ themeCacheKey,
746
+ () => {
747
+ const hydrated = hydrateThemeCache()
748
+ if (hydrated.themeId)
749
+ lastStableThemeId.value = hydrated.themeId
750
+ if (hydrated.theme && typeof hydrated.theme === 'object')
751
+ lastResolvedTheme.value = hydrated.theme
752
+ if (hydrated.head && typeof hydrated.head === 'object')
753
+ lastResolvedHead.value = hydrated.head
754
+ },
755
+ { immediate: true },
756
+ )
757
+
758
+ watch(selectedThemeId, (themeId) => {
759
+ const normalized = String(themeId || '').trim()
760
+ if (normalized) {
761
+ lastStableThemeId.value = normalized
762
+ writeThemeCache({ themeId: normalized })
763
+ }
764
+ loadSiteThemeFallback()
765
+ }, { immediate: true })
766
+
767
+ const effectiveThemeId = computed(() => {
768
+ const normalized = String(selectedThemeId.value || '').trim()
769
+ if (normalized)
770
+ return normalized
771
+ return lastStableThemeId.value
772
+ })
773
+
774
+ const parsedTheme = computed(() => {
775
+ const themeId = effectiveThemeId.value
776
+ if (!themeId)
542
777
  return null
778
+ const themeDoc = edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/themes`]?.[themeId] || null
779
+ return parseThemeDoc(themeDoc)
780
+ })
781
+
782
+ watch(parsedTheme, (nextTheme) => {
783
+ if (nextTheme && typeof nextTheme === 'object') {
784
+ lastResolvedTheme.value = nextTheme
785
+ writeThemeCache({ theme: nextTheme })
543
786
  }
787
+ }, { immediate: true, deep: true })
788
+
789
+ const theme = computed(() => {
790
+ return parsedTheme.value || lastResolvedTheme.value || null
544
791
  })
545
792
 
546
793
  const themeColorMap = computed(() => {
@@ -847,14 +1094,20 @@ const addRowAt = (workingDoc, layoutValue = '6', insertIndex = 0, isPost = false
847
1094
  }
848
1095
 
849
1096
  const headObject = computed(() => {
850
- const themeId = selectedThemeId.value
1097
+ const themeId = effectiveThemeId.value
851
1098
  if (!themeId)
852
- return {}
1099
+ return lastResolvedHead.value || {}
853
1100
  try {
854
- return JSON.parse(edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/themes`]?.[themeId]?.headJSON || '{}')
1101
+ const parsedHead = parseHeadDoc(edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/themes`]?.[themeId] || null)
1102
+ if (parsedHead && typeof parsedHead === 'object') {
1103
+ lastResolvedHead.value = parsedHead
1104
+ writeThemeCache({ head: parsedHead })
1105
+ return parsedHead
1106
+ }
1107
+ return lastResolvedHead.value || {}
855
1108
  }
856
1109
  catch (e) {
857
- return {}
1110
+ return lastResolvedHead.value || {}
858
1111
  }
859
1112
  })
860
1113
 
@@ -918,6 +1171,397 @@ const currentPage = computed(() => {
918
1171
  return edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/sites/${props.site}/pages`]?.[props.page] || null
919
1172
  })
920
1173
 
1174
+ const pagesCollectionPath = computed(() => `${edgeGlobal.edgeState.organizationDocPath}/sites/${props.site}/pages`)
1175
+ const pagesCollection = computed(() => edgeFirebase.data?.[pagesCollectionPath.value] || {})
1176
+ const pageEditorBasePath = computed(() => (props.isTemplateSite ? '/app/dashboard/templates' : `/app/dashboard/sites/${props.site}`))
1177
+ const INVALID_PAGE_IMPORT_MESSAGE = 'Invalid file. Please import a valid page file.'
1178
+
1179
+ const downloadJsonFile = (payload, filename) => {
1180
+ if (typeof window === 'undefined')
1181
+ return
1182
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
1183
+ const objectUrl = URL.createObjectURL(blob)
1184
+ const anchor = document.createElement('a')
1185
+ anchor.href = objectUrl
1186
+ anchor.download = filename
1187
+ document.body.appendChild(anchor)
1188
+ anchor.click()
1189
+ anchor.remove()
1190
+ URL.revokeObjectURL(objectUrl)
1191
+ }
1192
+
1193
+ const readTextFile = file => new Promise((resolve, reject) => {
1194
+ if (typeof FileReader === 'undefined') {
1195
+ reject(new Error('File import is only available in the browser.'))
1196
+ return
1197
+ }
1198
+ const reader = new FileReader()
1199
+ reader.onload = () => resolve(String(reader.result || ''))
1200
+ reader.onerror = () => reject(new Error('Could not read the selected file.'))
1201
+ reader.readAsText(file)
1202
+ })
1203
+
1204
+ const normalizeImportedDoc = (payload, fallbackDocId = '') => {
1205
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
1206
+ throw new Error(INVALID_PAGE_IMPORT_MESSAGE)
1207
+
1208
+ if (payload.document && typeof payload.document === 'object' && !Array.isArray(payload.document)) {
1209
+ const normalized = { ...payload.document }
1210
+ if (!normalized.docId && payload.docId)
1211
+ normalized.docId = payload.docId
1212
+ if (!normalized.docId && fallbackDocId)
1213
+ normalized.docId = fallbackDocId
1214
+ return normalized
1215
+ }
1216
+
1217
+ const normalized = { ...payload }
1218
+ if (!normalized.docId && fallbackDocId)
1219
+ normalized.docId = fallbackDocId
1220
+ return normalized
1221
+ }
1222
+
1223
+ const isPlainObject = value => !!value && typeof value === 'object' && !Array.isArray(value)
1224
+
1225
+ const cloneSchemaValue = (value) => {
1226
+ if (isPlainObject(value) || Array.isArray(value))
1227
+ return edgeGlobal.dupObject(value)
1228
+ return value
1229
+ }
1230
+
1231
+ const getDocDefaultsFromSchema = (schema = {}) => {
1232
+ const defaults = {}
1233
+ for (const [key, schemaEntry] of Object.entries(schema || {})) {
1234
+ const hasValueProp = isPlainObject(schemaEntry) && Object.prototype.hasOwnProperty.call(schemaEntry, 'value')
1235
+ const baseValue = hasValueProp ? schemaEntry.value : schemaEntry
1236
+ defaults[key] = cloneSchemaValue(baseValue)
1237
+ }
1238
+ return defaults
1239
+ }
1240
+
1241
+ const getPageDocDefaults = () => getDocDefaultsFromSchema(state.newDocs?.pages || {})
1242
+
1243
+ const isBlankString = value => String(value || '').trim() === ''
1244
+
1245
+ const applyImportedPageSeoDefaults = (doc) => {
1246
+ if (!isPlainObject(doc))
1247
+ return doc
1248
+
1249
+ if (isBlankString(doc.structuredData))
1250
+ doc.structuredData = buildPageStructuredData()
1251
+
1252
+ if (doc.post && isBlankString(doc.postStructuredData))
1253
+ doc.postStructuredData = doc.structuredData || buildPageStructuredData()
1254
+
1255
+ return doc
1256
+ }
1257
+
1258
+ const validateImportedPageDoc = (doc) => {
1259
+ if (!isPlainObject(doc))
1260
+ throw new Error(INVALID_PAGE_IMPORT_MESSAGE)
1261
+
1262
+ const requiredKeys = Object.keys(state.newDocs?.pages || {})
1263
+ const missing = requiredKeys.filter(key => !Object.prototype.hasOwnProperty.call(doc, key))
1264
+ if (missing.length)
1265
+ throw new Error(INVALID_PAGE_IMPORT_MESSAGE)
1266
+
1267
+ return doc
1268
+ }
1269
+
1270
+ const normalizeMenusForImport = (menus) => {
1271
+ const normalized = isPlainObject(menus) ? edgeGlobal.dupObject(menus) : {}
1272
+ if (!Array.isArray(normalized['Site Root']))
1273
+ normalized['Site Root'] = []
1274
+ if (!Array.isArray(normalized['Not In Menu']))
1275
+ normalized['Not In Menu'] = []
1276
+ return normalized
1277
+ }
1278
+
1279
+ const walkMenuEntries = (items, callback) => {
1280
+ if (!Array.isArray(items))
1281
+ return
1282
+ for (const entry of items) {
1283
+ if (!entry || typeof entry !== 'object')
1284
+ continue
1285
+ callback(entry)
1286
+ if (isPlainObject(entry.item)) {
1287
+ for (const nested of Object.values(entry.item)) {
1288
+ if (Array.isArray(nested))
1289
+ walkMenuEntries(nested, callback)
1290
+ }
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ const menuIncludesDocId = (menus, docId) => {
1296
+ let found = false
1297
+ const checkEntry = (entry) => {
1298
+ if (found)
1299
+ return
1300
+ if (typeof entry?.item === 'string' && entry.item === docId)
1301
+ found = true
1302
+ }
1303
+ for (const menuItems of Object.values(menus || {})) {
1304
+ walkMenuEntries(menuItems, checkEntry)
1305
+ if (found)
1306
+ return true
1307
+ }
1308
+ return false
1309
+ }
1310
+
1311
+ const collectMenuPageNames = (menus) => {
1312
+ const names = new Set()
1313
+ const collectEntry = (entry) => {
1314
+ if (typeof entry?.item !== 'string')
1315
+ return
1316
+ const name = String(entry?.name || '').trim()
1317
+ if (name)
1318
+ names.add(name)
1319
+ }
1320
+ for (const menuItems of Object.values(menus || {}))
1321
+ walkMenuEntries(menuItems, collectEntry)
1322
+ return names
1323
+ }
1324
+
1325
+ const slugifyMenuPageName = (value) => {
1326
+ return String(value || '')
1327
+ .trim()
1328
+ .toLowerCase()
1329
+ .replace(/[^a-z0-9]+/g, '-')
1330
+ .replace(/(^-|-$)+/g, '') || 'page'
1331
+ }
1332
+
1333
+ const makeUniqueMenuPageName = (value, existingNames = new Set()) => {
1334
+ const base = slugifyMenuPageName(value)
1335
+ let candidate = base
1336
+ let suffix = 2
1337
+ while (existingNames.has(candidate)) {
1338
+ candidate = `${base}-${suffix}`
1339
+ suffix += 1
1340
+ }
1341
+ return candidate
1342
+ }
1343
+
1344
+ const addImportedPageToSiteMenu = async (docId, pageName = '') => {
1345
+ const nextDocId = String(docId || '').trim()
1346
+ if (!nextDocId)
1347
+ return
1348
+ const siteId = String(props.site || '').trim()
1349
+ if (!siteId)
1350
+ return
1351
+
1352
+ const sitesCollectionPath = `${edgeGlobal.edgeState.organizationDocPath}/sites`
1353
+ const siteDoc = edgeFirebase.data?.[sitesCollectionPath]?.[siteId] || {}
1354
+ const menus = normalizeMenusForImport(siteDoc?.menus)
1355
+ if (menuIncludesDocId(menus, nextDocId))
1356
+ return
1357
+
1358
+ const existingNames = collectMenuPageNames(menus)
1359
+ const menuName = makeUniqueMenuPageName(pageName || nextDocId, existingNames)
1360
+ menus['Site Root'].push({ name: menuName, item: nextDocId })
1361
+
1362
+ const results = await edgeFirebase.changeDoc(sitesCollectionPath, siteId, { menus })
1363
+ if (results?.success === false)
1364
+ throw new Error('Could not save updated site menu.')
1365
+ }
1366
+
1367
+ const makeRandomPageDocId = (docsMap = {}) => {
1368
+ let nextDocId = String(edgeGlobal.generateShortId() || '').trim()
1369
+ while (!nextDocId || docsMap[nextDocId])
1370
+ nextDocId = String(edgeGlobal.generateShortId() || '').trim()
1371
+ return nextDocId
1372
+ }
1373
+
1374
+ const makeImportedPageNameForNew = (baseName, docsMap = {}) => {
1375
+ const normalizedBase = String(baseName || '').trim() || 'page'
1376
+ const existingNames = new Set(
1377
+ Object.values(docsMap || {})
1378
+ .map(doc => String(doc?.name || '').trim().toLowerCase())
1379
+ .filter(Boolean),
1380
+ )
1381
+
1382
+ let suffix = 1
1383
+ let candidate = `${normalizedBase}-${suffix}`
1384
+ while (existingNames.has(candidate.toLowerCase())) {
1385
+ suffix += 1
1386
+ candidate = `${normalizedBase}-${suffix}`
1387
+ }
1388
+ return candidate
1389
+ }
1390
+
1391
+ const requestPageImportDocId = (initialValue = '') => {
1392
+ state.importDocIdValue = String(initialValue || '')
1393
+ state.importDocIdDialogOpen = true
1394
+ return new Promise((resolve) => {
1395
+ pageImportDocIdResolver.value = resolve
1396
+ })
1397
+ }
1398
+
1399
+ const resolvePageImportDocId = (value = '') => {
1400
+ const resolver = pageImportDocIdResolver.value
1401
+ pageImportDocIdResolver.value = null
1402
+ state.importDocIdDialogOpen = false
1403
+ if (resolver)
1404
+ resolver(String(value || '').trim())
1405
+ }
1406
+
1407
+ const requestPageImportConflict = (docId) => {
1408
+ state.importConflictDocId = String(docId || '')
1409
+ state.importConflictDialogOpen = true
1410
+ return new Promise((resolve) => {
1411
+ pageImportConflictResolver.value = resolve
1412
+ })
1413
+ }
1414
+
1415
+ const resolvePageImportConflict = (action = 'cancel') => {
1416
+ const resolver = pageImportConflictResolver.value
1417
+ pageImportConflictResolver.value = null
1418
+ state.importConflictDialogOpen = false
1419
+ if (resolver)
1420
+ resolver(action)
1421
+ }
1422
+
1423
+ watch(() => state.importDocIdDialogOpen, (open) => {
1424
+ if (!open && pageImportDocIdResolver.value) {
1425
+ const resolver = pageImportDocIdResolver.value
1426
+ pageImportDocIdResolver.value = null
1427
+ resolver('')
1428
+ }
1429
+ })
1430
+
1431
+ watch(() => state.importConflictDialogOpen, (open) => {
1432
+ if (!open && pageImportConflictResolver.value) {
1433
+ const resolver = pageImportConflictResolver.value
1434
+ pageImportConflictResolver.value = null
1435
+ resolver('cancel')
1436
+ }
1437
+ })
1438
+
1439
+ const getImportDocId = async (incomingDoc, fallbackDocId = '') => {
1440
+ let nextDocId = String(incomingDoc?.docId || '').trim()
1441
+ if (!nextDocId)
1442
+ nextDocId = await requestPageImportDocId(fallbackDocId)
1443
+ if (!nextDocId)
1444
+ throw new Error('Import canceled. A docId is required.')
1445
+ if (nextDocId.includes('/'))
1446
+ throw new Error('docId cannot include "/".')
1447
+ return nextDocId
1448
+ }
1449
+
1450
+ const notifySuccess = (message) => {
1451
+ edgeFirebase?.toast?.success?.(message)
1452
+ }
1453
+
1454
+ const notifyError = (message) => {
1455
+ edgeFirebase?.toast?.error?.(message)
1456
+ }
1457
+
1458
+ const openImportErrorDialog = (message) => {
1459
+ state.importErrorMessage = String(message || 'Failed to import page JSON.')
1460
+ state.importErrorDialogOpen = true
1461
+ }
1462
+
1463
+ const exportCurrentPage = () => {
1464
+ const doc = currentPage.value
1465
+ if (!doc || !props.page || props.page === 'new') {
1466
+ notifyError('Save this page before exporting.')
1467
+ return
1468
+ }
1469
+ const docId = String(doc.docId || props.page).trim()
1470
+ const exportPayload = { ...getPageDocDefaults(), ...doc, docId }
1471
+ downloadJsonFile(exportPayload, `page-${docId}.json`)
1472
+ notifySuccess(`Exported page "${docId}".`)
1473
+ }
1474
+
1475
+ const triggerPageImport = () => {
1476
+ pageImportInputRef.value?.click()
1477
+ }
1478
+
1479
+ const importSinglePageFile = async (file, existingPages = {}, fallbackDocId = '') => {
1480
+ const fileText = await readTextFile(file)
1481
+ const parsed = JSON.parse(fileText)
1482
+ const importedDoc = applyImportedPageSeoDefaults(validateImportedPageDoc(normalizeImportedDoc(parsed, fallbackDocId)))
1483
+ const incomingDocId = await getImportDocId(importedDoc, fallbackDocId)
1484
+ let targetDocId = incomingDocId
1485
+ let importDecision = 'create'
1486
+
1487
+ if (existingPages[targetDocId]) {
1488
+ const decision = await requestPageImportConflict(targetDocId)
1489
+ if (decision === 'cancel')
1490
+ return ''
1491
+ if (decision === 'new') {
1492
+ targetDocId = makeRandomPageDocId(existingPages)
1493
+ importedDoc.name = makeImportedPageNameForNew(importedDoc.name || incomingDocId, existingPages)
1494
+ importDecision = 'new'
1495
+ }
1496
+ else {
1497
+ importDecision = 'overwrite'
1498
+ }
1499
+ }
1500
+
1501
+ const isCreatingNewPage = !existingPages[targetDocId]
1502
+ const payload = { ...getPageDocDefaults(), ...importedDoc, docId: targetDocId }
1503
+ await edgeFirebase.storeDoc(pagesCollectionPath.value, payload, targetDocId)
1504
+ existingPages[targetDocId] = payload
1505
+
1506
+ if (isCreatingNewPage) {
1507
+ try {
1508
+ await addImportedPageToSiteMenu(targetDocId, importedDoc.name)
1509
+ }
1510
+ catch (menuError) {
1511
+ console.error('Imported page but failed to update site menu', menuError)
1512
+ openImportErrorDialog('Imported page, but could not add it to Site Menu automatically.')
1513
+ }
1514
+ }
1515
+
1516
+ if (importDecision === 'overwrite')
1517
+ notifySuccess(`Overwrote page "${targetDocId}".`)
1518
+ else if (importDecision === 'new')
1519
+ notifySuccess(`Imported page as new "${targetDocId}".`)
1520
+ else
1521
+ notifySuccess(`Imported page "${targetDocId}".`)
1522
+
1523
+ return targetDocId
1524
+ }
1525
+
1526
+ const handlePageImport = async (event) => {
1527
+ const input = event?.target
1528
+ const files = Array.from(input?.files || [])
1529
+ if (!files.length)
1530
+ return
1531
+
1532
+ state.importingJson = true
1533
+ const fallbackDocId = props.page !== 'new' ? props.page : ''
1534
+ const existingPages = { ...(pagesCollection.value || {}) }
1535
+ let lastImportedDocId = ''
1536
+ try {
1537
+ for (const file of files) {
1538
+ try {
1539
+ const importedDocId = await importSinglePageFile(file, existingPages, fallbackDocId)
1540
+ if (importedDocId)
1541
+ lastImportedDocId = importedDocId
1542
+ }
1543
+ catch (error) {
1544
+ console.error('Failed to import page JSON', error)
1545
+ const message = error?.message || 'Failed to import page JSON.'
1546
+ if (/^Import canceled\./i.test(message))
1547
+ continue
1548
+ if (error instanceof SyntaxError || message === INVALID_PAGE_IMPORT_MESSAGE)
1549
+ openImportErrorDialog(INVALID_PAGE_IMPORT_MESSAGE)
1550
+ else
1551
+ openImportErrorDialog(message)
1552
+ }
1553
+ }
1554
+
1555
+ if (files.length === 1 && lastImportedDocId && lastImportedDocId !== props.page)
1556
+ await router.push(`${pageEditorBasePath.value}/${lastImportedDocId}`)
1557
+ }
1558
+ finally {
1559
+ state.importingJson = false
1560
+ if (input)
1561
+ input.value = ''
1562
+ }
1563
+ }
1564
+
921
1565
  watch (currentPage, (newPage) => {
922
1566
  state.workingDoc.last_updated = newPage?.last_updated
923
1567
  state.workingDoc.metaTitle = newPage?.metaTitle
@@ -1141,7 +1785,7 @@ const hasUnsavedChanges = (changes) => {
1141
1785
  @unsaved-changes="hasUnsavedChanges"
1142
1786
  >
1143
1787
  <template #header="slotProps">
1144
- <div class="relative flex items-center bg-secondary p-2 justify-between sticky top-0 z-50 bg-primary rounded h-[50px]">
1788
+ <div class="relative flex items-center p-2 justify-between sticky top-0 z-50 bg-gray-100 rounded h-[50px]">
1145
1789
  <span class="text-lg font-semibold whitespace-nowrap pr-1">{{ pageName }}</span>
1146
1790
 
1147
1791
  <div class="flex w-full items-center">
@@ -1188,6 +1832,21 @@ const hasUnsavedChanges = (changes) => {
1188
1832
  </div>
1189
1833
  <div class="w-full border-t border-border" aria-hidden="true" />
1190
1834
 
1835
+ <div class="flex items-center gap-2 px-3">
1836
+ <edge-shad-button
1837
+ type="button"
1838
+ size="icon"
1839
+ variant="outline"
1840
+ class="h-8 w-8"
1841
+ :disabled="!currentPage || !props.page || props.page === 'new'"
1842
+ title="Export Page"
1843
+ aria-label="Export Page"
1844
+ @click="exportCurrentPage"
1845
+ >
1846
+ <Download class="w-3.5 h-3.5" />
1847
+ </edge-shad-button>
1848
+ </div>
1849
+
1191
1850
  <div class="flex items-center gap-1 pr-3">
1192
1851
  <span class="text-[11px] uppercase tracking-wide text-muted-foreground">Viewport</span>
1193
1852
  <edge-shad-button
@@ -1276,10 +1935,10 @@ const hasUnsavedChanges = (changes) => {
1276
1935
  <TabsContent value="list">
1277
1936
  <Separator class="my-4" />
1278
1937
  <div
1279
- :key="selectedThemeId"
1280
- class="w-full mx-auto bg-card border border-border rounded-lg shadow-sm md:shadow-md p-0 space-y-6"
1281
- :class="{ 'transition-all duration-300': !state.editMode }"
1282
- :style="previewViewportStyle"
1938
+ :key="`${pagePreviewRenderKey}:list`"
1939
+ class="w-full mx-auto bg-card border border-border shadow-sm md:shadow-md p-0 space-y-6"
1940
+ :class="[{ 'transition-all duration-300': !state.editMode }, state.editMode ? 'rounded-lg' : 'rounded-none']"
1941
+ :style="previewViewportContainStyle"
1283
1942
  >
1284
1943
  <edge-button-divider v-if="state.editMode" class="my-2">
1285
1944
  <Popover v-model:open="state.addRowPopoverOpen.listTop">
@@ -1419,9 +2078,11 @@ const hasUnsavedChanges = (changes) => {
1419
2078
  <div :key="blockId" class="relative group">
1420
2079
  <edge-cms-block
1421
2080
  v-if="blockIndex(slotProps.workingDoc, blockId, false) !== -1"
2081
+ :key="`${pagePreviewRenderKey}:${blockId}:${effectiveThemeId}:list`"
1422
2082
  v-model="slotProps.workingDoc.content[blockIndex(slotProps.workingDoc, blockId, false)]"
1423
2083
  :site-id="props.site"
1424
2084
  :edit-mode="state.editMode"
2085
+ :contain-fixed="true"
1425
2086
  :viewport-mode="previewViewportMode"
1426
2087
  :block-id="blockId"
1427
2088
  :theme="theme"
@@ -1527,10 +2188,10 @@ const hasUnsavedChanges = (changes) => {
1527
2188
  <TabsContent value="post">
1528
2189
  <Separator class="my-4" />
1529
2190
  <div
1530
- :key="`${selectedThemeId}-post`"
1531
- class="w-full mx-auto bg-card border border-border rounded-lg shadow-sm md:shadow-md p-4 space-y-6"
1532
- :class="{ 'transition-all duration-300': !state.editMode }"
1533
- :style="previewViewportStyle"
2191
+ :key="`${pagePreviewRenderKey}:post`"
2192
+ class="w-full mx-auto bg-card border border-border shadow-sm md:shadow-md p-4 space-y-6"
2193
+ :class="[{ 'transition-all duration-300': !state.editMode }, state.editMode ? 'rounded-lg' : 'rounded-none']"
2194
+ :style="previewViewportContainStyle"
1534
2195
  >
1535
2196
  <edge-button-divider v-if="state.editMode" class="my-2">
1536
2197
  <Popover v-model:open="state.addRowPopoverOpen.postTop">
@@ -1670,8 +2331,10 @@ const hasUnsavedChanges = (changes) => {
1670
2331
  <div :key="blockId" class="relative group">
1671
2332
  <edge-cms-block
1672
2333
  v-if="blockIndex(slotProps.workingDoc, blockId, true) !== -1"
2334
+ :key="`${pagePreviewRenderKey}:${blockId}:${effectiveThemeId}:post`"
1673
2335
  v-model="slotProps.workingDoc.postContent[blockIndex(slotProps.workingDoc, blockId, true)]"
1674
2336
  :edit-mode="state.editMode"
2337
+ :contain-fixed="true"
1675
2338
  :viewport-mode="previewViewportMode"
1676
2339
  :block-id="blockId"
1677
2340
  :theme="theme"
@@ -1846,6 +2509,72 @@ const hasUnsavedChanges = (changes) => {
1846
2509
  </Sheet>
1847
2510
  </template>
1848
2511
  </edge-editor>
2512
+ <edge-shad-dialog v-model="state.importDocIdDialogOpen">
2513
+ <DialogContent class="pt-8">
2514
+ <DialogHeader>
2515
+ <DialogTitle class="text-left">
2516
+ Enter Page Doc ID
2517
+ </DialogTitle>
2518
+ <DialogDescription>
2519
+ This JSON file does not include a <code>docId</code>. Enter the doc ID you want to import into this site.
2520
+ </DialogDescription>
2521
+ </DialogHeader>
2522
+ <edge-shad-input
2523
+ v-model="state.importDocIdValue"
2524
+ name="page-import-doc-id"
2525
+ label="Doc ID"
2526
+ placeholder="example-page-id"
2527
+ />
2528
+ <DialogFooter class="pt-2 flex justify-between">
2529
+ <edge-shad-button variant="outline" @click="resolvePageImportDocId('')">
2530
+ Cancel
2531
+ </edge-shad-button>
2532
+ <edge-shad-button @click="resolvePageImportDocId(state.importDocIdValue)">
2533
+ Continue
2534
+ </edge-shad-button>
2535
+ </DialogFooter>
2536
+ </DialogContent>
2537
+ </edge-shad-dialog>
2538
+ <edge-shad-dialog v-model="state.importConflictDialogOpen">
2539
+ <DialogContent class="pt-8">
2540
+ <DialogHeader>
2541
+ <DialogTitle class="text-left">
2542
+ Page Already Exists
2543
+ </DialogTitle>
2544
+ <DialogDescription>
2545
+ <code>{{ state.importConflictDocId }}</code> already exists in this site. Choose to overwrite it or import as a new page.
2546
+ </DialogDescription>
2547
+ </DialogHeader>
2548
+ <DialogFooter class="pt-2 flex justify-between">
2549
+ <edge-shad-button variant="outline" @click="resolvePageImportConflict('cancel')">
2550
+ Cancel
2551
+ </edge-shad-button>
2552
+ <edge-shad-button variant="outline" @click="resolvePageImportConflict('new')">
2553
+ Add As New
2554
+ </edge-shad-button>
2555
+ <edge-shad-button @click="resolvePageImportConflict('overwrite')">
2556
+ Overwrite
2557
+ </edge-shad-button>
2558
+ </DialogFooter>
2559
+ </DialogContent>
2560
+ </edge-shad-dialog>
2561
+ <edge-shad-dialog v-model="state.importErrorDialogOpen">
2562
+ <DialogContent class="pt-8">
2563
+ <DialogHeader>
2564
+ <DialogTitle class="text-left">
2565
+ Import Failed
2566
+ </DialogTitle>
2567
+ <DialogDescription class="text-left">
2568
+ {{ state.importErrorMessage }}
2569
+ </DialogDescription>
2570
+ </DialogHeader>
2571
+ <DialogFooter class="pt-2">
2572
+ <edge-shad-button @click="state.importErrorDialogOpen = false">
2573
+ Close
2574
+ </edge-shad-button>
2575
+ </DialogFooter>
2576
+ </DialogContent>
2577
+ </edge-shad-dialog>
1849
2578
  <edge-shad-dialog v-model="state.showUnpublishedChangesDialog">
1850
2579
  <DialogContent class="max-w-2xl">
1851
2580
  <DialogHeader>