@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,12 +1,19 @@
1
1
  <script setup>
2
2
  // TODO: pass possible roles in prop
3
3
  import { toTypedSchema } from '@vee-validate/zod'
4
+ import { ArrowLeft, Loader2, Trash2, User } from 'lucide-vue-next'
4
5
  import * as z from 'zod'
5
6
  const props = defineProps({
6
7
  usersCollectionPath: {
7
8
  type: String,
8
9
  default: () => `organizations/${edgeGlobal.edgeState.currentOrganization}`,
9
10
  },
11
+ defaultImageTags: {
12
+ type: Array,
13
+ default: () => [
14
+ 'Headshots',
15
+ ],
16
+ },
10
17
  metaFields: {
11
18
  type: Array,
12
19
  default: () => [
@@ -49,6 +56,11 @@ const props = defineProps({
49
56
  name: z
50
57
  .string({ required_error: 'Name is required' })
51
58
  .min(1, { message: 'Name is required' }),
59
+ email: z
60
+ .string({ required_error: 'Email is required' })
61
+ .email({ message: 'Invalid email address' })
62
+ .min(6, { message: 'Email must be at least 6 characters long' })
63
+ .max(50, { message: 'Email must be less than 50 characters long' }),
52
64
  }),
53
65
  role: z
54
66
  .string({ required_error: 'Role is required' })
@@ -56,6 +68,10 @@ const props = defineProps({
56
68
  }),
57
69
  ),
58
70
  },
71
+ metaFieldsSchema: {
72
+ type: Object,
73
+ default: null,
74
+ },
59
75
  })
60
76
  // TODO: If a removed user no longer has roles to any organiztions, need to a create new organization for them with
61
77
  // default name of "Personal". This will allow them to continue to use the app.
@@ -80,6 +96,9 @@ const state = reactive({
80
96
  role: '',
81
97
  isTemplate: false,
82
98
  },
99
+ inviteOrgIds: [],
100
+ editOrgIds: [],
101
+ removeOrgIds: [],
83
102
  loaded: false,
84
103
  })
85
104
 
@@ -92,6 +111,47 @@ const roleNamesOnly = computed(() => {
92
111
  const edgeUsers = toRef(edgeFirebase.state, 'users')
93
112
  const users = computed(() => Object.values(edgeUsers.value ?? {}))
94
113
 
114
+ const orgCollectionPath = orgId => `organizations-${String(orgId).replaceAll('/', '-')}`
115
+
116
+ const adminOrgOptions = computed(() => {
117
+ const orgs = edgeGlobal.edgeState.organizations || []
118
+ const roles = edgeFirebase?.user?.roles || []
119
+ return orgs.filter(org =>
120
+ roles.some(role => role.collectionPath === orgCollectionPath(org.docId) && role.role === 'admin'),
121
+ )
122
+ })
123
+
124
+ const inviteOrgOptions = computed(() => adminOrgOptions.value)
125
+
126
+ const editOrgOptions = computed(() => adminOrgOptions.value)
127
+
128
+ const removeOrgOptions = computed(() => {
129
+ const userRoles = state.workingItem?.roles || []
130
+ return adminOrgOptions.value.filter(org =>
131
+ userRoles.some(role => role.collectionPath.startsWith(orgCollectionPath(org.docId))),
132
+ )
133
+ })
134
+
135
+ const showInviteOrgSelect = computed(() => inviteOrgOptions.value.length > 1)
136
+ const showEditOrgSelect = computed(() => editOrgOptions.value.length > 1)
137
+ const showRemoveOrgSelect = computed(() => removeOrgOptions.value.length > 1)
138
+
139
+ const adminCount = computed(() => {
140
+ return users.value.filter((item) => {
141
+ return item.roles.find((role) => {
142
+ return role.collectionPath === edgeGlobal.edgeState.organizationDocPath.replaceAll('/', '-') && role.role === 'admin'
143
+ })
144
+ }).length
145
+ })
146
+
147
+ const selfRemoveBlocked = computed(() => {
148
+ return state.workingItem.userId === edgeFirebase.user.uid
149
+ && adminCount.value === 1
150
+ && state.removeOrgIds.includes(edgeGlobal.edgeState.currentOrganization)
151
+ })
152
+
153
+ const emailDisabledHint = 'This field is tied to the user\'s username and can only be changed by them.'
154
+
95
155
  const WIDTHS = {
96
156
  1: 'md:col-span-1',
97
157
  2: 'md:col-span-2',
@@ -109,6 +169,42 @@ const WIDTHS = {
109
169
 
110
170
  const numColsToTailwind = cols => WIDTHS[cols] || 'md:col-span-12'
111
171
 
172
+ const disabledNoteText = 'Contact admin to change.'
173
+
174
+ const getDisabledNote = (field) => {
175
+ if (!field?.disabled)
176
+ return ''
177
+ return field?.disabledNote || disabledNoteText
178
+ }
179
+
180
+ const mergeDisabledNote = (text, field) => {
181
+ const note = getDisabledNote(field)
182
+ if (!note)
183
+ return text || ''
184
+ if (text)
185
+ return `${text} ${note}`
186
+ return note
187
+ }
188
+
189
+ const userKey = (user) => {
190
+ return user?.docId || user?.userId || user?.id || user?.uid || ''
191
+ }
192
+
193
+ const PROFILE_IMAGE_SIZE = 96
194
+ const PROFILE_IMAGE_VARIANT = `width=${PROFILE_IMAGE_SIZE},height=${PROFILE_IMAGE_SIZE},fit=cover,quality=85`
195
+
196
+ const profileImageUrl = (url) => {
197
+ if (!url || typeof url !== 'string')
198
+ return ''
199
+ if (url.includes('/cdn-cgi/image/'))
200
+ return url
201
+ if (url.includes('width=') && url.includes('height='))
202
+ return url
203
+ if (url.endsWith('/thumbnail'))
204
+ return url.replace(/\/thumbnail$/, `/${PROFILE_IMAGE_VARIANT}`)
205
+ return url
206
+ }
207
+
112
208
  // Helpers to read/write nested keys like "profile.firstName" on plain objects
113
209
  function getByPath(obj, path, fallback = undefined) {
114
210
  if (!obj || !path)
@@ -142,39 +238,38 @@ function setByPath(obj, path, value) {
142
238
  }
143
239
 
144
240
  const sortedFilteredUsers = computed(() => {
145
- const filter = state.filter.toLowerCase()
241
+ const filter = String(state.filter || '').toLowerCase()
146
242
 
147
243
  const getLastName = (fullName) => {
148
244
  if (!fullName)
149
245
  return ''
150
- const parts = fullName.trim().split(/\s+/)
246
+ const parts = String(fullName).trim().split(/\s+/)
151
247
  return parts[parts.length - 1] || ''
152
248
  }
153
249
 
154
250
  return users.value
155
- .filter(user => user.meta.name.toLowerCase().includes(filter))
251
+ .filter((user) => {
252
+ const name = String(user?.meta?.name || '')
253
+ return name.toLowerCase().includes(filter)
254
+ })
156
255
  .sort((a, b) => {
157
- const lastA = getLastName(a.meta.name).toLowerCase()
158
- const lastB = getLastName(b.meta.name).toLowerCase()
256
+ const lastA = getLastName(a?.meta?.name).toLowerCase()
257
+ const lastB = getLastName(b?.meta?.name).toLowerCase()
159
258
  return lastA.localeCompare(lastB)
160
259
  })
161
260
  })
162
261
 
163
- const adminCount = computed(() => {
164
- return users.value.filter((item) => {
165
- return item.roles.find((role) => {
166
- return role.collectionPath === edgeGlobal.edgeState.organizationDocPath.replaceAll('/', '-') && role.role === 'admin'
167
- })
168
- }).length
169
- })
170
-
171
262
  const addItem = () => {
172
263
  state.saveButton = 'Invite User'
173
264
  const newItem = edgeGlobal.dupObject(state.newItem)
174
265
  newItem.meta.email = ''
266
+ newItem.meta.name = ''
267
+ newItem.meta.profilephoto = ''
175
268
  state.workingItem = newItem
176
269
  state.workingItem.id = edgeGlobal.generateShortId()
177
270
  state.currentTitle = 'Invite User'
271
+ state.inviteOrgIds = [edgeGlobal.edgeState.currentOrganization]
272
+ state.editOrgIds = []
178
273
  state.dialog = true
179
274
  }
180
275
 
@@ -184,6 +279,9 @@ const editItem = (item) => {
184
279
  state.workingItem = edgeGlobal.dupObject(item)
185
280
  state.workingItem.meta = edgeGlobal.dupObject(item.meta)
186
281
  state.workingItem.role = edgeGlobal.getRoleName(item.roles, edgeGlobal.edgeState.currentOrganization)
282
+ state.editOrgIds = editOrgOptions.value
283
+ .filter(org => state.workingItem.roles.some(role => role.collectionPath.startsWith(orgCollectionPath(org.docId))))
284
+ .map(org => org.docId)
187
285
  const newItemKeys = Object.keys(state.newItem)
188
286
  newItemKeys.forEach((key) => {
189
287
  if (!state.workingItem?.[key]) {
@@ -205,15 +303,20 @@ const editItem = (item) => {
205
303
  const deleteConfirm = (item) => {
206
304
  state.currentTitle = item.name
207
305
  state.workingItem = edgeGlobal.dupObject(item)
306
+ state.removeOrgIds = [edgeGlobal.edgeState.currentOrganization]
208
307
  state.deleteDialog = true
209
308
  }
210
309
 
211
310
  const deleteAction = async () => {
311
+ const targetUserId = state.workingItem.docId || state.workingItem.userId
312
+ const selectedOrgIds = state.removeOrgIds.length > 0
313
+ ? state.removeOrgIds
314
+ : [edgeGlobal.edgeState.currentOrganization]
212
315
  const userRoles = state.workingItem.roles.filter((role) => {
213
- return role.collectionPath.startsWith(edgeGlobal.edgeState.organizationDocPath.replaceAll('/', '-'))
316
+ return selectedOrgIds.some(orgId => role.collectionPath.startsWith(orgCollectionPath(orgId)))
214
317
  })
215
318
  for (const role of userRoles) {
216
- await edgeFirebase.removeUserRoles(state.workingItem.docId, role.collectionPath)
319
+ await edgeFirebase.removeUserRoles(targetUserId, role.collectionPath)
217
320
  // console.log(role.collectionPath)
218
321
  }
219
322
  state.deleteDialog = false
@@ -229,10 +332,49 @@ const disableTracking = computed(() => {
229
332
  return state.saveButton === 'Invite User'
230
333
  })
231
334
 
335
+ const updateInviteOrgSelection = (orgId, checked) => {
336
+ const selections = new Set(state.inviteOrgIds)
337
+ if (checked) {
338
+ selections.add(orgId)
339
+ }
340
+ else {
341
+ selections.delete(orgId)
342
+ }
343
+ state.inviteOrgIds = Array.from(selections)
344
+ }
345
+
346
+ const updateEditOrgSelection = (orgId, checked) => {
347
+ const selections = new Set(state.editOrgIds)
348
+ if (checked) {
349
+ selections.add(orgId)
350
+ }
351
+ else {
352
+ selections.delete(orgId)
353
+ }
354
+ state.editOrgIds = Array.from(selections)
355
+ }
356
+
357
+ const updateRemoveOrgSelection = (orgId, checked) => {
358
+ const selections = new Set(state.removeOrgIds)
359
+ if (checked) {
360
+ selections.add(orgId)
361
+ }
362
+ else {
363
+ selections.delete(orgId)
364
+ }
365
+ state.removeOrgIds = Array.from(selections)
366
+ }
367
+
232
368
  const onSubmit = async () => {
233
369
  state.loading = true
234
- const userRoles = edgeGlobal.orgUserRoles(edgeGlobal.edgeState.currentOrganization)
235
- const roles = userRoles.find(role => role.name === state.workingItem.role).roles
370
+ const selectedOrgIds = state.inviteOrgIds.length > 0
371
+ ? state.inviteOrgIds
372
+ : [edgeGlobal.edgeState.currentOrganization]
373
+ const roles = selectedOrgIds.flatMap((orgId) => {
374
+ const userRoles = edgeGlobal.orgUserRoles(orgId)
375
+ const roleMatch = userRoles.find(role => role.name === state.workingItem.role)
376
+ return roleMatch ? roleMatch.roles : []
377
+ })
236
378
  if (state.saveButton === 'Invite User') {
237
379
  if (!state.workingItem.isTemplate) {
238
380
  await edgeFirebase.addUser({ roles, meta: state.workingItem.meta })
@@ -242,19 +384,41 @@ const onSubmit = async () => {
242
384
  }
243
385
  }
244
386
  else {
245
- const oldRoles = state.workingItem.roles.filter((role) => {
246
- return role.collectionPath.startsWith(edgeGlobal.edgeState.organizationDocPath.replaceAll('/', '-'))
247
- && !roles.find(r => r.collectionPath === role.collectionPath)
248
- })
249
-
250
- for (const role of oldRoles) {
251
- await edgeFirebase.removeUserRoles(state.workingItem.docId, role.collectionPath)
252
- }
253
-
254
- for (const role of roles) {
255
- await edgeFirebase.storeUserRoles(state.workingItem.docId, role.collectionPath, role.role)
387
+ const targetUserId = state.workingItem.docId || state.workingItem.userId
388
+ const selectedOrgIds = state.editOrgIds
389
+ for (const org of editOrgOptions.value) {
390
+ const orgId = org.docId
391
+ const orgPath = orgCollectionPath(orgId)
392
+ const shouldHave = selectedOrgIds.includes(orgId)
393
+ const existingRoles = state.workingItem.roles.filter(role =>
394
+ role.collectionPath.startsWith(orgPath),
395
+ )
396
+ if (!shouldHave && existingRoles.length > 0) {
397
+ for (const role of existingRoles) {
398
+ await edgeFirebase.removeUserRoles(targetUserId, role.collectionPath)
399
+ }
400
+ continue
401
+ }
402
+ if (shouldHave) {
403
+ const orgRoles = edgeGlobal.orgUserRoles(orgId)
404
+ const roleMatch = orgRoles.find(role => role.name === state.workingItem.role)
405
+ if (!roleMatch)
406
+ continue
407
+ for (const role of existingRoles) {
408
+ if (!roleMatch.roles.some(r => r.collectionPath === role.collectionPath && r.role === role.role)) {
409
+ await edgeFirebase.removeUserRoles(targetUserId, role.collectionPath)
410
+ }
411
+ }
412
+ for (const role of roleMatch.roles) {
413
+ if (!existingRoles.some(r => r.collectionPath === role.collectionPath && r.role === role.role)) {
414
+ await edgeFirebase.storeUserRoles(targetUserId, role.collectionPath, role.role)
415
+ }
416
+ }
417
+ }
256
418
  }
257
419
  const stagedUserId = state.workingItem.docId
420
+ console.log('Staged User ID:', stagedUserId)
421
+ console.log('Updating meta:', state.workingItem.meta)
258
422
  await edgeFirebase.setUserMeta(state.workingItem.meta, '', stagedUserId)
259
423
  }
260
424
  edgeGlobal.edgeState.changeTracker = {}
@@ -262,10 +426,48 @@ const onSubmit = async () => {
262
426
  state.dialog = false
263
427
  }
264
428
 
429
+ const roleSchema = z
430
+ .string({ required_error: 'Role is required' })
431
+ .min(1, { message: 'Role is required' })
432
+
433
+ const baseMetaSchema = z.object({
434
+ name: z
435
+ .string({ required_error: 'Name is required' })
436
+ .min(1, { message: 'Name is required' }),
437
+ email: z
438
+ .string({ required_error: 'Email is required' })
439
+ .email({ message: 'Invalid email address' })
440
+ .min(6, { message: 'Email must be at least 6 characters long' })
441
+ .max(50, { message: 'Email must be less than 50 characters long' }),
442
+ })
443
+
444
+ const buildMetaSchema = () => {
445
+ const extra = props.metaFieldsSchema
446
+ if (!extra)
447
+ return baseMetaSchema
448
+ if (extra?.shape && typeof extra.shape === 'object')
449
+ return baseMetaSchema.merge(extra)
450
+ if (typeof extra === 'object')
451
+ return baseMetaSchema.extend(extra)
452
+ return baseMetaSchema
453
+ }
454
+
455
+ const computedNewUserSchema = computed(() => {
456
+ if (!props.metaFieldsSchema)
457
+ return props.newUserSchema
458
+ return toTypedSchema(z.object({ meta: buildMetaSchema(), role: roleSchema }))
459
+ })
460
+
461
+ const computedUpdateUserSchema = computed(() => {
462
+ if (!props.metaFieldsSchema)
463
+ return props.updateUserSchema
464
+ return toTypedSchema(z.object({ meta: buildMetaSchema(), role: roleSchema }))
465
+ })
466
+
265
467
  const computedUserSchema = computed(() =>
266
468
  state.saveButton === 'Invite User'
267
- ? props.newUserSchema
268
- : props.updateUserSchema,
469
+ ? computedNewUserSchema.value
470
+ : computedUpdateUserSchema.value,
269
471
  )
270
472
 
271
473
  const currentOrganization = computed(() => {
@@ -301,222 +503,405 @@ onBeforeMount(async () => {
301
503
  </script>
302
504
 
303
505
  <template>
304
- <Card v-if="state.loaded" class="w-full flex-1 bg-muted/50 mx-auto w-full border-none shadow-none pt-2">
305
- <slot name="header" :add-item="addItem">
306
- <edge-menu class="bg-secondary text-foreground rounded-none sticky top-0 py-6">
307
- <template #start>
308
- <slot name="header-start">
309
- <component :is="edgeGlobal.iconFromMenu(route)" class="mr-2" />
310
- <span class="capitalize">Members</span>
311
- </slot>
312
- </template>
313
- <template #center>
314
- <slot name="header-center">
315
- <div class="w-full px-6" />
316
- </slot>
317
- </template>
318
- <template #end>
319
- <slot name="header-end" :add-item="addItem">
320
- <edge-shad-button class="bg-primary mx-2 h-6 text-xs" @click="addItem()">
321
- Invite Member
322
- </edge-shad-button>
323
- </slot>
324
- </template>
325
- </edge-menu>
326
- </slot>
327
- <CardContent class="p-3 w-full overflow-y-auto scroll-area">
328
- <Input
329
- v-model="state.filter"
330
- class="mb-2"
331
- placeholder="Filter members..."
332
- />
333
- <div v-if="sortedFilteredUsers.length > 0">
334
- <div v-for="user in sortedFilteredUsers" :key="user.id" class="flex w-full py-2 justify-between items-center cursor-pointer" @click="editItem(user)">
335
- <slot name="user" :user="user">
336
- <Avatar class="handle pointer p-0 h-6 w-6 mr-2">
337
- <User width="18" height="18" />
338
- </Avatar>
339
- <div class="flex gap-2 mr-2 items-center">
340
- <div class="text-md text-bold mr-2">
341
- {{ user.meta.name }}
506
+ <div v-if="state.loaded" class="w-full flex-1 min-h-0 h-[calc(100vh-58px)] overflow-hidden">
507
+ <ResizablePanelGroup direction="horizontal" class="w-full h-full flex-1">
508
+ <ResizablePanel class="bg-sidebar text-sidebar-foreground min-w-[400px]" :default-size="22" :min-size="30">
509
+ <div class="flex flex-col h-full">
510
+ <div class="px-3 py-3 border-b border-sidebar-border bg-sidebar/90">
511
+ <div class="flex items-center justify-between gap-2">
512
+ <div class="flex items-center gap-2 text-sm font-semibold">
513
+ <component :is="edgeGlobal.iconFromMenu(route)" class="h-4 w-4" />
514
+ <span>Members</span>
342
515
  </div>
343
- <edge-chip v-if="user.userId === edgeFirebase.user.uid">
344
- You
345
- </edge-chip>
346
- <!-- <edge-chip v-if="!user.userId" class="bg-primary">
347
- Invited, Not Registered
348
- </edge-chip> -->
349
- </div>
350
- <div class="grow flex gap-2 justify-end">
351
- <template v-if="!user.userId">
352
- <edge-chip class="bg-slate-600 w-[200px]">
353
- {{ user.docId }}
354
- <edge-clipboard-button class="relative ml-1 top-[2px] mt-0" :text="user.docId" />
355
- </edge-chip>
356
- </template>
357
- <edge-chip>
358
- {{ edgeGlobal.getRoleName(user.roles, edgeGlobal.edgeState.currentOrganization) }}
359
- </edge-chip>
516
+ <edge-shad-button size="sm" class="h-7 text-xs bg-primary" @click="addItem()">
517
+ Invite
518
+ </edge-shad-button>
360
519
  </div>
361
- <edge-shad-button
362
- :disabled="users.length === 1"
363
- class="bg-red-400 mx-2 h-6 w-[80px] text-xs"
364
- variant="outline"
365
- @click.stop="deleteConfirm(user)"
366
- >
367
- <span v-if="user.userId === edgeFirebase.user.uid">Leave</span>
368
- <span v-else>Remove</span>
369
- </edge-shad-button>
370
- </slot>
520
+ <Input
521
+ v-model="state.filter"
522
+ class="mt-3 h-8 w-full"
523
+ placeholder="Filter members..."
524
+ />
525
+ </div>
526
+ <div class="flex-1 overflow-y-auto">
527
+ <SidebarMenu class="px-2 py-2 space-y-0">
528
+ <SidebarMenuItem
529
+ v-for="user in sortedFilteredUsers"
530
+ :key="userKey(user)"
531
+ >
532
+ <SidebarMenuButton
533
+ class="w-full !h-auto items-start px-3 py-2"
534
+ :class="state.dialog && userKey(state.workingItem) && userKey(state.workingItem) === userKey(user) ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''"
535
+ @click="editItem(user)"
536
+ >
537
+ <div class="flex w-full items-start gap-3" :class="!user.userId ? 'opacity-70' : ''">
538
+ <Avatar class="h-12 w-12 rounded-md border bg-muted/40 flex items-center justify-center overflow-hidden">
539
+ <img
540
+ v-if="user?.meta?.profilephoto"
541
+ :src="profileImageUrl(user.meta.profilephoto)"
542
+ alt=""
543
+ class="h-full w-full object-cover"
544
+ >
545
+ <User v-else width="24" height="24" />
546
+ </Avatar>
547
+ <div class="min-w-0 flex-1">
548
+ <div class="flex items-center gap-2">
549
+ <span class="text-sm font-medium leading-snug whitespace-normal uppercase">
550
+ {{ user?.meta?.name || user?.meta?.email || 'Unnamed Member' }}
551
+ </span>
552
+ <!-- <span v-if="!user.userId" class="text-[10px] uppercase tracking-wide text-muted-foreground">
553
+ -
554
+ </span> -->
555
+ <edge-chip v-if="user.userId === edgeFirebase.user.uid">
556
+ You
557
+ </edge-chip>
558
+ </div>
559
+ <div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground leading-snug">
560
+ <span class="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-secondary-foreground">
561
+ {{ edgeGlobal.getRoleName(user.roles, edgeGlobal.edgeState.currentOrganization) }}
562
+ </span>
563
+ <span v-if="!user.userId && user.docId" class="inline-flex items-center gap-1 whitespace-normal">
564
+ {{ user.docId }}
565
+ <edge-clipboard-button class="relative top-[1px]" :text="user.docId" />
566
+ </span>
567
+ </div>
568
+ </div>
569
+ <edge-shad-button
570
+ size="icon"
571
+ variant="ghost"
572
+ class="h-7 w-7 text-destructive/80 hover:text-destructive"
573
+ @click.stop="deleteConfirm(user)"
574
+ >
575
+ <Trash2 class="h-4 w-4" />
576
+ </edge-shad-button>
577
+ </div>
578
+ </SidebarMenuButton>
579
+ <Separator class="my-1" />
580
+ </SidebarMenuItem>
581
+ <div v-if="sortedFilteredUsers.length === 0" class="px-4 py-6 text-xs text-muted-foreground">
582
+ No members found.
583
+ </div>
584
+ </SidebarMenu>
585
+ </div>
371
586
  </div>
372
- </div>
373
- <edge-shad-dialog
374
- v-model="state.deleteDialog"
375
- >
376
- <DialogContent>
377
- <DialogHeader>
378
- <DialogTitle>
379
- <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
380
- Remove Yourself?
381
- </span>
382
- <span v-else>
383
- Remove "{{ state.workingItem.meta.name }}"
384
- </span>
385
- </DialogTitle>
386
- <DialogDescription />
387
- </DialogHeader>
388
-
389
- <h3 v-if="state.workingItem.userId === edgeFirebase.user.uid && adminCount > 1">
390
- Are you sure you want to remove yourself from the organization "{{ currentOrganization.name }}"? You will no longer have access to any of the organization's data.
391
- </h3>
392
- <h3 v-else-if="state.workingItem.userId === edgeFirebase.user.uid && adminCount === 1">
393
- You cannot remove yourself from this organization because you are the only admin. You can delete the organization or add another admin.
394
- </h3>
395
- <h3 v-else>
396
- Are you sure you want to remove "{{ state.workingItem.meta.name }}" from the organization "{{ currentOrganization.name }}"?
397
- </h3>
398
- <DialogFooter class="pt-6 flex justify-between">
399
- <edge-shad-button class="text-white bg-slate-800 hover:bg-slate-400" @click="state.deleteDialog = false">
400
- Cancel
401
- </edge-shad-button>
402
- <edge-shad-button
403
- :disabled="adminCount === 1 && state.workingItem.userId === edgeFirebase.user.uid"
404
- class="w-full"
405
- variant="destructive"
406
- @click="deleteAction()"
587
+ </ResizablePanel>
588
+ <ResizablePanel class="bg-background">
589
+ <div class="h-full flex flex-col">
590
+ <div v-if="state.dialog" class="h-full flex flex-col">
591
+ <edge-shad-form
592
+ :key="userKey(state.workingItem) || state.workingItem?.id || 'member-form'"
593
+ :initial-values="state.workingItem"
594
+ :schema="computedUserSchema"
595
+ class="flex flex-col h-full"
596
+ @submit="onSubmit"
407
597
  >
408
- <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
409
- Leave
410
- </span>
411
- <span v-else>
412
- Remove
413
- </span>
414
- </edge-shad-button>
415
- </DialogFooter>
416
- </DialogContent>
417
- </edge-shad-dialog>
418
- <edge-shad-dialog
419
- v-model="state.dialog"
420
- >
421
- <DialogContent class="w-full max-w-[1200px]">
422
- <edge-shad-form :initial-values="state.workingItem" :schema="computedUserSchema" @submit="onSubmit">
423
- <DialogHeader class="mb-4">
424
- <DialogTitle>
425
- {{ state.currentTitle }}
426
- </DialogTitle>
427
- <DialogDescription />
428
- </DialogHeader>
429
- <slot name="edit-fields" :working-item="state.workingItem">
430
- <edge-g-input
431
- v-model="state.workingItem.role"
432
- name="role"
433
- :disable-tracking="true"
434
- :items="roleNamesOnly"
435
- field-type="select"
436
- label="Role"
437
- :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
438
- :disabled="state.workingItem.userId === edgeFirebase.user.uid"
439
- />
440
- <edge-g-input
441
- v-if="state.saveButton === 'Invite User'"
442
- v-model="state.workingItem.meta.email"
443
- name="meta.email"
444
- :disable-tracking="true"
445
- field-type="text"
446
- label="Email"
447
- :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
448
- />
449
- <Separator class="my-6" />
450
- <div class="grid grid-cols-12 gap-2">
451
- <div v-for="field in props.metaFields" :key="field.field" class="mb-3 col-span-12" :class="numColsToTailwind(field.cols)">
452
- <!-- Use explicit model binding so dotted paths (e.g., "address.street") work -->
453
- <edge-g-input
454
- v-if="field?.type === 'textarea'"
455
- :model-value="getByPath(state.workingItem.meta, field.field, '')"
456
- :name="`meta.${field.field}`"
457
- :field-type="field?.type"
458
- :label="field?.label"
459
- parent-tracker-id="user-settings"
460
- :hint="field?.hint"
461
- :disable-tracking="true"
462
- :bindings="{ class: 'h-60' }"
463
- @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
464
- />
465
- <edge-shad-tags
466
- v-else-if="field?.type === 'tags' || field?.type === 'commaTags'"
467
- :model-value="getByPath(state.workingItem.meta, field.field, '')"
468
- :name="`meta.${field.field}`"
469
- :field-type="field?.type"
470
- :label="field?.label"
471
- parent-tracker-id="user-settings"
472
- :hint="field?.hint"
473
- :disable-tracking="true"
474
- @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
475
- />
598
+ <div class="flex items-center justify-between border-b bg-secondary px-4 py-3">
599
+ <div class="text-sm font-semibold">
600
+ {{ state.currentTitle || 'Member' }}
601
+ </div>
602
+ <div class="flex items-center gap-2">
603
+ <edge-shad-button variant="text" class="text-xs text-red-700" @click="closeDialog">
604
+ Close
605
+ </edge-shad-button>
606
+ <edge-shad-button
607
+ type="submit"
608
+ class="text-xs bg-primary"
609
+ :disabled="state.loading"
610
+ >
611
+ <Loader2 v-if="state.loading" class="w-4 h-4 mr-2 animate-spin" />
612
+ {{ state.saveButton }}
613
+ </edge-shad-button>
614
+ </div>
615
+ </div>
616
+ <div class="flex-1 overflow-y-auto p-6 space-y-4">
617
+ <slot name="edit-fields" :working-item="state.workingItem">
618
+ <div class="rounded-xl border bg-card p-4 space-y-4 shadow-sm">
619
+ <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
620
+ Member Details
621
+ </div>
622
+ <div class="flex flex-col gap-4 md:flex-row md:items-stretch">
623
+ <div class="w-full md:w-[260px] self-stretch">
624
+ <edge-image-picker
625
+ v-model="state.workingItem.meta.profilephoto"
626
+ label="Profile Photo"
627
+ dialog-title="Select Profile Photo"
628
+ site="clearwater-hub-images"
629
+ :default-tags="props.defaultImageTags"
630
+ height-class="h-full min-h-[180px]"
631
+ :include-cms-all="false"
632
+ />
633
+ </div>
634
+ <div class="flex-1 space-y-4">
635
+ <edge-g-input
636
+ v-model="state.workingItem.role"
637
+ name="role"
638
+ :disable-tracking="true"
639
+ :items="roleNamesOnly"
640
+ field-type="select"
641
+ label="Role"
642
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
643
+ :disabled="state.workingItem.userId === edgeFirebase.user.uid"
644
+ />
645
+ <edge-g-input
646
+ v-model="state.workingItem.meta.name"
647
+ name="meta.name"
648
+ :disable-tracking="true"
649
+ field-type="text"
650
+ label="Name"
651
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
652
+ />
653
+ <edge-g-input
654
+ v-model="state.workingItem.meta.email"
655
+ name="meta.email"
656
+ :disable-tracking="true"
657
+ field-type="text"
658
+ label="Email"
659
+ :disabled="state.saveButton !== 'Invite User'"
660
+ :hint="state.saveButton !== 'Invite User' ? emailDisabledHint : ''"
661
+ :persistent-hint="state.saveButton !== 'Invite User'"
662
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
663
+ />
664
+ <edge-g-input
665
+ v-model="state.workingItem.meta.phone"
666
+ name="meta.phone"
667
+ :disable-tracking="true"
668
+ field-type="text"
669
+ label="Phone"
670
+ :mask-options="{ mask: '(###) ###-####' }"
671
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
672
+ />
673
+ </div>
674
+ </div>
675
+ </div>
676
+ <div v-if="state.saveButton !== 'Invite User' && showEditOrgSelect" class="mt-4 w-full">
677
+ <div class="text-sm font-medium text-foreground">
678
+ Organizations
679
+ </div>
680
+ <div class="mt-2 w-full flex flex-wrap gap-2">
681
+ <div v-for="org in editOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
682
+ <edge-shad-checkbox
683
+ :name="`edit-add-org-${org.docId}`"
684
+ :model-value="state.editOrgIds.includes(org.docId)"
685
+ @update:model-value="val => updateEditOrgSelection(org.docId, val)"
686
+ >
687
+ {{ org.name }}
688
+ </edge-shad-checkbox>
689
+ </div>
690
+ </div>
691
+ </div>
692
+ <div v-if="state.saveButton === 'Invite User' && showInviteOrgSelect" class="mt-4 w-full">
693
+ <div class="text-sm font-medium text-foreground">
694
+ Add to organizations
695
+ </div>
696
+ <div class="mt-2 w-full flex flex-wrap gap-2">
697
+ <div v-for="org in inviteOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
698
+ <edge-shad-checkbox
699
+ :name="`invite-org-${org.docId}`"
700
+ :model-value="state.inviteOrgIds.includes(org.docId)"
701
+ @update:model-value="val => updateInviteOrgSelection(org.docId, val)"
702
+ >
703
+ {{ org.name }}
704
+ </edge-shad-checkbox>
705
+ </div>
706
+ </div>
707
+ </div>
708
+ <Separator class="my-6" />
709
+ <div class="grid grid-cols-12 gap-2">
710
+ <div v-for="field in props.metaFields" :key="field.field" class="mb-3 col-span-12" :class="numColsToTailwind(field.cols)">
711
+ <!-- Use explicit model binding so dotted paths (e.g., "address.street") work -->
712
+ <edge-image-picker
713
+ v-if="field?.type === 'imagePicker'"
714
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
715
+ :label="field?.label || 'Photo'"
716
+ :dialog-title="field?.dialogTitle || 'Select Image'"
717
+ :site="field?.site || 'clearwater-hub-images'"
718
+ :default-tags="field?.tags || []"
719
+ :height-class="field?.heightClass || 'h-[160px]'"
720
+ :disabled="field?.disabled || false"
721
+ :include-cms-all="false"
722
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
723
+ />
724
+ <p v-if="field?.type === 'imagePicker' && field?.disabled" class="mt-1 text-xs text-muted-foreground">
725
+ {{ getDisabledNote(field) }}
726
+ </p>
727
+ <div v-else-if="field?.type === 'richText'" class="member-richtext">
728
+ <edge-shad-html
729
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
730
+ :name="`meta.${field.field}`"
731
+ :label="field?.label"
732
+ :disabled="field?.disabled || false"
733
+ :description="mergeDisabledNote(field?.description, field)"
734
+ :enabled-toggles="field?.enabledToggles || ['bold', 'italic', 'strike', 'bulletlist', 'orderedlist', 'underline']"
735
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
736
+ />
737
+ </div>
738
+ <edge-shad-select-tags
739
+ v-else-if="field?.type === 'selectTags'"
740
+ :model-value="getByPath(state.workingItem.meta, field.field, [])"
741
+ :name="`meta.${field.field}`"
742
+ :label="field?.label"
743
+ :description="mergeDisabledNote(field?.description, field)"
744
+ :items="field?.items || []"
745
+ :item-title="field?.itemTitle || 'title'"
746
+ :item-value="field?.itemValue || 'name'"
747
+ :allow-additions="field?.allowAdditions || false"
748
+ :placeholder="field?.placeholder"
749
+ :disabled="field?.disabled || false"
750
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
751
+ />
752
+ <div v-else-if="field?.type === 'boolean'" class="space-y-1 -mt-3">
753
+ <div class="text-sm font-medium leading-none opacity-0 select-none h-4">
754
+ {{ field?.label || '' }}
755
+ </div>
756
+ <edge-g-input
757
+ :model-value="getByPath(state.workingItem.meta, field.field, false)"
758
+ :name="`meta.${field.field}`"
759
+ :field-type="field?.type"
760
+ :label="field?.label"
761
+ parent-tracker-id="user-settings"
762
+ :disable-tracking="true"
763
+ :disabled="field?.disabled || false"
764
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
765
+ />
766
+ <p v-if="mergeDisabledNote(field?.hint, field)" class="text-xs text-muted-foreground">
767
+ {{ mergeDisabledNote(field?.hint, field) }}
768
+ </p>
769
+ </div>
770
+ <edge-g-input
771
+ v-else-if="field?.type === 'textarea'"
772
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
773
+ :name="`meta.${field.field}`"
774
+ :field-type="field?.type"
775
+ :label="field?.label"
776
+ parent-tracker-id="user-settings"
777
+ :hint="mergeDisabledNote(field?.hint, field)"
778
+ :persistent-hint="Boolean(mergeDisabledNote(field?.hint, field))"
779
+ :disable-tracking="true"
780
+ :bindings="{ class: 'h-60' }"
781
+ :mask-options="field?.maskOptions"
782
+ :disabled="field?.disabled || false"
783
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
784
+ />
785
+ <edge-shad-tags
786
+ v-else-if="field?.type === 'tags' || field?.type === 'commaTags'"
787
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
788
+ :name="`meta.${field.field}`"
789
+ :field-type="field?.type"
790
+ :label="field?.label"
791
+ parent-tracker-id="user-settings"
792
+ :description="mergeDisabledNote(field?.description || field?.hint, field)"
793
+ :disable-tracking="true"
794
+ :disabled="field?.disabled || false"
795
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
796
+ />
797
+ <edge-g-input
798
+ v-else
799
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
800
+ :name="`meta.${field.field}`"
801
+ :field-type="field?.type"
802
+ :label="field?.label"
803
+ parent-tracker-id="user-settings"
804
+ :hint="mergeDisabledNote(field?.hint, field)"
805
+ :persistent-hint="Boolean(mergeDisabledNote(field?.hint, field))"
806
+ :disable-tracking="true"
807
+ :mask-options="field?.maskOptions"
808
+ :disabled="field?.disabled || false"
809
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
810
+ />
811
+ </div>
812
+ </div>
813
+
476
814
  <edge-g-input
477
- v-else
478
- :model-value="getByPath(state.workingItem.meta, field.field, '')"
479
- :name="`meta.${field.field}`"
480
- :field-type="field?.type"
481
- :label="field?.label"
482
- parent-tracker-id="user-settings"
483
- :hint="field?.hint"
815
+ v-if="state.saveButton === 'Invite User'"
816
+ v-model="state.workingItem.isTemplate"
817
+ name="isTemplate"
484
818
  :disable-tracking="true"
485
- @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
819
+ field-type="boolean"
820
+ label="Template User"
821
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
486
822
  />
487
- </div>
823
+ </slot>
488
824
  </div>
825
+ </edge-shad-form>
826
+ </div>
827
+ <div v-else class="p-4 text-center flex text-slate-500 h-[calc(100vh-4rem)] justify-center items-center overflow-y-auto">
828
+ <div class="text-4xl">
829
+ <ArrowLeft class="inline-block w-12 h-12 mr-2" /> Select a member to view details.
830
+ </div>
831
+ </div>
832
+ </div>
833
+ </ResizablePanel>
834
+ </ResizablePanelGroup>
489
835
 
490
- <edge-g-input
491
- v-if="state.saveButton === 'Invite User'"
492
- v-model="state.workingItem.isTemplate"
493
- name="isTemplate"
494
- :disable-tracking="true"
495
- field-type="boolean"
496
- label="Template User"
497
- :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
498
- />
499
- </slot>
500
- <DialogFooter class="pt-6 flex justify-between">
501
- <edge-shad-button variant="destructive" @click="closeDialog">
502
- Cancel
503
- </edge-shad-button>
504
- <edge-shad-button
505
- :disabled="state.loading"
506
- class="text-white w-100 bg-slate-800 hover:bg-slate-400"
507
- type="submit"
508
- >
509
- <Loader2 v-if="state.loading" class="w-4 h-4 mr-2 animate-spin" />
510
- {{ state.saveButton }}
511
- </edge-shad-button>
512
- </DialogFooter>
513
- </edge-shad-form>
514
- </DialogContent>
515
- </edge-shad-dialog>
516
- </CardContent>
517
- </Card>
836
+ <edge-shad-dialog
837
+ v-model="state.deleteDialog"
838
+ >
839
+ <DialogContent>
840
+ <DialogHeader>
841
+ <DialogTitle>
842
+ <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
843
+ Remove Yourself?
844
+ </span>
845
+ <span v-else>
846
+ Remove "{{ state.workingItem.meta.name }}"
847
+ </span>
848
+ </DialogTitle>
849
+ <DialogDescription />
850
+ </DialogHeader>
851
+
852
+ <h3 v-if="selfRemoveBlocked">
853
+ You cannot remove yourself from this organization because you are the only admin. You can delete the organization or add another admin.
854
+ </h3>
855
+ <h3 v-else-if="state.workingItem.userId === edgeFirebase.user.uid">
856
+ <span v-if="showRemoveOrgSelect">Select the organizations you want to leave.</span>
857
+ <span v-else>Are you sure you want to remove yourself from the organization "{{ currentOrganization.name }}"? You will no longer have access to any of the organization's data.</span>
858
+ </h3>
859
+ <h3 v-else>
860
+ <span v-if="showRemoveOrgSelect">Select the organizations you want to remove "{{ state.workingItem.meta.name }}" from.</span>
861
+ <span v-else>Are you sure you want to remove "{{ state.workingItem.meta.name }}" from the organization "{{ currentOrganization.name }}"?</span>
862
+ </h3>
863
+ <div v-if="showRemoveOrgSelect" class="mt-4 w-full flex flex-wrap gap-2">
864
+ <div v-for="org in removeOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
865
+ <edge-shad-checkbox
866
+ :name="`remove-org-${org.docId}`"
867
+ :model-value="state.removeOrgIds.includes(org.docId)"
868
+ @update:model-value="val => updateRemoveOrgSelection(org.docId, val)"
869
+ >
870
+ {{ org.name }}
871
+ </edge-shad-checkbox>
872
+ </div>
873
+ </div>
874
+ <DialogFooter class="pt-6 flex justify-between">
875
+ <edge-shad-button class="text-white bg-slate-800 hover:bg-slate-400" @click="state.deleteDialog = false">
876
+ Cancel
877
+ </edge-shad-button>
878
+ <edge-shad-button
879
+ :disabled="selfRemoveBlocked"
880
+ class="w-full"
881
+ variant="destructive"
882
+ @click="deleteAction()"
883
+ >
884
+ <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
885
+ Leave
886
+ </span>
887
+ <span v-else>
888
+ Remove
889
+ </span>
890
+ </edge-shad-button>
891
+ </DialogFooter>
892
+ </DialogContent>
893
+ </edge-shad-dialog>
894
+ </div>
518
895
  </template>
519
896
 
520
897
  <style lang="scss" scoped>
898
+ :deep(.member-richtext .tiptap) {
899
+ min-height: 220px;
900
+ padding: 0.75rem 1rem;
901
+ }
521
902
 
903
+ :deep(.member-richtext .tiptap p) {
904
+ margin-top: 0;
905
+ margin-bottom: 1rem;
906
+ }
522
907
  </style>