@edgedev/create-edge-app 1.2.29 → 1.2.31

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.
package/.env CHANGED
@@ -11,4 +11,4 @@ VITE_FIREBASE_EMULATOR_FIRESTORE=
11
11
  VITE_FIREBASE_EMULATOR_FUNCTIONS=
12
12
  VITE_FIREBASE_EMULATOR_STORAGE=
13
13
  REGISTRATION_CODE=organization-registration-template
14
- DEVELOPMENT_MODE=false
14
+ DEVELOPMENT_MODE=false
package/.env.dev CHANGED
@@ -11,4 +11,4 @@ VITE_FIREBASE_EMULATOR_FIRESTORE=8080
11
11
  VITE_FIREBASE_EMULATOR_FUNCTIONS=5001
12
12
  VITE_FIREBASE_EMULATOR_STORAGE=9199
13
13
  REGISTRATION_CODE=organization-registration-template
14
- DEVELOPMENT_MODE=true
14
+ DEVELOPMENT_MODE=true
@@ -7,15 +7,7 @@ const state = reactive({
7
7
  themesFilter: [],
8
8
  })
9
9
 
10
- const previewState = reactive({
11
- loaded: [],
12
- })
13
-
14
- definePageMeta({
15
- middleware: 'auth',
16
- })
17
-
18
- const rawInitBlockFiles = import.meta.glob('../../../../edge/components/cms/init_blocks/*.html', {
10
+ const rawInitBlockFiles = import.meta.glob('./init_blocks/*.html', {
19
11
  as: 'raw',
20
12
  eager: true,
21
13
  })
@@ -74,14 +66,6 @@ const seedInitialBlocks = async () => {
74
66
  return created
75
67
  }
76
68
 
77
- onBeforeMount(async () => {
78
- restoreFilters()
79
- if (!edgeFirebase.data?.[`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`]) {
80
- await edgeFirebase.startSnapshot(`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`)
81
- }
82
- state.mounted = true
83
- })
84
-
85
69
  const getThemeFromId = (themeId) => {
86
70
  const theme = edgeFirebase.data[`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`]?.[themeId]
87
71
  console.log('getThemeFromId', themeId, theme.name)
@@ -93,13 +77,6 @@ const loadingRender = (content) => {
93
77
  return safeContent.replaceAll('{{loading}}', '').replaceAll('{{loaded}}', 'hidden')
94
78
  }
95
79
 
96
- const markPreviewLoaded = (isLoading, id) => {
97
- if (!isLoading && !previewState.loaded.includes(id))
98
- previewState.loaded.push(id)
99
- }
100
-
101
- const hasPreviewLoaded = id => previewState.loaded.includes(id)
102
-
103
80
  const FILTER_STORAGE_KEY = 'edge.blocks.filters'
104
81
 
105
82
  const restoreFilters = () => {
@@ -136,6 +113,14 @@ watch(
136
113
  { deep: true },
137
114
  )
138
115
 
116
+ onBeforeMount(async () => {
117
+ restoreFilters()
118
+ if (!edgeFirebase.data?.[`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`]) {
119
+ await edgeFirebase.startSnapshot(`organizations/${edgeGlobal.edgeState.currentOrganization}/themes`)
120
+ }
121
+ state.mounted = true
122
+ })
123
+
139
124
  const tagOptions = computed(() => {
140
125
  const tagsSet = new Set()
141
126
  const blocks = edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/blocks`] || {}
@@ -103,6 +103,7 @@ const schemas = {
103
103
  message: 'At least one domain is required',
104
104
  path: ['domains', 0],
105
105
  }),
106
+ forwardApex: z.boolean().optional(),
106
107
  contactEmail: z.string().optional(),
107
108
  contactPhone: z.string().optional(),
108
109
  theme: z.string({
@@ -783,6 +784,7 @@ const isSiteDiff = computed(() => {
783
784
  brandLogoLight: publishedSite.brandLogoLight,
784
785
  favicon: publishedSite.favicon,
785
786
  menuPosition: publishedSite.menuPosition,
787
+ forwardApex: publishedSite.forwardApex,
786
788
  contactEmail: publishedSite.contactEmail,
787
789
  contactPhone: publishedSite.contactPhone,
788
790
  metaTitle: publishedSite.metaTitle,
@@ -810,6 +812,7 @@ const isSiteDiff = computed(() => {
810
812
  brandLogoLight: siteData.value.brandLogoLight,
811
813
  favicon: siteData.value.favicon,
812
814
  menuPosition: siteData.value.menuPosition,
815
+ forwardApex: siteData.value.forwardApex,
813
816
  contactEmail: siteData.value.contactEmail,
814
817
  contactPhone: siteData.value.contactPhone,
815
818
  metaTitle: siteData.value.metaTitle,
@@ -851,6 +854,7 @@ const discardSiteSettings = async () => {
851
854
  brandLogoLight: publishedSite.brandLogoLight || '',
852
855
  favicon: publishedSite.favicon || '',
853
856
  menuPosition: publishedSite.menuPosition || '',
857
+ forwardApex: publishedSite.forwardApex === false ? false : true,
854
858
  contactEmail: publishedSite.contactEmail || '',
855
859
  contactPhone: publishedSite.contactPhone || '',
856
860
  metaTitle: publishedSite.metaTitle || '',
@@ -1699,6 +1703,7 @@ const pageSettingsUpdated = async (pageData) => {
1699
1703
  :enable-media-picker="true"
1700
1704
  :site-id="props.site"
1701
1705
  :domain-error="domainError"
1706
+ :settings-open="state.siteSettings"
1702
1707
  />
1703
1708
  </div>
1704
1709
  <SheetFooter class="pt-2 flex justify-between">
@@ -42,6 +42,10 @@ const props = defineProps({
42
42
  type: String,
43
43
  default: '',
44
44
  },
45
+ settingsOpen: {
46
+ type: Boolean,
47
+ default: true,
48
+ },
45
49
  })
46
50
 
47
51
  const edgeFirebase = inject('edgeFirebase')
@@ -101,41 +105,266 @@ const menuPositionOptions = [
101
105
 
102
106
  const domainError = computed(() => String(props.domainError || '').trim())
103
107
  const serverPagesProject = ref('')
108
+ const domainRegistry = ref({})
109
+ const loadingDomainRegistry = ref(false)
110
+ const hasLoadedDomainRegistry = ref(false)
104
111
  const pagesProject = computed(() => String(serverPagesProject.value || '').trim())
105
112
  const pagesDomain = computed(() => (pagesProject.value ? `${pagesProject.value}.pages.dev` : '(CLOUDFLARE_PAGES_PROJECT).pages.dev'))
113
+ const forwardApexEnabled = computed({
114
+ get: () => props.settings?.forwardApex !== false,
115
+ set: (value) => {
116
+ props.settings.forwardApex = !!value
117
+ },
118
+ })
119
+
120
+ const normalizeDomain = (value) => {
121
+ if (!value)
122
+ return ''
123
+ let normalized = String(value).trim().toLowerCase()
124
+ if (!normalized)
125
+ return ''
126
+ if (normalized.includes('://')) {
127
+ try {
128
+ normalized = new URL(normalized).host
129
+ }
130
+ catch {
131
+ normalized = normalized.split('://').pop() || normalized
132
+ }
133
+ }
134
+ normalized = normalized.split('/')[0] || ''
135
+ if (normalized.includes(':') && !normalized.startsWith('[')) {
136
+ normalized = normalized.split(':')[0] || ''
137
+ }
138
+ return normalized.replace(/\.+$/g, '')
139
+ }
140
+
141
+ const isIpv4Address = (value) => {
142
+ const parts = String(value || '').split('.')
143
+ if (parts.length !== 4)
144
+ return false
145
+ return parts.every((part) => {
146
+ if (!/^\d{1,3}$/.test(part))
147
+ return false
148
+ const num = Number(part)
149
+ return num >= 0 && num <= 255
150
+ })
151
+ }
152
+
153
+ const isIpv6Address = (value) => {
154
+ const normalized = String(value || '').toLowerCase()
155
+ if (!normalized.includes(':'))
156
+ return false
157
+ return /^[0-9a-f:]+$/.test(normalized)
158
+ }
159
+
160
+ const isIpAddress = value => isIpv4Address(value) || isIpv6Address(value)
161
+
162
+ const shouldDisplayDomainDnsRecords = (domain) => {
163
+ if (!domain)
164
+ return false
165
+ if (domain.includes('localhost'))
166
+ return false
167
+ if (isIpAddress(domain))
168
+ return false
169
+ if (domain.endsWith('.dev'))
170
+ return false
171
+ return true
172
+ }
173
+
174
+ const getCloudflareApexDomain = (domain) => {
175
+ if (!domain)
176
+ return ''
177
+ if (domain.startsWith('www.'))
178
+ return domain.slice(4)
179
+ return domain
180
+ }
181
+
182
+ const getCloudflarePagesDomain = (domain) => {
183
+ if (!domain)
184
+ return ''
185
+ if (domain.startsWith('www.'))
186
+ return domain
187
+ return `www.${domain}`
188
+ }
189
+
190
+ const normalizedDomains = computed(() => {
191
+ const values = Array.isArray(props.settings?.domains) ? props.settings.domains : []
192
+ return Array.from(new Set(values.map(normalizeDomain).filter(Boolean)))
193
+ })
194
+
195
+ const dnsEligibleDomains = computed(() => normalizedDomains.value.filter(shouldDisplayDomainDnsRecords))
196
+
197
+ const organizationId = computed(() => String(edgeGlobal?.edgeState?.currentOrganization || '').trim())
198
+ const shouldShowDomainRegistryLoading = computed(() => {
199
+ if (!normalizedDomains.value.length)
200
+ return false
201
+ return loadingDomainRegistry.value || !hasLoadedDomainRegistry.value
202
+ })
203
+ const DOMAIN_REGISTRY_POLL_MS = 2500
204
+ let domainRegistryPollTimer = null
205
+
206
+ const buildFallbackDomainEntry = (domain) => {
207
+ const apexDomain = getCloudflareApexDomain(domain)
208
+ const wwwDomain = getCloudflarePagesDomain(apexDomain)
209
+ const target = pagesDomain.value
210
+ const hasTarget = !!String(target || '').trim()
211
+ return {
212
+ domain,
213
+ apexDomain,
214
+ wwwDomain,
215
+ apexAttempted: false,
216
+ apexAdded: false,
217
+ apexError: '',
218
+ dnsGuidance: 'Add the www CNAME record. Apex is unavailable; forward apex to www.',
219
+ dnsRecords: {
220
+ target,
221
+ www: {
222
+ type: 'CNAME',
223
+ name: 'www',
224
+ host: wwwDomain,
225
+ value: target,
226
+ enabled: hasTarget,
227
+ },
228
+ apex: {
229
+ type: 'CNAME',
230
+ name: '@',
231
+ host: apexDomain,
232
+ value: target,
233
+ enabled: false,
234
+ },
235
+ },
236
+ }
237
+ }
238
+
239
+ const domainDnsEntries = computed(() => {
240
+ return dnsEligibleDomains.value.map((domain) => {
241
+ const fallback = buildFallbackDomainEntry(domain)
242
+ const value = domainRegistry.value?.[domain] || {}
243
+ const dnsRecords = {
244
+ ...fallback.dnsRecords,
245
+ ...(value.dnsRecords || {}),
246
+ www: {
247
+ ...fallback.dnsRecords.www,
248
+ ...(value?.dnsRecords?.www || {}),
249
+ },
250
+ apex: {
251
+ ...fallback.dnsRecords.apex,
252
+ ...(value?.dnsRecords?.apex || {}),
253
+ },
254
+ }
255
+ const apexAdded = value?.apexAdded === true
256
+ return {
257
+ ...fallback,
258
+ ...value,
259
+ dnsRecords,
260
+ apexAdded,
261
+ apexAttempted: value?.apexAttempted === true,
262
+ apexError: String(value?.apexError || '').trim(),
263
+ dnsGuidance: String(value?.dnsGuidance || '').trim()
264
+ || (apexAdded ? '' : fallback.dnsGuidance),
265
+ }
266
+ })
267
+ })
106
268
 
107
- onMounted(async () => {
269
+ const fetchDomainRegistry = async (options = {}) => {
270
+ const { background = false } = options
108
271
  if (!edgeFirebase?.runFunction)
109
272
  return
273
+ const domains = normalizedDomains.value
274
+ if (!background)
275
+ loadingDomainRegistry.value = true
110
276
  try {
111
- const response = await edgeFirebase.runFunction('cms-getCloudflarePagesProject', {})
277
+ const response = await edgeFirebase.runFunction('cms-getCloudflarePagesProject', {
278
+ orgId: organizationId.value,
279
+ siteId: props.siteId || '',
280
+ domains,
281
+ })
112
282
  serverPagesProject.value = String(response?.data?.project || '').trim()
283
+ const nextRegistry = response?.data?.domainRegistry
284
+ if (domains.length && nextRegistry && typeof nextRegistry === 'object')
285
+ domainRegistry.value = nextRegistry
286
+ else
287
+ domainRegistry.value = {}
288
+ hasLoadedDomainRegistry.value = true
113
289
  }
114
290
  catch {
115
291
  serverPagesProject.value = ''
292
+ domainRegistry.value = {}
293
+ hasLoadedDomainRegistry.value = true
116
294
  }
295
+ finally {
296
+ if (!background)
297
+ loadingDomainRegistry.value = false
298
+ }
299
+ }
300
+
301
+ const stopDomainRegistryPolling = () => {
302
+ if (domainRegistryPollTimer) {
303
+ clearInterval(domainRegistryPollTimer)
304
+ domainRegistryPollTimer = null
305
+ }
306
+ }
307
+
308
+ const startDomainRegistryPolling = () => {
309
+ stopDomainRegistryPolling()
310
+ if (!props.settingsOpen)
311
+ return
312
+ if (!normalizedDomains.value.length)
313
+ return
314
+ domainRegistryPollTimer = setInterval(() => {
315
+ fetchDomainRegistry({ background: true }).catch(() => {})
316
+ }, DOMAIN_REGISTRY_POLL_MS)
317
+ }
318
+
319
+ watch(() => `${props.siteId}:${organizationId.value}:${normalizedDomains.value.join('|')}`, async () => {
320
+ hasLoadedDomainRegistry.value = false
321
+ await fetchDomainRegistry()
322
+ startDomainRegistryPolling()
323
+ })
324
+
325
+ watch(() => props.settingsOpen, async (open) => {
326
+ if (open) {
327
+ hasLoadedDomainRegistry.value = false
328
+ await fetchDomainRegistry()
329
+ startDomainRegistryPolling()
330
+ }
331
+ else {
332
+ stopDomainRegistryPolling()
333
+ }
334
+ }, { immediate: true })
335
+
336
+ onBeforeUnmount(() => {
337
+ stopDomainRegistryPolling()
117
338
  })
339
+
340
+ watch(() => props.settings?.forwardApex, (value) => {
341
+ if (value === undefined)
342
+ props.settings.forwardApex = true
343
+ }, { immediate: true })
118
344
  </script>
119
345
 
120
346
  <template>
121
347
  <Tabs class="w-full" default-value="general">
122
- <TabsList class="w-full gap-2 bg-muted/40 p-1 rounded-lg">
123
- <TabsTrigger value="general" class="text-sm uppercase font-medium">
348
+ <TabsList class="w-full mt-3 bg-secondary rounded-sm grid grid-cols-2 md:grid-cols-4 xl:grid-cols-7 gap-1">
349
+ <TabsTrigger value="general" class="w-full text-black data-[state=active]:bg-black data-[state=active]:text-white">
124
350
  General
125
351
  </TabsTrigger>
126
- <TabsTrigger value="appearance" class="text-sm uppercase font-medium">
352
+ <TabsTrigger value="domains" class="w-full text-black data-[state=active]:bg-black data-[state=active]:text-white">
353
+ Domains
354
+ </TabsTrigger>
355
+ <TabsTrigger value="appearance" class="w-full text-black data-[state=active]:bg-black data-[state=active]:text-white">
127
356
  Appearance
128
357
  </TabsTrigger>
129
- <TabsTrigger value="branding" class="text-sm uppercase font-medium">
358
+ <TabsTrigger value="branding" class="w-full text-black data-[state=active]:bg-black data-[state=active]:text-white">
130
359
  Branding
131
360
  </TabsTrigger>
132
- <TabsTrigger value="seo" class="text-sm uppercase font-medium">
361
+ <TabsTrigger value="seo" class="w-full text-black data-[state=active]:bg-black data-[state=active]:text-white">
133
362
  SEO
134
363
  </TabsTrigger>
135
- <TabsTrigger value="tracking" class="text-sm uppercase font-medium">
364
+ <TabsTrigger value="tracking" class="w-full text-black data-[state=active]:bg-black data-[state=active]:text-white">
136
365
  Tracking Pixels
137
366
  </TabsTrigger>
138
- <TabsTrigger value="social" class="text-sm uppercase font-medium">
367
+ <TabsTrigger value="social" class="w-full text-black data-[state=active]:bg-black data-[state=active]:text-white">
139
368
  Social Media
140
369
  </TabsTrigger>
141
370
  </TabsList>
@@ -147,46 +376,6 @@ onMounted(async () => {
147
376
  placeholder="Enter name"
148
377
  class="w-full"
149
378
  />
150
- <edge-shad-tags
151
- v-model="props.settings.domains"
152
- name="domains"
153
- label="Domains"
154
- placeholder="Add or remove domains"
155
- class="w-full"
156
- />
157
- <Alert v-if="domainError" variant="destructive">
158
- <CircleAlert class="h-4 w-4" />
159
- <AlertTitle>Domain error</AlertTitle>
160
- <AlertDescription class="text-sm">
161
- {{ domainError }}
162
- </AlertDescription>
163
- </Alert>
164
- <div class="rounded-lg border border-border/60 bg-muted/40 p-4 space-y-3">
165
- <div class="flex items-center justify-between">
166
- <div class="text-sm font-semibold text-foreground">
167
- Domain DNS records
168
- </div>
169
- <div class="text-xs text-muted-foreground">
170
- Target: <span class="font-mono">{{ pagesDomain }}</span>
171
- </div>
172
- </div>
173
- <p class="text-sm text-muted-foreground">
174
- Add these records at your DNS provider.
175
- </p>
176
- <div class="space-y-2 text-sm">
177
- <div class="grid grid-cols-[70px_1fr] gap-3">
178
- <div class="text-muted-foreground">CNAME</div>
179
- <div class="font-mono">www → {{ pagesDomain }}</div>
180
- </div>
181
- <div class="grid grid-cols-[70px_1fr] gap-3">
182
- <div class="text-muted-foreground">CNAME</div>
183
- <div class="font-mono">@ → {{ pagesDomain }}</div>
184
- </div>
185
- </div>
186
- <p class="text-xs text-muted-foreground">
187
- Then forward the root/apex (TLD) domain to <span class="font-mono">www</span> with a 301 redirect.
188
- </p>
189
- </div>
190
379
  <edge-shad-input
191
380
  v-model="props.settings.contactEmail"
192
381
  name="contactEmail"
@@ -219,6 +408,99 @@ onMounted(async () => {
219
408
  No organization users available for this site.
220
409
  </p>
221
410
  </TabsContent>
411
+ <TabsContent value="domains" class="pt-4 space-y-4">
412
+ <edge-shad-tags
413
+ v-model="props.settings.domains"
414
+ name="domains"
415
+ label="Domains"
416
+ placeholder="Add or remove domains"
417
+ class="w-full"
418
+ />
419
+ <div class="rounded-lg border border-border/60 bg-muted/40 p-4 space-y-3">
420
+ <edge-shad-switch
421
+ v-model="forwardApexEnabled"
422
+ name="forwardApex"
423
+ label="Forward Apex (non-www) domains to www"
424
+ class="w-full"
425
+ >
426
+ Use a single canonical host (www) to avoid duplicate URLs and consolidate SEO signals.
427
+ </edge-shad-switch>
428
+ <p class="text-xs text-amber-700 dark:text-amber-300">
429
+ Warning: if your DNS provider already redirects www to non-www, this can create a redirect loop.
430
+ </p>
431
+ </div>
432
+ <Alert v-if="domainError" variant="destructive">
433
+ <CircleAlert class="h-4 w-4" />
434
+ <AlertTitle>Domain error</AlertTitle>
435
+ <AlertDescription class="text-sm">
436
+ {{ domainError }}
437
+ </AlertDescription>
438
+ </Alert>
439
+ <div class="rounded-lg border border-border/60 bg-muted/40 p-4 space-y-3">
440
+ <div class="flex items-center justify-between">
441
+ <div class="text-sm font-semibold text-foreground">
442
+ Domain DNS records
443
+ </div>
444
+ <div class="text-xs text-muted-foreground">
445
+ Target: <span class="font-mono">{{ pagesDomain }}</span>
446
+ </div>
447
+ </div>
448
+ <p class="text-sm text-muted-foreground">
449
+ Records are listed for each domain.
450
+ </p>
451
+ <p v-if="shouldShowDomainRegistryLoading" class="text-xs text-muted-foreground">
452
+ Waiting for latest domain sync results...
453
+ </p>
454
+ <div v-if="!domainDnsEntries.length" class="text-sm text-muted-foreground italic">
455
+ Add at least one valid domain to see DNS records.
456
+ </div>
457
+ <div
458
+ v-for="entry in domainDnsEntries"
459
+ :key="entry.domain"
460
+ class="rounded-md border border-border/50 bg-background/70 p-3 space-y-3"
461
+ >
462
+ <div class="flex items-center justify-between gap-3">
463
+ <div class="text-sm font-semibold text-foreground font-mono">
464
+ {{ entry.domain }}
465
+ </div>
466
+ <div class="text-xs">
467
+ <span
468
+ v-if="entry.apexAdded"
469
+ class="rounded bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 px-2 py-1"
470
+ >
471
+ Apex added
472
+ </span>
473
+ <span
474
+ v-else
475
+ class="rounded bg-amber-500/15 text-amber-700 dark:text-amber-300 px-2 py-1"
476
+ >
477
+ Apex not added
478
+ </span>
479
+ </div>
480
+ </div>
481
+ <div class="space-y-2 text-sm">
482
+ <div class="grid grid-cols-[70px_1fr] gap-3">
483
+ <div class="text-muted-foreground">CNAME</div>
484
+ <div class="font-mono">
485
+ {{ entry?.dnsRecords?.www?.name || 'www' }} → {{ entry?.dnsRecords?.www?.value || pagesDomain }}
486
+ </div>
487
+ </div>
488
+ <div class="grid grid-cols-[70px_1fr] gap-3">
489
+ <div class="text-muted-foreground">CNAME</div>
490
+ <div class="font-mono">
491
+ {{ entry?.dnsRecords?.apex?.name || '@' }} → {{ entry?.dnsRecords?.apex?.value || pagesDomain }}
492
+ </div>
493
+ </div>
494
+ </div>
495
+ <p v-if="entry.apexError" class="text-xs text-amber-700 dark:text-amber-300">
496
+ {{ entry.apexError }}
497
+ </p>
498
+ <p v-if="!entry.apexAdded && entry.dnsGuidance" class="text-xs text-muted-foreground">
499
+ {{ entry.dnsGuidance }}
500
+ </p>
501
+ </div>
502
+ </div>
503
+ </TabsContent>
222
504
  <TabsContent value="appearance" class="pt-4 space-y-4">
223
505
  <edge-shad-select-tags
224
506
  v-if="props.showThemeFields && props.isAdmin"
@@ -0,0 +1,110 @@
1
+ <script setup>
2
+ import { Loader2 } from 'lucide-vue-next'
3
+
4
+ const edgeFirebase = inject('edgeFirebase')
5
+ const isAiBusy = status => status === 'queued' || status === 'running'
6
+
7
+ const state = reactive({
8
+ filter: '',
9
+ })
10
+
11
+ const isAdmin = computed(() => {
12
+ return edgeGlobal.isAdminGlobal(edgeFirebase).value
13
+ })
14
+
15
+ const disableAddSiteForNonAdmin = true
16
+
17
+ const currentOrgRoleName = computed(() => {
18
+ return String(edgeGlobal.getRoleName(edgeFirebase?.user?.roles || [], edgeGlobal.edgeState.currentOrganization) || '').toLowerCase()
19
+ })
20
+
21
+ const canAddSite = computed(() => {
22
+ if (!disableAddSiteForNonAdmin)
23
+ return true
24
+ return currentOrgRoleName.value === 'admin'
25
+ })
26
+
27
+ const queryField = computed(() => {
28
+ if (!isAdmin.value) {
29
+ return 'users'
30
+ }
31
+ return ''
32
+ })
33
+
34
+ const queryValue = computed(() => {
35
+ if (!isAdmin.value) {
36
+ return [edgeFirebase?.user?.uid]
37
+ }
38
+ return ''
39
+ })
40
+
41
+ const queryOperator = computed(() => {
42
+ if (!isAdmin.value) {
43
+ return 'array-contains-any'
44
+ }
45
+ return ''
46
+ })
47
+ </script>
48
+
49
+ <template>
50
+ <edge-dashboard
51
+ :filter="state.filter"
52
+ :query-field="queryField"
53
+ :query-value="queryValue"
54
+ :query-operator="queryOperator"
55
+ collection="sites"
56
+ class="flex-1 pt-0"
57
+ >
58
+ <template #header-end>
59
+ <edge-shad-button
60
+ v-if="canAddSite"
61
+ class="uppercase bg-primary"
62
+ to="/app/dashboard/sites/new"
63
+ >
64
+ Add Site
65
+ </edge-shad-button>
66
+ <div v-else class="hidden" />
67
+ </template>
68
+ <template #list="slotProps">
69
+ <template v-for="item in slotProps.filtered" :key="item.docId">
70
+ <edge-shad-button
71
+ variant="text"
72
+ class="cursor-pointer w-full flex justify-between items-center py-2 gap-3"
73
+ :to="isAiBusy(item.aiBootstrapStatus) ? undefined : `/app/dashboard/sites/${item.docId}`"
74
+ :disabled="isAiBusy(item.aiBootstrapStatus)"
75
+ >
76
+ <div>
77
+ <Avatar class="cursor-pointer p-0 h-8 w-8 mr-2">
78
+ <FilePenLine class="h-5 w-5" />
79
+ </Avatar>
80
+ </div>
81
+ <div class="grow text-left">
82
+ <div class="text-lg">
83
+ {{ item.name }}
84
+ </div>
85
+ <div v-if="isAiBusy(item.aiBootstrapStatus)" class="flex items-center gap-2 text-xs text-muted-foreground">
86
+ <Loader2 class="h-3 w-3 animate-spin" />
87
+ <span>AI is preparing this site</span>
88
+ </div>
89
+ </div>
90
+ <div>
91
+ <edge-shad-button
92
+ size="icon"
93
+ class="bg-slate-600 h-7 w-7"
94
+ @click.stop="slotProps.deleteItem(item.docId)"
95
+ >
96
+ <Trash class="h-5 w-5" />
97
+ </edge-shad-button>
98
+ </div>
99
+ </edge-shad-button>
100
+ <Separator class="dark:bg-slate-600" />
101
+ </template>
102
+ <div
103
+ v-if="slotProps.filtered.length === 0 && !isAdmin && disableAddSiteForNonAdmin"
104
+ class="px-4 py-6 text-sm text-muted-foreground"
105
+ >
106
+ No sites are assigned to your account. Contact an organization admin to add a site for you.
107
+ </div>
108
+ </template>
109
+ </edge-dashboard>
110
+ </template>
@@ -96,6 +96,46 @@ const state = reactive({
96
96
  const edgeFirebase = inject('edgeFirebase')
97
97
  // const edgeGlobal = inject('edgeGlobal')
98
98
 
99
+ const normalizeObject = (value) => {
100
+ if (Array.isArray(value)) {
101
+ return value.map(item => normalizeObject(item))
102
+ }
103
+
104
+ if (value && typeof value === 'object') {
105
+ return Object.keys(value)
106
+ .sort()
107
+ .reduce((acc, key) => {
108
+ acc[key] = normalizeObject(value[key])
109
+ return acc
110
+ }, {})
111
+ }
112
+
113
+ return value
114
+ }
115
+
116
+ const withSchemaDefaults = (doc = {}) => {
117
+ const normalizedDoc = edgeGlobal.dupObject(doc || {})
118
+ Object.keys(newDoc.value).forEach((field) => {
119
+ if (!edgeGlobal.objHas(normalizedDoc, field)) {
120
+ normalizedDoc[field] = newDoc.value[field]
121
+ }
122
+ })
123
+ return normalizedDoc
124
+ }
125
+
126
+ const comparableDoc = (doc = {}) => {
127
+ const sourceDoc = (doc && typeof doc === 'object') ? doc : {}
128
+ const comparable = {}
129
+ Object.keys(props.newDocSchema || {}).forEach((field) => {
130
+ if (edgeGlobal.objHas(sourceDoc, field)) {
131
+ comparable[field] = sourceDoc[field]
132
+ return
133
+ }
134
+ comparable[field] = newDoc.value[field]
135
+ })
136
+ return normalizeObject(comparable)
137
+ }
138
+
99
139
  const unsavedChanges = computed(() => {
100
140
  if (props.docId === 'new') {
101
141
  return false
@@ -107,9 +147,9 @@ const unsavedChanges = computed(() => {
107
147
  return false
108
148
  }
109
149
 
110
- console.log('comparing', state.workingDoc, baselineDoc)
111
- console.log('unsavedChanges', JSON.stringify(state.workingDoc) !== JSON.stringify(baselineDoc))
112
- return JSON.stringify(state.workingDoc) !== JSON.stringify(baselineDoc)
150
+ const workingComparable = comparableDoc(state.workingDoc)
151
+ const baselineComparable = comparableDoc(baselineDoc)
152
+ return JSON.stringify(workingComparable) !== JSON.stringify(baselineComparable)
113
153
  })
114
154
 
115
155
  onBeforeRouteLeave((to, from, next) => {
@@ -164,7 +204,7 @@ const discardChanges = async () => {
164
204
  }
165
205
  return
166
206
  }
167
- state.workingDoc = await edgeGlobal.dupObject(state.collectionData[props.docId])
207
+ state.workingDoc = withSchemaDefaults(state.collectionData[props.docId])
168
208
  state.bypassUnsavedChanges = true
169
209
  state.dialog = false
170
210
  edgeGlobal.edgeState.changeTracker = {}
@@ -328,44 +368,63 @@ const initData = (newVal) => {
328
368
  if (edgeGlobal.objHas(newVal, props.docId) === false) {
329
369
  return
330
370
  }
331
- state.workingDoc = edgeGlobal.dupObject(newVal[props.docId])
332
- Object.keys(newDoc.value).forEach((field) => {
333
- if (!edgeGlobal.objHas(state.workingDoc, field)) {
334
- state.workingDoc[field] = newDoc.value[field]
335
- }
336
- })
371
+ state.workingDoc = withSchemaDefaults(newVal[props.docId])
337
372
  state.afterMount = true
338
373
  }
339
374
  else {
340
375
  if (!state.afterMount) {
341
- state.workingDoc = edgeGlobal.dupObject(newDoc.value)
376
+ state.workingDoc = withSchemaDefaults(newDoc.value)
342
377
  }
343
378
  state.afterMount = true
344
379
  }
345
380
  }
346
381
 
347
- onBeforeMount(async () => {
348
- state.bypassUnsavedChanges = false
349
- edgeGlobal.edgeState.changeTracker = {}
350
- for (const field of Object.keys(props.newDocSchema)) {
382
+ const startCollectionSnapshots = async () => {
383
+ for (const field of Object.keys(props.newDocSchema || {})) {
351
384
  if (props.newDocSchema[field].type === 'collection') {
352
385
  await edgeFirebase.startSnapshot(`${edgeGlobal.edgeState.organizationDocPath}/${field}`)
353
386
  }
354
387
  }
355
- emit('unsavedChanges', unsavedChanges.value)
356
- // console.log('mounting editor for', props.collection, props.docId)
357
- // console.log('starting snapshot for collection:', props.collection)
358
- // await edgeFirebase.startSnapshot(`${edgeGlobal.edgeState.organizationDocPath}/${props.collection}`)
359
- if (!edgeFirebase.data?.[`${edgeGlobal.edgeState.organizationDocPath}/${props.collection}`]) {
360
- console.log(`${edgeGlobal.edgeState.organizationDocPath}/${props.collection}`)
361
- console.log(props.docId)
362
- const docData = await edgeFirebase.getDocData(`${edgeGlobal.edgeState.organizationDocPath}/${props.collection}`, props.docId)
363
- state.collectionData[props.docId] = docData
364
- initData(state.collectionData)
365
- }
366
- else {
367
- state.collectionData = edgeFirebase.data[`${edgeGlobal.edgeState.organizationDocPath}/${props.collection}`]
388
+ }
389
+
390
+ const setCollectionData = async () => {
391
+ const collectionPath = `${edgeGlobal.edgeState.organizationDocPath}/${props.collection}`
392
+ if (!edgeFirebase.data?.[collectionPath]) {
393
+ if (props.docId !== 'new') {
394
+ const docData = await edgeFirebase.getDocData(collectionPath, props.docId)
395
+ state.collectionData = {
396
+ ...(state.collectionData || {}),
397
+ [props.docId]: docData,
398
+ }
399
+ }
400
+ else if (!state.collectionData || typeof state.collectionData !== 'object') {
401
+ state.collectionData = {}
402
+ }
403
+ return
368
404
  }
405
+ state.collectionData = edgeFirebase.data[collectionPath]
406
+ }
407
+
408
+ const resetEditorState = () => {
409
+ state.bypassUnsavedChanges = false
410
+ state.bypassRoute = ''
411
+ state.afterMount = false
412
+ state.dialog = false
413
+ state.successMessage = ''
414
+ state.skipNextValidation = props.docId === 'new'
415
+ edgeGlobal.edgeState.changeTracker = {}
416
+ }
417
+
418
+ const refreshEditorData = async () => {
419
+ resetEditorState()
420
+ await startCollectionSnapshots()
421
+ await setCollectionData()
422
+ initData(state.collectionData)
423
+ emit('unsavedChanges', unsavedChanges.value)
424
+ }
425
+
426
+ onBeforeMount(async () => {
427
+ await refreshEditorData()
369
428
  if (props.noCloseAfterSave) {
370
429
  state.overrideClose = true
371
430
  }
@@ -374,6 +433,17 @@ onBeforeMount(async () => {
374
433
  watch(() => state.collectionData, (newVal) => {
375
434
  initData(newVal)
376
435
  })
436
+
437
+ watch(() => [props.collection, props.docId], async (newVal, oldVal) => {
438
+ if (!oldVal) {
439
+ return
440
+ }
441
+ if (newVal[0] === oldVal[0] && newVal[1] === oldVal[1]) {
442
+ return
443
+ }
444
+ await refreshEditorData()
445
+ })
446
+
377
447
  onActivated(() => {
378
448
  // console.log('activated')
379
449
  state.bypassUnsavedChanges = false
@@ -384,17 +454,12 @@ onActivated(() => {
384
454
  if (edgeGlobal.objHas(state.collectionData, props.docId) === false) {
385
455
  return
386
456
  }
387
- state.workingDoc = edgeGlobal.dupObject(state.collectionData[props.docId])
388
- Object.keys(newDoc.value).forEach((field) => {
389
- if (!edgeGlobal.objHas(state.workingDoc, field)) {
390
- state.workingDoc[field] = newDoc.value[field]
391
- }
392
- })
457
+ state.workingDoc = withSchemaDefaults(state.collectionData[props.docId])
393
458
 
394
459
  // console.log('state.workingDoc', state.workingDoc)
395
460
  }
396
461
  else {
397
- state.workingDoc = edgeGlobal.dupObject(newDoc.value)
462
+ state.workingDoc = withSchemaDefaults(newDoc.value)
398
463
  Object.entries(route.query).forEach(([key, value]) => {
399
464
  // Check if the key exists in state.workingDoc, and if so, set the value
400
465
  if (key in state.workingDoc) {
@@ -15,6 +15,7 @@ export const useSiteSettingsTemplate = () => {
15
15
  favicon: '',
16
16
  menuPosition: 'right',
17
17
  domains: [],
18
+ forwardApex: true,
18
19
  contactEmail: '',
19
20
  contactPhone: '',
20
21
  metaTitle: '',
@@ -49,6 +50,7 @@ export const useSiteSettingsTemplate = () => {
49
50
  favicon: { bindings: { 'field-type': 'text', 'label': 'Favicon' }, cols: '12', value: defaults.favicon },
50
51
  menuPosition: { bindings: { 'field-type': 'select', 'label': 'Menu Position', 'items': ['left', 'center', 'right'] }, cols: '12', value: defaults.menuPosition },
51
52
  domains: { bindings: { 'field-type': 'tags', 'label': 'Domains', 'helper': 'Add or remove domains' }, cols: '12', value: defaults.domains },
53
+ forwardApex: { bindings: { 'field-type': 'boolean', 'label': 'Forward Apex (non-www) domains to www' }, cols: '12', value: defaults.forwardApex },
52
54
  contactEmail: { bindings: { 'field-type': 'text', 'label': 'Contact Email' }, cols: '12', value: defaults.contactEmail },
53
55
  contactPhone: { bindings: { 'field-type': 'text', 'label': 'Contact Phone' }, cols: '12', value: defaults.contactPhone },
54
56
  metaTitle: { bindings: { 'field-type': 'text', 'label': 'Meta Title' }, cols: '12', value: defaults.metaTitle },
@@ -0,0 +1,21 @@
1
+ <script setup>
2
+ const state = reactive({
3
+ mounted: false,
4
+ })
5
+
6
+ definePageMeta({
7
+ middleware: 'auth',
8
+ })
9
+
10
+ onMounted(() => {
11
+ state.mounted = true
12
+ })
13
+ </script>
14
+
15
+ <template>
16
+ <div
17
+ v-if="edgeGlobal.edgeState.organizationDocPath && state.mounted"
18
+ >
19
+ <edge-cms-blocks-manager />
20
+ </div>
21
+ </template>
@@ -0,0 +1,13 @@
1
+ <script setup>
2
+ definePageMeta({
3
+ middleware: 'auth',
4
+ })
5
+ </script>
6
+
7
+ <template>
8
+ <div
9
+ v-if="edgeGlobal.edgeState.organizationDocPath"
10
+ >
11
+ <edge-cms-sites-manager />
12
+ </div>
13
+ </template>
@@ -0,0 +1,52 @@
1
+ import { resolve as resolvePath } from 'node:path'
2
+ import { cmsRoutes } from './routes'
3
+
4
+ const resolveRouteFileFromCwd = (file) => {
5
+ const normalizedPath = String(file || '').replace(/^\.\//, '')
6
+ return resolvePath(process.cwd(), normalizedPath)
7
+ }
8
+
9
+ export const createCmsNuxtHooks = (resolveFile) => {
10
+ const resolveRouteFile = typeof resolveFile === 'function'
11
+ ? resolveFile
12
+ : resolveRouteFileFromCwd
13
+
14
+ const routeExists = (routeList, name) => {
15
+ for (const route of routeList) {
16
+ if (route.name === name)
17
+ return true
18
+ if (route.children?.length && routeExists(route.children, name))
19
+ return true
20
+ }
21
+ return false
22
+ }
23
+
24
+ const resolveCmsRoute = (route, appRootRoute, isChild = false) => {
25
+ const shouldStripAppPrefix = Boolean(appRootRoute) && !isChild
26
+ const nextRoute = {
27
+ ...route,
28
+ path: shouldStripAppPrefix
29
+ ? route.path.replace(/^\/app\/?/, '')
30
+ : route.path,
31
+ file: resolveRouteFile(route.file),
32
+ }
33
+
34
+ if (route.children?.length)
35
+ nextRoute.children = route.children.map(child => resolveCmsRoute(child, appRootRoute, true))
36
+
37
+ return nextRoute
38
+ }
39
+
40
+ return {
41
+ 'pages:extend': (pages) => {
42
+ const appRootRoute = pages.find(page => page.path === '/app')
43
+ const targetRoutes = appRootRoute?.children || pages
44
+
45
+ cmsRoutes.forEach((route) => {
46
+ const routeToAdd = resolveCmsRoute(route, appRootRoute)
47
+ if (!routeExists(targetRoutes, routeToAdd.name))
48
+ targetRoutes.push(routeToAdd)
49
+ })
50
+ },
51
+ }
52
+ }
@@ -0,0 +1,56 @@
1
+ export const cmsRoutes = [
2
+ {
3
+ name: 'cms-dashboard-sites-index',
4
+ path: '/app/dashboard/sites',
5
+ file: './edge/routes/cms/dashboard/sites/index.vue',
6
+ },
7
+ {
8
+ name: 'cms-dashboard-blocks-index',
9
+ path: '/app/dashboard/blocks',
10
+ file: './edge/routes/cms/dashboard/blocks/index.vue',
11
+ },
12
+ {
13
+ name: 'cms-dashboard-blocks-block',
14
+ path: '/app/dashboard/blocks/:block',
15
+ file: './edge/routes/cms/dashboard/blocks/[block].vue',
16
+ },
17
+ {
18
+ name: 'cms-dashboard-media-index',
19
+ path: '/app/dashboard/media',
20
+ file: './edge/routes/cms/dashboard/media/index.vue',
21
+ },
22
+ {
23
+ name: 'cms-dashboard-sites-site',
24
+ path: '/app/dashboard/sites/:site',
25
+ file: './edge/routes/cms/dashboard/sites/[site].vue',
26
+ children: [
27
+ {
28
+ name: 'cms-dashboard-sites-site-page',
29
+ path: ':page',
30
+ file: './edge/routes/cms/dashboard/sites/[site]/[[page]].vue',
31
+ },
32
+ ],
33
+ },
34
+ {
35
+ name: 'cms-dashboard-templates-index',
36
+ path: '/app/dashboard/templates',
37
+ file: './edge/routes/cms/dashboard/templates/index.vue',
38
+ children: [
39
+ {
40
+ name: 'cms-dashboard-templates-page',
41
+ path: ':page',
42
+ file: './edge/routes/cms/dashboard/templates/[page].vue',
43
+ },
44
+ ],
45
+ },
46
+ {
47
+ name: 'cms-dashboard-themes-index',
48
+ path: '/app/dashboard/themes',
49
+ file: './edge/routes/cms/dashboard/themes/index.vue',
50
+ },
51
+ {
52
+ name: 'cms-dashboard-themes-theme',
53
+ path: '/app/dashboard/themes/:theme',
54
+ file: './edge/routes/cms/dashboard/themes/[theme].vue',
55
+ },
56
+ ]
package/nuxt.config.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { createCmsNuxtHooks } from './edge/routes/cms/nuxtHooks'
2
+
3
+ const cmsRouteHooks = {}
4
+ Object.assign(cmsRouteHooks, createCmsNuxtHooks()) // Comment out this line to disable CMS routes.
5
+
1
6
  // https://nuxt.com/docs/api/configuration/nuxt-config
2
7
  export default defineNuxtConfig({
3
8
  ssr: false,
@@ -16,6 +21,12 @@ export default defineNuxtConfig({
16
21
  { rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' },
17
22
  { rel: 'manifest', href: '/favicon/site.webmanifest' },
18
23
  ],
24
+ meta: [
25
+ {
26
+ 'http-equiv': 'Content-Security-Policy',
27
+ 'content': 'font-src \'self\' https://files.edgemarketingdesign.com https://use.typekit.net https://p.typekit.net data:;',
28
+ },
29
+ ],
19
30
  },
20
31
  },
21
32
  modules: ['@vant/nuxt', '@nuxtjs/tailwindcss', '@nuxtjs/color-mode', 'shadcn-nuxt'],
@@ -40,9 +51,11 @@ export default defineNuxtConfig({
40
51
  },
41
52
  components: {
42
53
  dirs: [
43
- { path: '~/components/formSubtypes', global: true, prefix: 'edge-form-subtypes' },
54
+ { path: '~/components' },
55
+
56
+ // Namespaced wrappers — keep them, but lower priority is fine
44
57
  { path: '~/edge/components', global: true, prefix: 'edge' },
45
- '~/components',
58
+ { path: '~/components/formSubtypes', global: true, prefix: 'edge-form-subtypes' },
46
59
  ],
47
60
  },
48
61
  vite: {
@@ -50,11 +63,15 @@ export default defineNuxtConfig({
50
63
  'process.env.DEBUG': false,
51
64
  },
52
65
  server: {
66
+ watch: {
67
+ ignored: ['**/.nuxt/**', '**/.output/**', '**/dist/**', '**/node_modules/**'],
68
+ },
53
69
  hmr: {
54
70
  port: 3000, // Make sure this port matches your Nuxt server port
55
71
  clientPort: 3000, // Ensure this matches your Nuxt server port as well
56
72
  },
57
73
  },
58
74
  },
75
+ hooks: cmsRouteHooks,
59
76
  devtools: { enabled: false },
60
77
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgedev/create-edge-app",
3
- "version": "1.2.29",
3
+ "version": "1.2.31",
4
4
  "description": "Create Edge Starter App",
5
5
  "bin": {
6
6
  "create-edge-app": "./bin/cli.js"
@@ -1,114 +0,0 @@
1
- <script setup>
2
- import { toTypedSchema } from '@vee-validate/zod'
3
- import * as z from 'zod'
4
- import { Loader2 } from 'lucide-vue-next'
5
- const route = useRoute()
6
- // const edgeGlobal = inject('edgeGlobal')
7
-
8
- const edgeFirebase = inject('edgeFirebase')
9
- const isAiBusy = status => status === 'queued' || status === 'running'
10
-
11
- const state = reactive({
12
- filter: '',
13
- newDocs: {
14
- sites: {
15
- name: { bindings: { 'field-type': 'text', 'label': 'Name', 'helper': 'Name' }, cols: '12', value: '' },
16
- },
17
- },
18
- })
19
-
20
- const schemas = {
21
- sites: toTypedSchema(z.object({
22
- name: z.string({
23
- required_error: 'Name is required',
24
- }).min(1, { message: 'Name is required' }),
25
- })),
26
- }
27
-
28
- const collection = computed(() => {
29
- if (route.params.collection) {
30
- return route.params.collection
31
- }
32
- return ''
33
- })
34
- const docId = computed(() => {
35
- if (route.params.docId) {
36
- return route.params.docId
37
- }
38
- return ''
39
- })
40
- definePageMeta({
41
- middleware: 'auth',
42
- })
43
-
44
- onBeforeMount(() => {
45
- // edgeGlobal.showLeftPanel(true)
46
- })
47
- const isAdmin = computed(() => {
48
- return edgeGlobal.isAdminGlobal(edgeFirebase).value
49
- })
50
- const queryField = computed(() => {
51
- if (!isAdmin.value) {
52
- return 'users'
53
- }
54
- return ''
55
- })
56
-
57
- const queryValue = computed(() => {
58
- if (!isAdmin.value) {
59
- return [edgeFirebase?.user?.uid]
60
- }
61
- return ''
62
- })
63
-
64
- const queryOperator = computed(() => {
65
- if (!isAdmin.value) {
66
- return 'array-contains-any'
67
- }
68
- return ''
69
- })
70
- </script>
71
-
72
- <template>
73
- <div
74
- v-if="edgeGlobal.edgeState.organizationDocPath"
75
- >
76
- <edge-dashboard :load-first-if-one="!isAdmin" :filter="state.filter" :query-field="queryField" :query-value="queryValue" :query-operator="queryOperator" collection="sites" class="flex-1 pt-0">
77
- <template #list="slotProps">
78
- <template v-for="item in slotProps.filtered" :key="item.docId">
79
- <edge-shad-button
80
- variant="text"
81
- class="cursor-pointer w-full flex justify-between items-center py-2 gap-3"
82
- :to="isAiBusy(item.aiBootstrapStatus) ? undefined : `/app/dashboard/sites/${item.docId}`"
83
- :disabled="isAiBusy(item.aiBootstrapStatus)"
84
- >
85
- <div>
86
- <Avatar class="cursor-pointer p-0 h-8 w-8 mr-2">
87
- <FilePenLine class="h-5 w-5" />
88
- </Avatar>
89
- </div>
90
- <div class="grow text-left">
91
- <div class="text-lg">
92
- {{ item.name }}
93
- </div>
94
- <div v-if="isAiBusy(item.aiBootstrapStatus)" class="flex items-center gap-2 text-xs text-muted-foreground">
95
- <Loader2 class="h-3 w-3 animate-spin" />
96
- <span>AI is preparing this site</span>
97
- </div>
98
- </div>
99
- <div>
100
- <edge-shad-button
101
- size="icon"
102
- class="bg-slate-600 h-7 w-7"
103
- @click.stop="slotProps.deleteItem(item.docId)"
104
- >
105
- <Trash class="h-5 w-5" />
106
- </edge-shad-button>
107
- </div>
108
- </edge-shad-button>
109
- <Separator class="dark:bg-slate-600" />
110
- </template>
111
- </template>
112
- </edge-dashboard>
113
- </div>
114
- </template>