@stacksjs/defaults 0.70.362 → 0.70.365

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 (137) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +2 -1
  2. package/ai/skills/stacks-browse/scripts/browse.ts +20 -2
  3. package/ai/skills/stacks-dashboard/SKILL.md +4 -0
  4. package/app/Actions/Auth/LogoutAction.ts +11 -4
  5. package/app/Actions/Auth/LogoutAllAction.ts +8 -4
  6. package/app/Actions/Dashboard/Models/ModelShowAction.ts +4 -1
  7. package/app/Actions/Dashboard/Models/model-write.ts +5 -0
  8. package/ide/vscode/package.json +1 -1
  9. package/package.json +1 -1
  10. package/resources/components/Buttons/Counter.stx +1 -1
  11. package/resources/components/Buttons/ToggleDark.stx +1 -1
  12. package/resources/components/Dashboard/App/RequestDetailsDialog.stx +42 -0
  13. package/resources/components/Dashboard/App/RequestsOverview.stx +1 -21
  14. package/resources/components/Dashboard/Auth/AccessTokenCreateDialog.stx +155 -0
  15. package/resources/components/Dashboard/Auth/AccessTokens.stx +41 -129
  16. package/resources/components/Dashboard/Ci/CiDashboard.stx +1 -157
  17. package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +127 -0
  18. package/resources/components/Dashboard/Commerce/CommerceCategoryDeleteDialog.stx +10 -8
  19. package/resources/components/Dashboard/Commerce/CommerceCategoryDetailsDialog.stx +22 -17
  20. package/resources/components/Dashboard/Commerce/CommerceCategoryDialog.stx +14 -13
  21. package/resources/components/Dashboard/Commerce/CommerceCouponDeleteDialog.stx +10 -7
  22. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +25 -19
  23. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +14 -11
  24. package/resources/components/Dashboard/Commerce/CommerceCustomerDeleteDialog.stx +10 -8
  25. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +24 -16
  26. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +14 -13
  27. package/resources/components/Dashboard/Commerce/CommerceGiftCardDeleteDialog.stx +10 -8
  28. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +16 -14
  29. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +17 -16
  30. package/resources/components/Dashboard/Commerce/CommerceManufacturerDeleteDialog.stx +14 -16
  31. package/resources/components/Dashboard/Commerce/CommerceManufacturerDialog.stx +17 -16
  32. package/resources/components/Dashboard/Commerce/CommerceOrderDeleteDialog.stx +10 -8
  33. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +15 -10
  34. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +14 -13
  35. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +30 -25
  36. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +27 -20
  37. package/resources/components/Dashboard/Commerce/CommerceProductDeleteDialog.stx +14 -14
  38. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +14 -13
  39. package/resources/components/Dashboard/Commerce/CommerceReviewDeleteDialog.stx +10 -18
  40. package/resources/components/Dashboard/Commerce/CommerceReviewEditDialog.stx +17 -16
  41. package/resources/components/Dashboard/Commerce/CommerceTaxDeleteDialog.stx +14 -14
  42. package/resources/components/Dashboard/Commerce/CommerceTaxDialog.stx +17 -16
  43. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +1 -1
  44. package/resources/components/Dashboard/Commerce/CommerceUnitDeleteDialog.stx +14 -17
  45. package/resources/components/Dashboard/Commerce/CommerceUnitDialog.stx +17 -16
  46. package/resources/components/Dashboard/Commerce/CommerceVariantDeleteDialog.stx +10 -8
  47. package/resources/components/Dashboard/Commerce/CommerceVariantDialog.stx +17 -16
  48. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRouteDeleteDialog.stx +11 -15
  49. package/resources/components/Dashboard/Commerce/Delivery/DeliveryRouteDialog.stx +17 -16
  50. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryDeleteDialog.stx +11 -15
  51. package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryDialog.stx +17 -16
  52. package/resources/components/Dashboard/Commerce/Delivery/DriverDeleteDialog.stx +11 -15
  53. package/resources/components/Dashboard/Commerce/Delivery/DriverDialog.stx +17 -16
  54. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeyDeleteDialog.stx +11 -15
  55. package/resources/components/Dashboard/Commerce/Delivery/LicenseKeyDialog.stx +17 -16
  56. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDeleteDialog.stx +11 -15
  57. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +17 -16
  58. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDeleteDialog.stx +11 -15
  59. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +17 -16
  60. package/resources/components/Dashboard/Commerce/Delivery/ShippingZoneDeleteDialog.stx +11 -15
  61. package/resources/components/Dashboard/Commerce/Delivery/ShippingZoneDialog.stx +17 -16
  62. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +16 -15
  63. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +16 -15
  64. package/resources/components/Dashboard/Commerce/PrintDeviceDeleteDialog.stx +10 -11
  65. package/resources/components/Dashboard/Commerce/PrintDeviceDetailsDialog.stx +16 -14
  66. package/resources/components/Dashboard/Commerce/PrintDeviceDialog.stx +17 -16
  67. package/resources/components/Dashboard/Commerce/PrintLogDeleteDialog.stx +10 -7
  68. package/resources/components/Dashboard/Commerce/PrintLogDetailsDialog.stx +13 -10
  69. package/resources/components/Dashboard/Commerce/ProductWaitlistDeleteDialog.stx +10 -18
  70. package/resources/components/Dashboard/Commerce/ProductWaitlistEntryDialog.stx +96 -105
  71. package/resources/components/Dashboard/Commerce/RestaurantWaitlistDeleteDialog.stx +10 -18
  72. package/resources/components/Dashboard/Commerce/RestaurantWaitlistEntryDialog.stx +92 -95
  73. package/resources/components/Dashboard/Content/AuthorEditorDialog.stx +94 -0
  74. package/resources/components/Dashboard/Content/AuthorsDashboard.stx +39 -31
  75. package/resources/components/Dashboard/Content/BlogDashboard.stx +87 -176
  76. package/resources/components/Dashboard/Content/BlogPostEditorDialog.stx +195 -0
  77. package/resources/components/Dashboard/Content/CommentsDashboard.stx +14 -4
  78. package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +40 -55
  79. package/resources/components/Dashboard/Content/CreateFolderDialog.stx +45 -0
  80. package/resources/components/Dashboard/Content/FileDetailsDrawer.stx +31 -0
  81. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +47 -107
  82. package/resources/components/Dashboard/Content/FileUploadDialog.stx +89 -0
  83. package/resources/components/Dashboard/Content/PageEditorDialog.stx +93 -0
  84. package/resources/components/Dashboard/Content/PagesDashboard.stx +36 -26
  85. package/resources/components/Dashboard/Content/PostEditorDialog.stx +176 -0
  86. package/resources/components/Dashboard/Content/PostsDashboard.stx +40 -64
  87. package/resources/components/Dashboard/Content/SeoDashboard.stx +18 -84
  88. package/resources/components/Dashboard/Content/SeoEditorDialog.stx +108 -0
  89. package/resources/components/Dashboard/Content/TaxonomyEditorDialog.stx +106 -0
  90. package/resources/components/Dashboard/CreateRecordModal.stx +72 -83
  91. package/resources/components/Dashboard/Email/InboxMessageList.stx +1 -1
  92. package/resources/components/Dashboard/GlobalSearch.stx +3 -9
  93. package/resources/components/Dashboard/Infrastructure/CloudDetailsDialog.stx +2 -16
  94. package/resources/components/Dashboard/Infrastructure/DnsRecordDialog.stx +4 -16
  95. package/resources/components/Dashboard/Infrastructure/LogDetailsDialog.stx +4 -19
  96. package/resources/components/Dashboard/Infrastructure/MailboxDetailsDialog.stx +6 -16
  97. package/resources/components/Dashboard/Infrastructure/ServerInspectorDialog.stx +105 -0
  98. package/resources/components/Dashboard/Infrastructure/ServersDashboard.stx +5 -59
  99. package/resources/components/Dashboard/Jobs/JobHistory.stx +1 -11
  100. package/resources/components/Dashboard/Library/ComponentsDashboard.stx +11 -60
  101. package/resources/components/Dashboard/Library/FunctionsDashboard.stx +11 -15
  102. package/resources/components/Dashboard/Library/SourceScaffoldDialog.stx +95 -0
  103. package/resources/components/Dashboard/Management/AssignmentDrawer.stx +93 -0
  104. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +47 -129
  105. package/resources/components/Dashboard/Marketing/CampaignDeliveryDialog.stx +5 -8
  106. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +17 -17
  107. package/resources/components/Dashboard/Marketing/MarketingListDialog.stx +17 -17
  108. package/resources/components/Dashboard/Marketing/MarketingListsTable.stx +1 -1
  109. package/resources/components/Dashboard/Marketing/SocialPostDialog.stx +17 -16
  110. package/resources/components/Dashboard/Models/EditRecordModal.stx +81 -0
  111. package/resources/components/Dashboard/Models/ModelRecordActions.stx +30 -0
  112. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +42 -94
  113. package/resources/components/Dashboard/Navbar.stx +5 -5
  114. package/resources/components/Dashboard/Notifications/NotificationDeliveryDialog.stx +40 -60
  115. package/resources/components/Dashboard/UI/Button.stx +4 -0
  116. package/resources/components/Dashboard/UI/ConfirmDialog.stx +4 -0
  117. package/resources/components/Dashboard/UI/Drawer.stx +114 -0
  118. package/resources/components/Dashboard/UI/Modal.stx +19 -12
  119. package/resources/components/Docs/Demo/ComboboxDemo.stx +4 -5
  120. package/resources/components/Docs/Demo/DropdownDemo.stx +12 -5
  121. package/resources/components/Docs/Demo/ListboxDemo.stx +14 -6
  122. package/resources/components/Docs/Demo/PopoverDemo.stx +10 -5
  123. package/resources/components/Docs/Demo/RadioGroupDemo.stx +14 -6
  124. package/resources/components/Docs/Demo/StepperDemo.stx +4 -5
  125. package/resources/components/Docs/TeamMember.stx +16 -9
  126. package/resources/components/Forum/ForumReplyForm.stx +7 -7
  127. package/resources/components/Forum/ForumSidebar.stx +10 -6
  128. package/resources/components/Marketing/Faqs.stx +5 -5
  129. package/resources/components/Marketing/Feature.stx +11 -6
  130. package/resources/components/Marketing/Layout.stx +12 -8
  131. package/resources/components/Marketing/Pricing.stx +11 -5
  132. package/resources/components/Marketing/PrimaryFeatures.stx +4 -5
  133. package/resources/components/Marketing/PrimaryFeatures2.stx +6 -5
  134. package/resources/components/Marketing/SecondaryFeatures.stx +10 -5
  135. package/resources/components/Marketing/Testimonials.stx +8 -5
  136. package/resources/views/index.stx +4 -4
  137. package/views/system-tray/index.stx +1 -1
@@ -175,17 +175,13 @@
175
175
 
176
176
  const selectedUser = state<UserRow | null>(null)
177
177
  const selectedUserGuard = state('web')
178
- const userRolesDraft = state([])
179
178
  const savingUserRoles = state(false)
180
179
 
181
180
  async function loadSelectedUserRoles() {
182
181
  const user = selectedUser()
183
182
  if (!user) return
184
183
  const guardName = selectedUserGuard()
185
- userRolesDraft.set([])
186
184
  await rbac.loadUserRoles(user.id, guardName)
187
- if (selectedUser()?.id === user.id && selectedUserGuard() === guardName)
188
- userRolesDraft.set([...rbac.userRoleNames(user.id, guardName)])
189
185
  }
190
186
 
191
187
  function selectUser(user: UserRow) {
@@ -199,21 +195,12 @@
199
195
  loadSelectedUserRoles()
200
196
  }
201
197
 
202
- function toggleUserRoleDraft(roleName) {
203
- const list = userRolesDraft()
204
- userRolesDraft.set(
205
- list.includes(roleName)
206
- ? list.filter(n => n !== roleName)
207
- : [...list, roleName],
208
- )
209
- }
210
-
211
- async function saveUserRoles() {
198
+ async function saveUserRoles(roleNames: string[]) {
212
199
  const user = selectedUser()
213
200
  if (!user) return
214
201
  savingUserRoles.set(true)
215
202
  try {
216
- await rbac.syncUserRoles(user.id, userRolesDraft(), selectedUserGuard())
203
+ await rbac.syncUserRoles(user.id, roleNames, selectedUserGuard())
217
204
  }
218
205
  finally {
219
206
  savingUserRoles.set(false)
@@ -222,14 +209,17 @@
222
209
 
223
210
  function closeUserDrawer() {
224
211
  selectedUser.set(null)
225
- userRolesDraft.set([])
226
- }
227
-
228
- function isRoleInDraft(roleName) {
229
- return userRolesDraft().includes(roleName)
230
212
  }
231
213
 
232
214
  const rolesForSelectedGuard = derived(() => roles().filter(role => role.guardName === selectedUserGuard()))
215
+ const selectedUserRoles = derived(() => {
216
+ const user = selectedUser()
217
+ return user ? rbac.userRoleNames(user.id, selectedUserGuard()) : []
218
+ })
219
+ const selectedUserName = derived(() => {
220
+ const user = selectedUser()
221
+ return user?.name || user?.email || (user ? `User #${user.id}` : '')
222
+ })
233
223
  const userRolesLoading = derived(() => {
234
224
  const user = selectedUser()
235
225
  return user ? Boolean(loadingPivots()[`user:${user.id}:${encodeURIComponent(selectedUserGuard())}`]) : false
@@ -238,31 +228,19 @@
238
228
  // ─── Role-permission matrix ───────────────────────────────────
239
229
 
240
230
  const selectedRole = state<RoleRow | null>(null)
241
- const rolePermsDraft = state([])
242
231
  const savingRolePerms = state(false)
243
232
 
244
233
  async function selectRole(role: RoleRow) {
245
234
  selectedRole.set(role)
246
- rolePermsDraft.set([])
247
235
  await rbac.loadRolePermissions(role.name, role.guardName)
248
- rolePermsDraft.set([...rbac.rolePermissionNames(role.name, role.guardName)])
249
236
  }
250
237
 
251
- function toggleRolePermDraft(permName) {
252
- const list = rolePermsDraft()
253
- rolePermsDraft.set(
254
- list.includes(permName)
255
- ? list.filter(n => n !== permName)
256
- : [...list, permName],
257
- )
258
- }
259
-
260
- async function saveRolePerms() {
238
+ async function saveRolePerms(permissionNames: string[]) {
261
239
  const role = selectedRole()
262
240
  if (!role) return
263
241
  savingRolePerms.set(true)
264
242
  try {
265
- await rbac.syncRolePermissions(role.name, rolePermsDraft(), role.guardName)
243
+ await rbac.syncRolePermissions(role.name, permissionNames, role.guardName)
266
244
  }
267
245
  finally {
268
246
  savingRolePerms.set(false)
@@ -271,17 +249,20 @@
271
249
 
272
250
  function closeRoleDrawer() {
273
251
  selectedRole.set(null)
274
- rolePermsDraft.set([])
275
- }
276
-
277
- function isPermInDraft(permName) {
278
- return rolePermsDraft().includes(permName)
279
252
  }
280
253
 
281
254
  const permissionsForSelectedRole = derived(() => {
282
255
  const role = selectedRole()
283
256
  return role ? permissions().filter(permission => permission.guardName === role.guardName) : []
284
257
  })
258
+ const selectedRolePermissions = derived(() => {
259
+ const role = selectedRole()
260
+ return role ? rbac.rolePermissionNames(role.name, role.guardName) : []
261
+ })
262
+ const selectedRoleDescription = derived(() => {
263
+ const role = selectedRole()
264
+ return role ? `Guard: ${role.guardName}` : ''
265
+ })
285
266
 
286
267
  const rolePermissionsLoading = derived(() => {
287
268
  const role = selectedRole()
@@ -332,17 +313,6 @@
332
313
  }
333
314
  }
334
315
 
335
- function handleKeydown(event: KeyboardEvent) {
336
- if (event.key !== 'Escape' || pendingDelete())
337
- return
338
- if (selectedRole())
339
- closeRoleDrawer()
340
- else if (selectedUser())
341
- closeUserDrawer()
342
- }
343
-
344
- useEventListener('keydown', handleKeydown)
345
-
346
316
  // ─── Tiny formatters ──────────────────────────────────────────
347
317
 
348
318
  function formatDate(iso) {
@@ -545,86 +515,34 @@
545
515
  </div>
546
516
  </div>
547
517
 
548
- <!-- ─── User-role drawer (right-side panel) ───────────────── -->
549
- <div :if="selectedUser()" class="fixed inset-0 z-30 dashboard-modal-layer">
550
- <button type="button" aria-label="Close role assignment drawer" class="fixed inset-0 w-full bg-black/50 cursor-default" @click="closeUserDrawer()"></button>
551
- <aside role="dialog" aria-modal="true" aria-label="User role assignments" class="flex fixed inset-y-0 right-0 flex-col w-full sm:w-[28rem] bg-white dark:bg-neutral-900 shadow-xl">
552
- <div class="flex items-center justify-between px-5 py-3 border-b border-zinc-200 dark:border-zinc-800">
553
- <h3 class="font-medium text-sm text-zinc-900 dark:text-zinc-100">
554
- Roles for <span :text="selectedUser().name || selectedUser().email || ('User #' + selectedUser().id)"></span>
555
- </h3>
556
- <Button variant="ghost" size="sm" iconOnly ariaLabel="Close role assignment drawer" @click="closeUserDrawer()">
557
- <span class="block h-4 w-4 i-hugeicons-cancel-01"></span>
558
- </Button>
559
- </div>
560
- <div class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-800">
561
- <label for="user-role-guard" class="block mb-1.5 font-medium text-xs text-zinc-600 dark:text-zinc-400">Guard</label>
562
- <select id="user-role-guard" x-model="selectedUserGuard" @change="changeUserGuard(selectedUserGuard())" class="px-3 py-1.5 w-full text-sm text-zinc-900 dark:text-zinc-100 bg-white dark:bg-neutral-800 border border-zinc-300 rounded-md dark:border-zinc-700 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500/20">
563
- <template :for="option in guardOptions">
564
- <option :value="option.value">{{ option.label }}</option>
565
- </template>
566
- </select>
567
- </div>
568
- <div class="overflow-y-auto flex-1 px-5 py-4 space-y-2">
569
- <div :if="userRolesLoading()" class="flex gap-2 items-center py-6 text-sm text-zinc-500">
570
- <span class="h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
571
- Loading assignments…
572
- </div>
573
- <div :if="!userRolesLoading() && rolesForSelectedGuard().length === 0" class="text-sm text-zinc-500">No roles are defined for this guard.</div>
574
- <template :for="role in rolesForSelectedGuard">
575
- <label class="flex gap-2 items-center px-2 py-1.5 hover:bg-zinc-50 dark:hover:bg-neutral-800 rounded-md cursor-pointer">
576
- <input type="checkbox" :checked="isRoleInDraft(role.name)" @change="toggleUserRoleDraft(role.name)" class="h-4 w-4 text-violet-600 border-zinc-300 rounded dark:border-zinc-700 focus:ring-violet-500" />
577
- <span class="text-sm text-zinc-900 dark:text-zinc-100">{{ role.name }}</span>
578
- <span class="text-xs text-zinc-500">{{ role.description || '' }}</span>
579
- </label>
580
- </template>
581
- </div>
582
- <div class="flex gap-2 items-center justify-end px-5 py-3 border-t border-zinc-200 dark:border-zinc-800">
583
- <Button variant="secondary" size="sm" :disabled="savingUserRoles()" @click="closeUserDrawer()">Cancel</Button>
584
- <Button size="sm" :disabled="userRolesLoading()" :loading="savingUserRoles()" @click="saveUserRoles()">
585
- Save
586
- </Button>
587
- </div>
588
- </aside>
589
- </div>
518
+ <AssignmentDrawer
519
+ :open="Boolean(selectedUser())"
520
+ title="Assign roles"
521
+ :description="selectedUserName()"
522
+ :options="rolesForSelectedGuard()"
523
+ :selected="selectedUserRoles()"
524
+ :guard="selectedUserGuard()"
525
+ :guardOptions="guardOptions()"
526
+ :loading="userRolesLoading()"
527
+ :busy="savingUserRoles()"
528
+ emptyMessage="No roles are defined for this guard."
529
+ @guard-change="changeUserGuard"
530
+ @close="closeUserDrawer"
531
+ @save="saveUserRoles"
532
+ />
590
533
 
591
- <!-- ─── Role-permission drawer ────────────────────────────── -->
592
- <div :if="selectedRole()" class="fixed inset-0 z-30 dashboard-modal-layer">
593
- <button type="button" aria-label="Close permission assignment drawer" class="fixed inset-0 w-full bg-black/50 cursor-default" @click="closeRoleDrawer()"></button>
594
- <aside role="dialog" aria-modal="true" aria-label="Role permission assignments" class="flex fixed inset-y-0 right-0 flex-col w-full sm:w-[28rem] bg-white dark:bg-neutral-900 shadow-xl">
595
- <div class="flex items-center justify-between px-5 py-3 border-b border-zinc-200 dark:border-zinc-800">
596
- <h3 class="font-medium text-sm text-zinc-900 dark:text-zinc-100">
597
- Permissions for <span :text="selectedRole().name"></span>
598
- </h3>
599
- <Button variant="ghost" size="sm" iconOnly ariaLabel="Close permission assignment drawer" @click="closeRoleDrawer()">
600
- <span class="block h-4 w-4 i-hugeicons-cancel-01"></span>
601
- </Button>
602
- </div>
603
- <div class="px-5 py-3 text-xs text-zinc-500 border-b border-zinc-200 dark:border-zinc-800">
604
- Guard: <code class="text-zinc-700 dark:text-zinc-300">{{ selectedRole().guardName }}</code>
605
- </div>
606
- <div class="overflow-y-auto flex-1 px-5 py-4 space-y-2">
607
- <div :if="rolePermissionsLoading()" class="flex gap-2 items-center py-6 text-sm text-zinc-500">
608
- <span class="h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
609
- Loading assignments…
610
- </div>
611
- <div :if="!rolePermissionsLoading() && permissionsForSelectedRole().length === 0" class="text-sm text-zinc-500">No permissions are defined for this guard.</div>
612
- <template :for="perm in permissionsForSelectedRole">
613
- <label class="flex gap-2 items-center px-2 py-1.5 hover:bg-zinc-50 dark:hover:bg-neutral-800 rounded-md cursor-pointer">
614
- <input type="checkbox" :checked="isPermInDraft(perm.name)" @change="toggleRolePermDraft(perm.name)" class="h-4 w-4 text-violet-600 border-zinc-300 rounded dark:border-zinc-700 focus:ring-violet-500" />
615
- <span class="text-sm text-zinc-900 dark:text-zinc-100">{{ perm.name }}</span>
616
- <span class="text-xs text-zinc-500">{{ perm.description || '' }}</span>
617
- </label>
618
- </template>
619
- </div>
620
- <div class="flex gap-2 items-center justify-end px-5 py-3 border-t border-zinc-200 dark:border-zinc-800">
621
- <Button variant="secondary" size="sm" :disabled="savingRolePerms()" @click="closeRoleDrawer()">Cancel</Button>
622
- <Button size="sm" :disabled="rolePermissionsLoading()" :loading="savingRolePerms()" @click="saveRolePerms()">
623
- Save
624
- </Button>
625
- </div>
626
- </aside>
627
- </div>
534
+ <AssignmentDrawer
535
+ :open="Boolean(selectedRole())"
536
+ title="Assign permissions"
537
+ :description="selectedRoleDescription()"
538
+ :options="permissionsForSelectedRole()"
539
+ :selected="selectedRolePermissions()"
540
+ :loading="rolePermissionsLoading()"
541
+ :busy="savingRolePerms()"
542
+ emptyMessage="No permissions are defined for this guard."
543
+ @close="closeRoleDrawer"
544
+ @save="saveRolePerms"
545
+ />
628
546
 
629
547
  <ConfirmDialog
630
548
  :isOpen="Boolean(pendingDelete())"
@@ -66,11 +66,7 @@ function submit(): void {
66
66
  }
67
67
  </script>
68
68
 
69
- <div :if="open()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
70
- <button type="button" aria-label="Close campaign delivery dialog" class="absolute inset-0 bg-black/45" @click="emit('close')"></button>
71
- <section role="dialog" aria-modal="true" aria-labelledby="campaign-delivery-title" class="relative p-6 max-w-lg w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
72
- <h2 id="campaign-delivery-title" class="font-semibold text-gray-900 text-lg dark:text-white">{{ heading() }}</h2>
73
- <p class="mt-2 text-gray-500 text-sm dark:text-neutral-400">{{ description() }}</p>
69
+ <Modal :isOpen="open()" :title="heading()" :description="description()" size="md" @close="emit('close')">
74
70
  <div class="mt-4 p-3 bg-gray-50 dark:bg-neutral-900/50 border border-gray-200 rounded-lg dark:border-neutral-700">
75
71
  <p class="font-medium text-gray-900 text-sm dark:text-white">{{ record()?.name || '' }}</p>
76
72
  <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record()?.emailListName || 'Not assigned' }} · {{ record()?.audienceSize.toLocaleString() || '0' }} active recipients</p>
@@ -81,7 +77,8 @@ function submit(): void {
81
77
  <span class="block mt-1 text-gray-500 text-xs dark:text-neutral-400">Uses your browser’s local time.</span>
82
78
  </label>
83
79
  <div :if="error()" role="alert" class="mt-4 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
84
- <div class="flex gap-2 justify-end mt-6">
80
+ <template #footer>
81
+ <div class="flex gap-2 justify-end w-full">
85
82
  <Button :disabled="busy()" variant="secondary" @click="emit('close')">Close</Button>
86
83
  <template :if="mode() === 'cancel'">
87
84
  <Button :disabled="busy() || (mode() === 'schedule' && !scheduledAt())" :loading="busy()" variant="danger" @click="submit()">{{ confirmLabel() }}</Button>
@@ -90,5 +87,5 @@ function submit(): void {
90
87
  <Button :disabled="busy() || (mode() === 'schedule' && !scheduledAt())" :loading="busy()" @click="submit()">{{ confirmLabel() }}</Button>
91
88
  </template>
92
89
  </div>
93
- </section>
94
- </div>
90
+ </template>
91
+ </Modal>
@@ -106,17 +106,17 @@ function submit(): void {
106
106
  }
107
107
  </script>
108
108
 
109
- <div :if="open()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
110
- <button type="button" aria-label="Close campaign dialog" class="absolute inset-0 bg-black/45" @click="emit('close')"></button>
111
- <section role="dialog" aria-modal="true" aria-labelledby="campaign-dialog-title" class="overflow-y-auto relative max-h-[92dvh] max-w-3xl w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
112
- <form @submit.prevent="submit">
113
- <div class="p-6">
114
- <h2 id="campaign-dialog-title" class="font-semibold text-gray-900 text-lg dark:text-white">{{ mode() === 'edit' ? 'Edit campaign' : 'Add campaign' }}</h2>
115
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Persist campaign metadata through the Campaign model. Email delivery is started separately after review.</p>
109
+ <Modal
110
+ :isOpen="open()"
111
+ :title="mode() === 'edit' ? 'Edit campaign' : 'Add campaign'"
112
+ description="Persist campaign metadata through the Campaign model. Email delivery is started separately after review."
113
+ size="xl"
114
+ @close="emit('close')"
115
+ >
116
+ <form id="campaign-form" @submit.prevent="submit">
117
+ <div :if="error()" role="alert" class="mb-5 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
116
118
 
117
- <div :if="error()" role="alert" class="mt-4 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
118
-
119
- <div class="grid gap-4 sm:grid-cols-2 mt-5">
119
+ <div class="grid gap-4 sm:grid-cols-2">
120
120
  <label class="block sm:col-span-2">
121
121
  <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Name</span>
122
122
  <input x-model="name" type="text" minlength="3" maxlength="255" required autocomplete="off" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
@@ -213,13 +213,13 @@ function submit(): void {
213
213
  </label>
214
214
  </div>
215
215
  </div>
216
- </div>
217
- <footer class="flex gap-2 justify-end px-6 py-4 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
216
+ </form>
217
+ <template #footer>
218
+ <div class="flex gap-2 justify-end w-full">
218
219
  <Button :disabled="busy()" variant="secondary" @click="emit('close')">Cancel</Button>
219
- <Button type="submit" :disabled="busy() || !canSubmit()" :loading="busy()">
220
+ <Button type="submit" form="campaign-form" :disabled="busy() || !canSubmit()" :loading="busy()">
220
221
  {{ mode() === 'edit' ? 'Save campaign' : 'Add campaign' }}
221
222
  </Button>
222
- </footer>
223
- </form>
224
- </section>
225
- </div>
223
+ </div>
224
+ </template>
225
+ </Modal>
@@ -69,17 +69,17 @@ function submit(): void {
69
69
  }
70
70
  </script>
71
71
 
72
- <div :if="open()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
73
- <button type="button" aria-label="Close email list dialog" class="absolute inset-0 bg-black/45" @click="emit('close')"></button>
74
- <section role="dialog" aria-modal="true" aria-labelledby="marketing-list-dialog-title" class="relative max-w-xl w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
75
- <form @submit.prevent="submit">
76
- <div class="p-6">
77
- <h2 id="marketing-list-dialog-title" class="font-semibold text-gray-900 text-lg dark:text-white">{{ mode() === 'edit' ? 'Edit email list' : 'Add email list' }}</h2>
78
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Settings persist through the EmailList model and its generated API contract.</p>
72
+ <Modal
73
+ :isOpen="open()"
74
+ :title="mode() === 'edit' ? 'Edit email list' : 'Add email list'"
75
+ description="Settings persist through the EmailList model and its generated API contract."
76
+ size="lg"
77
+ @close="emit('close')"
78
+ >
79
+ <form id="marketing-list-form" @submit.prevent="submit">
80
+ <div :if="error()" role="alert" class="mb-5 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
79
81
 
80
- <div :if="error()" role="alert" class="mt-4 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
81
-
82
- <div class="mt-5 space-y-4">
82
+ <div class="space-y-4">
83
83
  <label class="block">
84
84
  <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Name</span>
85
85
  <input x-model="name" type="text" maxlength="100" required autocomplete="off" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
@@ -117,13 +117,13 @@ function submit(): void {
117
117
  </label>
118
118
  </div>
119
119
  </div>
120
- </div>
121
- <footer class="flex gap-2 justify-end px-6 py-4 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
120
+ </form>
121
+ <template #footer>
122
+ <div class="flex gap-2 justify-end w-full">
122
123
  <Button :disabled="busy()" variant="secondary" @click="emit('close')">Cancel</Button>
123
- <Button type="submit" :disabled="busy() || !name().trim() || !slug().trim()" :loading="busy()">
124
+ <Button type="submit" form="marketing-list-form" :disabled="busy() || !name().trim() || !slug().trim()" :loading="busy()">
124
125
  {{ mode() === 'edit' ? 'Save email list' : 'Add email list' }}
125
126
  </Button>
126
- </footer>
127
- </form>
128
- </section>
129
- </div>
127
+ </div>
128
+ </template>
129
+ </Modal>
@@ -52,7 +52,7 @@ function statusClass(status: string): string {
52
52
  <td class="px-4 py-4">
53
53
  <div class="flex gap-2 items-center">
54
54
  <p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.name }}</p>
55
- <span :if="record.countDrift" title="Stored counters differ from membership rows" class="h-4 w-4 text-amber-500 i-hugeicons-alert-02"></span>
55
+ <span :if="record.countDrift" x-tooltip="Stored counters differ from membership rows" title="Stored counters differ from membership rows" class="h-4 w-4 text-amber-500 i-hugeicons-alert-02"></span>
56
56
  </div>
57
57
  <p class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ record.slug }}</p>
58
58
  <p :if="record.description" class="mt-1 max-w-sm text-gray-500 text-xs dark:text-neutral-400">{{ record.description }}</p>
@@ -52,16 +52,17 @@ function submit(): void {
52
52
  }
53
53
  </script>
54
54
 
55
- <div :if="open()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
56
- <button type="button" aria-label="Close social post dialog" class="absolute inset-0 bg-black/45" @click="emit('close')"></button>
57
- <section role="dialog" aria-modal="true" aria-labelledby="social-post-dialog-title" class="relative max-w-2xl w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
58
- <form @submit.prevent="submit">
59
- <div class="p-6">
60
- <h2 id="social-post-dialog-title" class="font-semibold text-gray-900 text-lg dark:text-white">{{ mode() === 'edit' ? 'Edit social post' : 'Add social post' }}</h2>
61
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Create or schedule a persisted SocialPost record. Platform delivery remains the responsibility of the configured publisher.</p>
62
- <div :if="error()" role="alert" class="mt-4 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
55
+ <Modal
56
+ :isOpen="open()"
57
+ :title="mode() === 'edit' ? 'Edit social post' : 'Add social post'"
58
+ description="Create or schedule a persisted SocialPost record. Platform delivery remains the responsibility of the configured publisher."
59
+ size="lg"
60
+ @close="emit('close')"
61
+ >
62
+ <form id="social-post-form" @submit.prevent="submit">
63
+ <div :if="error()" role="alert" class="mb-5 p-3 text-red-700 text-sm dark:text-red-300 bg-red-50 dark:bg-red-950/30 border border-red-200 rounded-lg dark:border-red-900">{{ error() }}</div>
63
64
 
64
- <div class="mt-5 space-y-4">
65
+ <div class="space-y-4">
65
66
  <label class="block">
66
67
  <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Content</span>
67
68
  <textarea x-model="content" rows="5" maxlength="2000" required class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700"></textarea>
@@ -109,13 +110,13 @@ function submit(): void {
109
110
  <input x-model="imageUrl" type="url" maxlength="2000" placeholder="https://example.com/image.jpg" class="mt-1 px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" />
110
111
  </label>
111
112
  </div>
112
- </div>
113
- <footer class="flex gap-2 justify-end px-6 py-4 bg-gray-50 dark:bg-neutral-900/50 border-gray-200 border-t dark:border-neutral-700">
113
+ </form>
114
+ <template #footer>
115
+ <div class="flex gap-2 justify-end w-full">
114
116
  <Button :disabled="busy()" variant="secondary" @click="emit('close')">Cancel</Button>
115
- <Button type="submit" :disabled="busy() || !content().trim() || (status() === 'scheduled' && !scheduledAt())" :loading="busy()">
117
+ <Button type="submit" form="social-post-form" :disabled="busy() || !content().trim() || (status() === 'scheduled' && !scheduledAt())" :loading="busy()">
116
118
  {{ mode() === 'edit' ? 'Save social post' : 'Add social post' }}
117
119
  </Button>
118
- </footer>
119
- </form>
120
- </section>
121
- </div>
120
+ </div>
121
+ </template>
122
+ </Modal>
@@ -0,0 +1,81 @@
1
+ <script client>
2
+ interface EditField {
3
+ name: string
4
+ label: string
5
+ value: string
6
+ readonly: boolean
7
+ }
8
+
9
+ const open = useReactiveProp('open', false)
10
+ const modelName = useReactiveProp('modelName', 'Record')
11
+ const fields = useReactiveProp('fields', [] as EditField[])
12
+ const busy = useReactiveProp('busy', false)
13
+ const values = state<Record<string, string>>({})
14
+ const emit = defineEmits()
15
+ let hydrationKey = ''
16
+
17
+ effect(() => {
18
+ if (!open()) {
19
+ hydrationKey = ''
20
+ return
21
+ }
22
+
23
+ const key = fields().map(field => `${field.name}:${field.value}`).join('|')
24
+ if (key === hydrationKey)
25
+ return
26
+
27
+ hydrationKey = key
28
+ values.set(Object.fromEntries(fields().map(field => [field.name, field.value])))
29
+ })
30
+
31
+ function updateValue(field: string, event: Event): void {
32
+ values.set({ ...values(), [field]: (event.target as HTMLInputElement).value })
33
+ }
34
+
35
+ function submit(): void {
36
+ emit('submit', Object.fromEntries(
37
+ fields()
38
+ .filter(field => !field.readonly)
39
+ .map(field => [field.name, values()[field.name]]),
40
+ ))
41
+ }
42
+
43
+ function close(): void {
44
+ if (!busy())
45
+ emit('close')
46
+ }
47
+ </script>
48
+
49
+ <Modal
50
+ :isOpen="open()"
51
+ :title="'Edit ' + modelName()"
52
+ description="Only changed writable fields will be sent through the model API."
53
+ :closable="!busy()"
54
+ @close="close"
55
+ >
56
+ <form id="model-edit-form" class="overflow-y-auto space-y-3 max-h-[65vh]" @submit.prevent="submit">
57
+ <template :for="field in fields()">
58
+ <div class="flex flex-col gap-1.5">
59
+ <label :for="'model-edit-' + field.name" class="font-medium text-[12px] text-neutral-600 dark:text-neutral-300">{{ field.label }}</label>
60
+ <input
61
+ :id="'model-edit-' + field.name"
62
+ :name="field.readonly ? undefined : field.name"
63
+ type="text"
64
+ :value="values()[field.name] ?? field.value"
65
+ :readonly="field.readonly"
66
+ class="px-2.5 py-1.5 text-[13px] text-neutral-900 dark:text-neutral-100 read-only:text-neutral-500 dark:read-only:text-neutral-400 bg-white dark:bg-white/10 read-only:bg-neutral-50 dark:read-only:bg-white/5 border border-black/10 rounded-lg dark:border-white/15 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
67
+ @input="updateValue(field.name, $event)"
68
+ >
69
+ </div>
70
+ </template>
71
+ </form>
72
+
73
+ <template #footer>
74
+ <div class="flex gap-2 items-center justify-end w-full">
75
+ <Button :disabled="busy()" variant="secondary" @click="close">Cancel</Button>
76
+ <Button type="submit" form="model-edit-form" :loading="busy()">
77
+ {{ busy() ? 'Saving' : 'Save changes' }}
78
+ </Button>
79
+ </div>
80
+ </template>
81
+ </Modal>
@@ -0,0 +1,30 @@
1
+ <script client>
2
+ const record = useReactiveProp('record', {} as Record<string, unknown>)
3
+ const modelName = useReactiveProp('modelName', 'Record')
4
+ const canUpdate = useReactiveProp('canUpdate', false)
5
+ const canDestroy = useReactiveProp('canDestroy', false)
6
+ const emit = defineEmits()
7
+ </script>
8
+
9
+ <div class="flex gap-1 items-center justify-end">
10
+ <Button
11
+ :if="canUpdate()"
12
+ variant="ghost"
13
+ size="sm"
14
+ iconOnly
15
+ :ariaLabel="'Edit ' + modelName() + ' ' + record().id"
16
+ @click="emit('edit', record())"
17
+ >
18
+ <span class="h-4 w-4 i-hugeicons-edit-02"></span>
19
+ </Button>
20
+ <Button
21
+ :if="canDestroy()"
22
+ variant="ghost"
23
+ size="sm"
24
+ iconOnly
25
+ :ariaLabel="'Delete ' + modelName() + ' ' + record().id"
26
+ @click="emit('delete', record())"
27
+ >
28
+ <span class="h-4 w-4 text-red-500 i-hugeicons-delete-02"></span>
29
+ </Button>
30
+ </div>