@edgedev/create-edge-app 1.1.27 → 1.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/edge/components/auth/register.vue +51 -0
  2. package/edge/components/cms/block.vue +363 -42
  3. package/edge/components/cms/blockEditor.vue +50 -3
  4. package/edge/components/cms/codeEditor.vue +39 -2
  5. package/edge/components/cms/htmlContent.vue +10 -2
  6. package/edge/components/cms/init_blocks/footer.html +111 -19
  7. package/edge/components/cms/init_blocks/image.html +8 -0
  8. package/edge/components/cms/init_blocks/post_content.html +3 -2
  9. package/edge/components/cms/init_blocks/post_title_header.html +8 -6
  10. package/edge/components/cms/init_blocks/posts_list.html +6 -5
  11. package/edge/components/cms/mediaCard.vue +13 -2
  12. package/edge/components/cms/mediaManager.vue +35 -5
  13. package/edge/components/cms/menu.vue +384 -61
  14. package/edge/components/cms/optionsSelect.vue +20 -3
  15. package/edge/components/cms/page.vue +160 -18
  16. package/edge/components/cms/site.vue +548 -374
  17. package/edge/components/cms/siteSettingsForm.vue +623 -0
  18. package/edge/components/cms/themeDefaultMenu.vue +258 -22
  19. package/edge/components/cms/themeEditor.vue +95 -11
  20. package/edge/components/editor.vue +1 -0
  21. package/edge/components/formSubtypes/myOrgs.vue +112 -1
  22. package/edge/components/imagePicker.vue +126 -0
  23. package/edge/components/myAccount.vue +1 -0
  24. package/edge/components/myProfile.vue +345 -61
  25. package/edge/components/orgSwitcher.vue +1 -1
  26. package/edge/components/organizationMembers.vue +620 -235
  27. package/edge/components/shad/html.vue +6 -0
  28. package/edge/components/shad/number.vue +2 -2
  29. package/edge/components/sideBar.vue +7 -4
  30. package/edge/components/sideBarContent.vue +1 -1
  31. package/edge/components/userMenu.vue +50 -14
  32. package/edge/composables/global.ts +4 -1
  33. package/edge/composables/siteSettingsTemplate.js +79 -0
  34. package/edge/composables/structuredDataTemplates.js +36 -0
  35. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  <script setup lang="js">
2
2
  import { useVModel } from '@vueuse/core'
3
- import { File, FileCheck, FileCog, FileDown, FileMinus2, FilePen, FilePlus2, FileUp, FileWarning, FileX, Folder, FolderMinus, FolderOpen, FolderPen, FolderPlus } from 'lucide-vue-next'
3
+ import { File, FileCheck, FileCog, FileDown, FileMinus2, FilePen, FilePlus2, FileUp, FileWarning, FileX, Folder, FolderMinus, FolderOpen, FolderPen, FolderPlus, Link } from 'lucide-vue-next'
4
4
  import { toTypedSchema } from '@vee-validate/zod'
5
5
  import * as z from 'zod'
6
+ import { useStructuredDataTemplates } from '@/edge/composables/structuredDataTemplates'
6
7
 
7
8
  const props = defineProps({
8
9
  prevModelValue: {
@@ -50,6 +51,15 @@ const router = useRouter()
50
51
  const modelValue = useVModel(props, 'modelValue', emit)
51
52
  const route = useRoute()
52
53
  const edgeFirebase = inject('edgeFirebase')
54
+ const { buildPageStructuredData } = useStructuredDataTemplates()
55
+
56
+ const isExternalLinkEntry = entry => entry?.item && typeof entry.item === 'object' && entry.item.type === 'external'
57
+ const isPageEntry = entry => typeof entry?.item === 'string'
58
+ const isRenameDisabled = entry => isPageEntry(entry) && !!entry?.disableRename
59
+ const isDeleteDisabled = entry => isPageEntry(entry) && !!entry?.disableDelete
60
+ const isLinkUrlSpecial = url => /^tel:|^mailto:/i.test(String(url || '').trim())
61
+ const linkTarget = url => (isLinkUrlSpecial(url) ? null : '_blank')
62
+ const linkRel = url => (isLinkUrlSpecial(url) ? null : 'noopener noreferrer')
53
63
 
54
64
  const normalizeForCompare = (value) => {
55
65
  if (Array.isArray(value))
@@ -65,6 +75,42 @@ const normalizeForCompare = (value) => {
65
75
 
66
76
  const stableSerialize = value => JSON.stringify(normalizeForCompare(value))
67
77
  const areEqualNormalized = (a, b) => stableSerialize(a) === stableSerialize(b)
78
+ const isBlankString = value => String(value || '').trim() === ''
79
+ const isJsonInvalid = (value) => {
80
+ if (value === null || value === undefined)
81
+ return false
82
+ if (typeof value === 'object')
83
+ return false
84
+ const text = String(value).trim()
85
+ if (!text)
86
+ return false
87
+ try {
88
+ JSON.parse(text)
89
+ return false
90
+ }
91
+ catch {
92
+ return true
93
+ }
94
+ }
95
+ const hasStructuredDataErrors = (doc) => {
96
+ if (!doc)
97
+ return false
98
+ if (isJsonInvalid(doc.structuredData))
99
+ return true
100
+ if (doc.post && isJsonInvalid(doc.postStructuredData))
101
+ return true
102
+ return false
103
+ }
104
+ const ensurePostSeoDefaults = (doc) => {
105
+ if (!doc?.post)
106
+ return
107
+ if (isBlankString(doc.postMetaTitle))
108
+ doc.postMetaTitle = doc.metaTitle || ''
109
+ if (isBlankString(doc.postMetaDescription))
110
+ doc.postMetaDescription = doc.metaDescription || ''
111
+ if (isBlankString(doc.postStructuredData))
112
+ doc.postStructuredData = doc.structuredData || buildPageStructuredData()
113
+ }
68
114
 
69
115
  const orderedMenus = computed(() => {
70
116
  const menuEntries = Object.entries(modelValue.value || {}).map(([name, menu], originalIndex) => ({
@@ -107,6 +153,9 @@ const isPublishedPageDiff = (pageId) => {
107
153
  metaTitle: publishedPage.metaTitle,
108
154
  metaDescription: publishedPage.metaDescription,
109
155
  structuredData: publishedPage.structuredData,
156
+ postMetaTitle: publishedPage.postMetaTitle,
157
+ postMetaDescription: publishedPage.postMetaDescription,
158
+ postStructuredData: publishedPage.postStructuredData,
110
159
  },
111
160
  {
112
161
  content: draftPage.content,
@@ -116,6 +165,9 @@ const isPublishedPageDiff = (pageId) => {
116
165
  metaTitle: draftPage.metaTitle,
117
166
  metaDescription: draftPage.metaDescription,
118
167
  structuredData: draftPage.structuredData,
168
+ postMetaTitle: draftPage.postMetaTitle,
169
+ postMetaDescription: draftPage.postMetaDescription,
170
+ postStructuredData: draftPage.postStructuredData,
119
171
  },
120
172
  )
121
173
  }
@@ -142,17 +194,29 @@ const state = reactive({
142
194
  newPageName: '',
143
195
  indexPath: '',
144
196
  addMenu: false,
197
+ addLinkDialog: false,
145
198
  deletePage: {},
146
199
  renameItem: {},
147
200
  renameFolderOrPageDialog: false,
148
201
  deletePageDialog: false,
149
202
  pageSettings: false,
150
203
  pageData: {},
204
+ linkDialogMode: 'add',
205
+ linkName: '',
206
+ linkUrl: '',
207
+ linkTargetMenu: '',
208
+ linkTargetIndex: -1,
151
209
  newDocs: {
152
210
  pages: {
153
211
  name: { value: '' },
154
212
  content: { value: [] },
155
213
  blockIds: { value: [] },
214
+ metaTitle: { value: '' },
215
+ metaDescription: { value: '' },
216
+ structuredData: { value: buildPageStructuredData() },
217
+ postMetaTitle: { value: '' },
218
+ postMetaDescription: { value: '' },
219
+ postStructuredData: { value: '' },
156
220
  tags: { value: [] },
157
221
  allowedThemes: { value: [] },
158
222
  },
@@ -182,6 +246,8 @@ const templateTagItems = computed(() => {
182
246
  return [{ name: 'Quick Picks', title: 'Quick Picks' }, ...tagList]
183
247
  })
184
248
 
249
+ const BLANK_TEMPLATE_ID = 'blank'
250
+
185
251
  const resetAddPageDialogState = () => {
186
252
  state.newPageName = ''
187
253
  state.templateFilter = 'quick-picks'
@@ -194,6 +260,21 @@ watch(() => state.addPageDialog, (open) => {
194
260
  resetAddPageDialogState()
195
261
  })
196
262
 
263
+ const resetLinkDialogState = () => {
264
+ state.linkDialogMode = 'add'
265
+ state.linkName = ''
266
+ state.linkUrl = ''
267
+ state.linkTargetMenu = ''
268
+ state.linkTargetIndex = -1
269
+ }
270
+
271
+ watch(() => state.addLinkDialog, (open) => {
272
+ if (!open)
273
+ resetLinkDialogState()
274
+ })
275
+
276
+ const TEMPLATE_COLLECTION_PATH = computed(() => `${edgeGlobal.edgeState.organizationDocPath}/sites/templates/pages`)
277
+
197
278
  onMounted(async () => {
198
279
  if (!edgeGlobal.edgeState.organizationDocPath)
199
280
  return
@@ -202,8 +283,6 @@ onMounted(async () => {
202
283
  await edgeFirebase.startSnapshot(path)
203
284
  })
204
285
 
205
- const TEMPLATE_COLLECTION_PATH = computed(() => `${edgeGlobal.edgeState.organizationDocPath}/sites/templates/pages`)
206
-
207
286
  const templatePagesCollection = computed(() => {
208
287
  return edgeFirebase.data?.[TEMPLATE_COLLECTION_PATH.value] || {}
209
288
  })
@@ -265,8 +344,6 @@ watch(filteredTemplates, (templates) => {
265
344
  state.selectedTemplateId = BLANK_TEMPLATE_ID
266
345
  })
267
346
 
268
- const BLANK_TEMPLATE_ID = 'blank'
269
-
270
347
  const blankTemplateTile = {
271
348
  docId: BLANK_TEMPLATE_ID,
272
349
  name: 'Blank Page',
@@ -314,6 +391,8 @@ const templatePreviewBlocks = (template) => {
314
391
  }
315
392
 
316
393
  const renameFolderOrPageShow = (item) => {
394
+ if (isRenameDisabled(item))
395
+ return
317
396
  // Work on a copy so edits in the dialog do not mutate the live menu entry.
318
397
  state.renameItem = edgeGlobal.dupObject(item || {})
319
398
  state.renameItem.previousName = item?.name
@@ -328,6 +407,8 @@ const addPageShow = (menuName, isMenu = false) => {
328
407
  }
329
408
 
330
409
  const deletePageShow = (page) => {
410
+ if (isDeleteDisabled(page))
411
+ return
331
412
  state.deletePage = page
332
413
  state.deletePageDialog = true
333
414
  }
@@ -343,6 +424,9 @@ const collectRootLevelSlugs = (excludeName = '') => {
343
424
  if (entry.name && entry.name !== excludeName)
344
425
  slugs.add(entry.name)
345
426
  }
427
+ else if (isExternalLinkEntry(entry)) {
428
+ continue
429
+ }
346
430
  // Top-level folder at "/<folder>/*"
347
431
  else if (entry && typeof entry.item === 'object') {
348
432
  const key = Object.keys(entry.item)[0]
@@ -362,6 +446,9 @@ const collectRootLevelSlugs = (excludeName = '') => {
362
446
  if (entry.name && entry.name !== excludeName)
363
447
  slugs.add(entry.name)
364
448
  }
449
+ else if (isExternalLinkEntry(entry)) {
450
+ continue
451
+ }
365
452
  // Top-level folder at "/<folder>/*"
366
453
  else if (entry && typeof entry.item === 'object') {
367
454
  const key = Object.keys(entry.item)[0]
@@ -380,6 +467,9 @@ const collectRootLevelSlugs = (excludeName = '') => {
380
467
  if (entry.name && entry.name !== excludeName)
381
468
  slugs.add(entry.name)
382
469
  }
470
+ else if (isExternalLinkEntry(entry)) {
471
+ continue
472
+ }
383
473
  // Top-level folder at "/<folder>/*"
384
474
  else if (entry && typeof entry.item === 'object') {
385
475
  const key = Object.keys(entry.item)[0]
@@ -471,6 +561,8 @@ const hydrateSyncedBlocksFromSite = (blocks = []) => {
471
561
 
472
562
  const buildPagePayloadFromTemplate = (templateDoc, slug) => {
473
563
  const timestamp = Date.now()
564
+ const templateStructuredData = typeof templateDoc?.structuredData === 'string' ? templateDoc.structuredData.trim() : ''
565
+ const structuredData = templateDoc ? (templateStructuredData || buildPageStructuredData()) : buildPageStructuredData()
474
566
  const basePayload = {
475
567
  name: slug,
476
568
  content: [],
@@ -478,7 +570,10 @@ const buildPagePayloadFromTemplate = (templateDoc, slug) => {
478
570
  blockIds: [],
479
571
  metaTitle: '',
480
572
  metaDescription: '',
481
- structuredData: '',
573
+ structuredData,
574
+ postMetaTitle: '',
575
+ postMetaDescription: '',
576
+ postStructuredData: '',
482
577
  doc_created_at: timestamp,
483
578
  last_updated: timestamp,
484
579
  }
@@ -492,10 +587,35 @@ const buildPagePayloadFromTemplate = (templateDoc, slug) => {
492
587
  copy.content = Array.isArray(copy.content) ? hydrateSyncedBlocksFromSite(copy.content) : []
493
588
  copy.postContent = Array.isArray(copy.postContent) ? hydrateSyncedBlocksFromSite(copy.postContent) : []
494
589
  copy.blockIds = deriveBlockIds(copy)
590
+ if (!String(copy.structuredData || '').trim())
591
+ copy.structuredData = structuredData
495
592
  return { ...basePayload, ...copy }
496
593
  }
497
594
 
498
595
  const renameFolderOrPageAction = async () => {
596
+ if (isRenameDisabled(state.renameItem)) {
597
+ state.renameFolderOrPageDialog = false
598
+ state.renameItem = {}
599
+ return
600
+ }
601
+ if (isExternalLinkEntry(state.renameItem)) {
602
+ const nextName = state.renameItem.name?.trim() || ''
603
+ if (!nextName) {
604
+ state.renameFolderOrPageDialog = false
605
+ state.renameItem = {}
606
+ return
607
+ }
608
+ const menuName = state.renameItem.menuName
609
+ const index = state.renameItem.index
610
+ if (menuName && Number.isInteger(index) && Array.isArray(modelValue.value[menuName])) {
611
+ const target = modelValue.value[menuName][index]
612
+ if (target)
613
+ target.name = nextName
614
+ }
615
+ state.renameFolderOrPageDialog = false
616
+ state.renameItem = {}
617
+ return
618
+ }
499
619
  const newSlug = slugGenerator(state.renameItem.name, state.renameItem.previousName || '')
500
620
 
501
621
  if (state.renameItem.name === state.renameItem.previousName) {
@@ -582,7 +702,70 @@ const addPageAction = async () => {
582
702
 
583
703
  state.addPageDialog = false
584
704
  }
705
+
706
+ const addLinkShow = (menuName) => {
707
+ state.linkDialogMode = 'add'
708
+ state.linkTargetMenu = menuName
709
+ state.addLinkDialog = true
710
+ }
711
+
712
+ const editLinkShow = (menuName, index, entry) => {
713
+ state.linkDialogMode = 'edit'
714
+ state.linkTargetMenu = menuName
715
+ state.linkTargetIndex = index
716
+ state.linkName = entry?.name || ''
717
+ state.linkUrl = entry?.item?.url || ''
718
+ state.addLinkDialog = true
719
+ }
720
+
721
+ const linkDialogSchema = toTypedSchema(z.object({
722
+ name: z.string({
723
+ required_error: 'Name is required',
724
+ }).min(1, { message: 'Name is required' }),
725
+ url: z.string({
726
+ required_error: 'URL is required',
727
+ }).min(1, { message: 'URL is required' }),
728
+ }))
729
+
730
+ const submitLinkDialog = () => {
731
+ const name = state.linkName?.trim() || ''
732
+ const url = state.linkUrl?.trim() || ''
733
+ if (!name || !url)
734
+ return
735
+ const payload = { name, item: { type: 'external', url } }
736
+ if (!Array.isArray(modelValue.value[state.linkTargetMenu]))
737
+ modelValue.value[state.linkTargetMenu] = []
738
+ if (state.linkDialogMode === 'edit') {
739
+ const target = modelValue.value[state.linkTargetMenu]?.[state.linkTargetIndex]
740
+ if (target) {
741
+ target.name = name
742
+ target.item = { type: 'external', url }
743
+ }
744
+ else {
745
+ modelValue.value[state.linkTargetMenu].push(payload)
746
+ }
747
+ }
748
+ else {
749
+ modelValue.value[state.linkTargetMenu].push(payload)
750
+ }
751
+ state.addLinkDialog = false
752
+ }
585
753
  const deletePageAction = async () => {
754
+ if (isDeleteDisabled(state.deletePage)) {
755
+ state.deletePageDialog = false
756
+ state.deletePage = {}
757
+ return
758
+ }
759
+ if (isExternalLinkEntry(state.deletePage)) {
760
+ const menuName = state.deletePage.menuName
761
+ const index = state.deletePage.index
762
+ if (menuName && Number.isInteger(index) && Array.isArray(modelValue.value[menuName])) {
763
+ modelValue.value[menuName].splice(index, 1)
764
+ }
765
+ state.deletePageDialog = false
766
+ state.deletePage = {}
767
+ return
768
+ }
586
769
  if (state.deletePage.item === '') {
587
770
  // deleting a folder
588
771
  delete modelValue.value[state.deletePage.name]
@@ -662,6 +845,10 @@ const showPageSettings = (page) => {
662
845
  state.pageSettings = true
663
846
  }
664
847
 
848
+ const handlePageWorkingDoc = (doc) => {
849
+ ensurePostSeoDefaults(doc)
850
+ }
851
+
665
852
  const formErrors = (error) => {
666
853
  console.log('Form errors:', error)
667
854
  console.log(Object.values(error))
@@ -701,11 +888,11 @@ const theme = computed(() => {
701
888
 
702
889
  <template>
703
890
  <SidebarMenuItem v-for="({ menu, name: menuName }) in orderedMenus" :key="menuName">
704
- <SidebarMenuButton class="!px-0 hover:!bg-transparent">
891
+ <SidebarMenuButton class="group !px-0 hover:!bg-transparent">
705
892
  <FolderOpen
706
- class="mr-2"
893
+ class="mr-2 group-hover:text-black"
707
894
  />
708
- <span v-if="!props.isTemplateSite">{{ menuName === 'Site Root' ? 'Site Menu' : menuName }}</span>
895
+ <span v-if="!props.isTemplateSite" class="hover:text-black !text-black">{{ menuName === 'Site Root' ? 'Site Menu' : menuName }}</span>
709
896
  <SidebarGroupAction class="absolute right-2 top-0 hover:!bg-transparent">
710
897
  <DropdownMenu>
711
898
  <DropdownMenuTrigger as-child>
@@ -725,6 +912,10 @@ const theme = computed(() => {
725
912
  <FilePlus2 />
726
913
  <span>New Page</span>
727
914
  </DropdownMenuItem>
915
+ <DropdownMenuItem v-if="!props.isTemplateSite" @click="addLinkShow(menuName)">
916
+ <Link />
917
+ <span>New Link</span>
918
+ </DropdownMenuItem>
728
919
  <DropdownMenuItem v-if="!props.prevMenu && !props.isTemplateSite" @click="addPageShow(menuName, true)">
729
920
  <FolderPlus />
730
921
  <span>New Folder</span>
@@ -754,7 +945,7 @@ const theme = computed(() => {
754
945
  <template #item="{ element, index }">
755
946
  <div class="handle list-group-item">
756
947
  <edge-cms-menu
757
- v-if="typeof element.item === 'object'"
948
+ v-if="typeof element.item === 'object' && !isExternalLinkEntry(element)"
758
949
  v-model="modelValue[menuName][index].item"
759
950
  :prev-menu="menuName"
760
951
  :prev-model-value="modelValue"
@@ -764,13 +955,33 @@ const theme = computed(() => {
764
955
  :is-template-site="props.isTemplateSite"
765
956
  />
766
957
  <SidebarMenuSubItem v-else class="relative">
767
- <SidebarMenuSubButton :class="{ 'text-gray-400': element.item === '' }" as-child :is-active="element.item === props.page">
768
- <NuxtLink :disabled="element.item === ''" :class="{ '!text-red-500': element.name === 'Deleting...' }" class="text-xs" :to="`${pageRouteBase}/${element.item}`">
958
+ <SidebarMenuSubButton
959
+ :class="{ 'text-gray-400': element.item === '' }"
960
+ as-child
961
+ :is-active="!isExternalLinkEntry(element) && element.item === props.page"
962
+ >
963
+ <NuxtLink
964
+ v-if="!isExternalLinkEntry(element)"
965
+ :disabled="element.item === ''"
966
+ :class="{ '!text-red-500': element.name === 'Deleting...' }"
967
+ class="text-xs"
968
+ :to="`${pageRouteBase}/${element.item}`"
969
+ >
769
970
  <Loader2 v-if="element.item === '' || element.name === 'Deleting...'" :class="{ '!text-red-500': element.name === 'Deleting...' }" class="w-4 h-4 animate-spin" />
770
971
  <FileWarning v-else-if="isPublishedPageDiff(element.item) && !props.isTemplateSite" class="!text-yellow-600" />
771
972
  <FileCheck v-else class="text-xs !text-green-700 font-normal" />
772
973
  <span>{{ element.name }}</span>
773
974
  </NuxtLink>
975
+ <a
976
+ v-else
977
+ :href="element.item?.url || '#'"
978
+ class="text-xs inline-flex items-center gap-2"
979
+ :target="linkTarget(element.item?.url)"
980
+ :rel="linkRel(element.item?.url)"
981
+ >
982
+ <Link class="w-4 h-4 text-muted-foreground" />
983
+ <span>{{ element.name }}</span>
984
+ </a>
774
985
  </SidebarMenuSubButton>
775
986
  <div class="absolute right-0 -top-0.5">
776
987
  <DropdownMenu>
@@ -787,34 +998,51 @@ const theme = computed(() => {
787
998
  <File class="w-5 h-5" /> {{ ROOT_MENUS.includes(menuName) ? '' : menuName }}/{{ element.name }}
788
999
  </DropdownMenuLabel>
789
1000
  <DropdownMenuSeparator />
790
- <DropdownMenuItem :disabled="edgeGlobal.edgeState.cmsPageWithUnsavedChanges === element.item" @click="showPageSettings(element)">
791
- <FileCog />
792
- <div class="flex flex-col">
793
- <span>Settings</span>
794
- <span v-if="edgeGlobal.edgeState.cmsPageWithUnsavedChanges === element.item" class="text-xs text-red-500">(Unsaved Changes)</span>
795
- </div>
796
- </DropdownMenuItem>
797
- <DropdownMenuItem v-if="!props.isTemplateSite && isPublishedPageDiff(element.item)" @click="publishPage(element.item)">
798
- <FileUp />
799
- Publish
800
- </DropdownMenuItem>
801
- <DropdownMenuItem @click="renameFolderOrPageShow(element)">
802
- <FilePen />
803
- <span>Rename</span>
804
- </DropdownMenuItem>
805
- <DropdownMenuSeparator />
806
- <DropdownMenuItem v-if="!props.isTemplateSite && isPublishedPageDiff(element.item) && isPublished(element.item)" @click="discardPageChanges(element.item)">
807
- <FileX />
808
- Discard Changes
809
- </DropdownMenuItem>
810
- <DropdownMenuItem v-if="!props.isTemplateSite && isPublished(element.item)" @click="unPublishPage(element.item)">
811
- <FileDown />
812
- Unpublish
813
- </DropdownMenuItem>
814
- <DropdownMenuItem class="text-destructive" @click="deletePageShow(element)">
815
- <FileMinus2 />
816
- <span>Delete</span>
817
- </DropdownMenuItem>
1001
+ <template v-if="!isExternalLinkEntry(element)">
1002
+ <DropdownMenuItem :disabled="edgeGlobal.edgeState.cmsPageWithUnsavedChanges === element.item" @click="showPageSettings(element)">
1003
+ <FileCog />
1004
+ <div class="flex flex-col">
1005
+ <span>Settings</span>
1006
+ <span v-if="edgeGlobal.edgeState.cmsPageWithUnsavedChanges === element.item" class="text-xs text-red-500">(Unsaved Changes)</span>
1007
+ </div>
1008
+ </DropdownMenuItem>
1009
+ <DropdownMenuItem v-if="!props.isTemplateSite && isPublishedPageDiff(element.item)" @click="publishPage(element.item)">
1010
+ <FileUp />
1011
+ Publish
1012
+ </DropdownMenuItem>
1013
+ <DropdownMenuItem :disabled="isRenameDisabled(element)" @click="renameFolderOrPageShow({ ...element, menuName, index })">
1014
+ <FilePen />
1015
+ <span>Rename</span>
1016
+ </DropdownMenuItem>
1017
+ <DropdownMenuSeparator />
1018
+ <DropdownMenuItem v-if="!props.isTemplateSite && isPublishedPageDiff(element.item) && isPublished(element.item)" @click="discardPageChanges(element.item)">
1019
+ <FileX />
1020
+ Discard Changes
1021
+ </DropdownMenuItem>
1022
+ <DropdownMenuItem v-if="!props.isTemplateSite && isPublished(element.item)" @click="unPublishPage(element.item)">
1023
+ <FileDown />
1024
+ Unpublish
1025
+ </DropdownMenuItem>
1026
+ <DropdownMenuItem class="text-destructive" :disabled="isDeleteDisabled(element)" @click="deletePageShow({ ...element, menuName, index })">
1027
+ <FileMinus2 />
1028
+ <span>Delete</span>
1029
+ </DropdownMenuItem>
1030
+ </template>
1031
+ <template v-else>
1032
+ <DropdownMenuItem @click="editLinkShow(menuName, index, element)">
1033
+ <Link />
1034
+ <span>Edit Link</span>
1035
+ </DropdownMenuItem>
1036
+ <DropdownMenuItem @click="renameFolderOrPageShow({ ...element, menuName, index })">
1037
+ <FilePen />
1038
+ <span>Rename</span>
1039
+ </DropdownMenuItem>
1040
+ <DropdownMenuSeparator />
1041
+ <DropdownMenuItem class="text-destructive" @click="deletePageShow({ ...element, menuName, index })">
1042
+ <FileMinus2 />
1043
+ <span>Delete</span>
1044
+ </DropdownMenuItem>
1045
+ </template>
818
1046
  </DropdownMenuContent>
819
1047
  </DropdownMenu>
820
1048
  </div>
@@ -831,6 +1059,7 @@ const theme = computed(() => {
831
1059
  <DialogHeader>
832
1060
  <DialogTitle class="text-left">
833
1061
  <span v-if="state.deletePage.item === ''">Delete Folder "{{ state.deletePage.name }}"</span>
1062
+ <span v-else-if="isExternalLinkEntry(state.deletePage)">Delete Link "{{ state.deletePage.name }}"</span>
834
1063
  <span v-else>Delete Page "{{ state.deletePage.name }}"</span>
835
1064
  </DialogTitle>
836
1065
  <DialogDescription />
@@ -847,7 +1076,9 @@ const theme = computed(() => {
847
1076
  <edge-shad-button
848
1077
  variant="destructive" class="text-white w-full" @click="deletePageAction()"
849
1078
  >
850
- Delete Page
1079
+ <span v-if="state.deletePage.item === ''">Delete Folder</span>
1080
+ <span v-else-if="isExternalLinkEntry(state.deletePage)">Delete Link</span>
1081
+ <span v-else>Delete Page</span>
851
1082
  </edge-shad-button>
852
1083
  </DialogFooter>
853
1084
  </DialogContent>
@@ -962,6 +1193,32 @@ const theme = computed(() => {
962
1193
  </edge-shad-form>
963
1194
  </DialogContent>
964
1195
  </edge-shad-dialog>
1196
+ <edge-shad-dialog v-model="state.addLinkDialog">
1197
+ <DialogContent class="pt-10">
1198
+ <edge-shad-form :schema="linkDialogSchema" @submit="submitLinkDialog">
1199
+ <DialogHeader>
1200
+ <DialogTitle class="text-left">
1201
+ <span v-if="state.linkDialogMode === 'edit'">Edit Link</span>
1202
+ <span v-else>Add link to "{{ state.linkTargetMenu }}"</span>
1203
+ </DialogTitle>
1204
+ <DialogDescription />
1205
+ </DialogHeader>
1206
+ <div class="space-y-4">
1207
+ <edge-shad-input v-model="state.linkName" name="name" label="Label" placeholder="Link label" />
1208
+ <edge-shad-input v-model="state.linkUrl" name="url" label="URL" placeholder="https://example.com or tel:123-456-7890" />
1209
+ </div>
1210
+ <DialogFooter class="pt-2 flex justify-between">
1211
+ <edge-shad-button type="button" variant="destructive" @click="state.addLinkDialog = false">
1212
+ Cancel
1213
+ </edge-shad-button>
1214
+ <edge-shad-button type="submit" class="text-white bg-slate-800 hover:bg-slate-400 w-full">
1215
+ <span v-if="state.linkDialogMode === 'edit'">Update Link</span>
1216
+ <span v-else>Add Link</span>
1217
+ </edge-shad-button>
1218
+ </DialogFooter>
1219
+ </edge-shad-form>
1220
+ </DialogContent>
1221
+ </edge-shad-dialog>
965
1222
  <edge-shad-dialog
966
1223
  v-model="state.renameFolderOrPageDialog"
967
1224
  >
@@ -975,6 +1232,12 @@ const theme = computed(() => {
975
1232
  <DialogDescription />
976
1233
  </DialogHeader>
977
1234
  <edge-shad-input v-model="state.renameItem.name" name="name" placeholder="New Name" />
1235
+ <p
1236
+ v-if="state.renameItem.item !== ''"
1237
+ class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs font-medium text-red-700"
1238
+ >
1239
+ Renaming a page changes its URL. If search engines already indexed the old URL, rankings may drop temporarily.
1240
+ </p>
978
1241
  <DialogFooter class="pt-2 flex justify-between">
979
1242
  <edge-shad-button variant="destructive" @click="state.renameFolderOrPageDialog = false">
980
1243
  Cancel
@@ -1003,6 +1266,7 @@ const theme = computed(() => {
1003
1266
  :save-function-override="onSubmit"
1004
1267
  card-content-class="px-0"
1005
1268
  @error="formErrors"
1269
+ @working-doc="handlePageWorkingDoc"
1006
1270
  >
1007
1271
  <template #main="slotProps">
1008
1272
  <div class="p-6 space-y-4 h-[calc(100vh-142px)] overflow-y-auto">
@@ -1024,7 +1288,7 @@ const theme = computed(() => {
1024
1288
  :allow-additions="true"
1025
1289
  />
1026
1290
  <edge-shad-select-tags
1027
- v-if="props.themeOptions.length"
1291
+ v-if="props.isTemplateSite && props.themeOptions.length"
1028
1292
  :model-value="Array.isArray(slotProps.workingDoc.allowedThemes) ? slotProps.workingDoc.allowedThemes : []"
1029
1293
  name="allowedThemes"
1030
1294
  label="Allowed Themes"
@@ -1042,24 +1306,83 @@ const theme = computed(() => {
1042
1306
  <CardDescription>Meta tags for the page.</CardDescription>
1043
1307
  </CardHeader>
1044
1308
  <CardContent class="pt-0">
1045
- <edge-shad-input
1046
- v-model="slotProps.workingDoc.metaTitle"
1047
- label="Meta Title"
1048
- name="metaTitle"
1049
- />
1050
- <edge-shad-textarea
1051
- v-model="slotProps.workingDoc.metaDescription"
1052
- label="Meta Description"
1053
- name="metaDescription"
1054
- />
1055
- <edge-cms-code-editor
1056
- v-model="slotProps.workingDoc.structuredData"
1057
- title="Structured Data (JSON-LD)"
1058
- language="json"
1059
- name="structuredData"
1060
- height="300px"
1061
- class="mb-4 w-full"
1062
- />
1309
+ <Tabs class="w-full" default-value="list">
1310
+ <TabsList v-if="slotProps.workingDoc?.post" class="w-full grid grid-cols-2 gap-1 rounded-lg border border-border/60 bg-muted/30 p-1">
1311
+ <TabsTrigger
1312
+ value="list"
1313
+ class="text-xs font-semibold uppercase tracking-wide transition-all data-[state=active]:bg-slate-900 data-[state=active]:text-white data-[state=active]:shadow-sm"
1314
+ >
1315
+ Index Page
1316
+ </TabsTrigger>
1317
+ <TabsTrigger
1318
+ value="post"
1319
+ class="text-xs font-semibold uppercase tracking-wide transition-all data-[state=active]:bg-slate-900 data-[state=active]:text-white data-[state=active]:shadow-sm"
1320
+ >
1321
+ Detail Page
1322
+ </TabsTrigger>
1323
+ </TabsList>
1324
+ <TabsContent value="list" class="mt-4 space-y-4">
1325
+ <edge-shad-input
1326
+ v-model="slotProps.workingDoc.metaTitle"
1327
+ label="Meta Title"
1328
+ name="metaTitle"
1329
+ />
1330
+ <edge-shad-textarea
1331
+ v-model="slotProps.workingDoc.metaDescription"
1332
+ label="Meta Description"
1333
+ name="metaDescription"
1334
+ />
1335
+ <div class="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
1336
+ CMS tokens in double curly braces are replaced on the front end.
1337
+ Example: <span v-pre class="font-semibold text-foreground">"{{cms-site}}"</span> for the site URL,
1338
+ <span v-pre class="font-semibold text-foreground">"{{cms-url}}"</span> for the page URL, and
1339
+ <span v-pre class="font-semibold text-foreground">"{{cms-logo}}"</span> for the logo URL. Keep the tokens intact.
1340
+ </div>
1341
+ <edge-cms-code-editor
1342
+ v-model="slotProps.workingDoc.structuredData"
1343
+ title="Structured Data (JSON-LD)"
1344
+ language="json"
1345
+ name="structuredData"
1346
+ validate-json
1347
+ height="300px"
1348
+ class="mb-4 w-full"
1349
+ />
1350
+ </TabsContent>
1351
+ <TabsContent value="post" class="mt-4 space-y-4">
1352
+ <div class="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
1353
+ You can use template keys in double curly braces to pull data from the detail record.
1354
+ Example: <span v-pre class="font-semibold text-foreground">"{{name}}"</span> will be replaced with the record’s name.
1355
+ Dot notation is supported for nested objects, e.g. <span v-pre class="font-semibold text-foreground">"{{data.name}}"</span>.
1356
+ These keys work in the Title, Description, and Structured Data fields.
1357
+ </div>
1358
+ <edge-shad-input
1359
+ v-model="slotProps.workingDoc.postMetaTitle"
1360
+ label="Meta Title"
1361
+ name="postMetaTitle"
1362
+ />
1363
+ <edge-shad-textarea
1364
+ v-model="slotProps.workingDoc.postMetaDescription"
1365
+ label="Meta Description"
1366
+ name="postMetaDescription"
1367
+ />
1368
+
1369
+ <div class="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
1370
+ CMS tokens in double curly braces are replaced on the front end.
1371
+ Example: <span v-pre class="font-semibold text-foreground">"{{cms-site}}"</span> for the site URL,
1372
+ <span v-pre class="font-semibold text-foreground">"{{cms-url}}"</span> for the page URL, and
1373
+ <span v-pre class="font-semibold text-foreground">"{{cms-logo}}"</span> for the logo URL. Keep the tokens intact.
1374
+ </div>
1375
+ <edge-cms-code-editor
1376
+ v-model="slotProps.workingDoc.postStructuredData"
1377
+ title="Structured Data (JSON-LD)"
1378
+ language="json"
1379
+ name="postStructuredData"
1380
+ validate-json
1381
+ height="300px"
1382
+ class="mb-4 w-full"
1383
+ />
1384
+ </TabsContent>
1385
+ </Tabs>
1063
1386
  </CardContent>
1064
1387
  </Card>
1065
1388
  </div>
@@ -1067,7 +1390,7 @@ const theme = computed(() => {
1067
1390
  <edge-shad-button variant="destructive" class="text-white" @click="state.pageSettings = false">
1068
1391
  Cancel
1069
1392
  </edge-shad-button>
1070
- <edge-shad-button :disabled="slotProps.submitting" type="submit" class=" bg-slate-800 hover:bg-slate-400 w-full">
1393
+ <edge-shad-button :disabled="slotProps.submitting || hasStructuredDataErrors(slotProps.workingDoc)" type="submit" class=" bg-slate-800 hover:bg-slate-400 w-full">
1071
1394
  <Loader2 v-if="slotProps.submitting" class=" h-4 w-4 animate-spin" />
1072
1395
  Update
1073
1396
  </edge-shad-button>