@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
@@ -12,6 +12,13 @@ interface AuthorRecord {
12
12
  createdAt: string
13
13
  }
14
14
 
15
+ interface AuthorDraft {
16
+ name: string
17
+ email: string
18
+ bio: string
19
+ avatar: string
20
+ }
21
+
15
22
  const authors = state<AuthorRecord[]>([])
16
23
  const assignedPostCount = state(0)
17
24
  const loading = state(true)
@@ -19,10 +26,6 @@ const loadError = state('')
19
26
  const search = state('')
20
27
  const formOpen = state(false)
21
28
  const editingAuthor = state<AuthorRecord | null>(null)
22
- const name = state('')
23
- const email = state('')
24
- const bio = state('')
25
- const avatar = state('')
26
29
  const saving = state(false)
27
30
  const saveError = state('')
28
31
  const deletingAuthor = state<AuthorRecord | null>(null)
@@ -80,22 +83,15 @@ async function loadAuthors(): Promise<void> {
80
83
  }
81
84
  }
82
85
 
83
- function resetForm(author: AuthorRecord | null): void {
84
- editingAuthor.set(author)
85
- name.set(author?.name || '')
86
- email.set(author?.email || '')
87
- bio.set(author?.bio || '')
88
- avatar.set(author?.avatar || '')
89
- saveError.set('')
90
- }
91
-
92
86
  function openCreate(): void {
93
- resetForm(null)
87
+ editingAuthor.set(null)
88
+ saveError.set('')
94
89
  formOpen.set(true)
95
90
  }
96
91
 
97
92
  function openEdit(author: AuthorRecord): void {
98
- resetForm(author)
93
+ editingAuthor.set(author)
94
+ saveError.set('')
99
95
  formOpen.set(true)
100
96
  }
101
97
 
@@ -104,14 +100,14 @@ function closeForm(): void {
104
100
  formOpen.set(false)
105
101
  }
106
102
 
107
- async function saveAuthor(): Promise<void> {
103
+ async function saveAuthor(draft: AuthorDraft): Promise<void> {
108
104
  const author = editingAuthor()
109
105
  saving.set(true)
110
106
  saveError.set('')
111
107
  try {
112
108
  await dashboardApi(author ? `/api/dashboard/authors/${author.id}` : '/api/dashboard/authors', {
113
109
  method: author ? 'PATCH' : 'POST',
114
- body: { name: name().trim(), email: email().trim(), bio: bio().trim(), avatar: avatar().trim() },
110
+ body: draft,
115
111
  })
116
112
  formOpen.set(false)
117
113
  pushToast('success', author ? 'Author updated' : 'Author created')
@@ -135,6 +131,13 @@ function closeDelete(): void {
135
131
  deletingAuthor.set(null)
136
132
  }
137
133
 
134
+ function deleteDescription(): string {
135
+ const author = deletingAuthor()
136
+ return author
137
+ ? `${author.name} will be removed. Their posts will be kept without an assigned author.`
138
+ : ''
139
+ }
140
+
138
141
  async function deleteAuthor(): Promise<void> {
139
142
  const author = deletingAuthor()
140
143
  if (!author)
@@ -191,17 +194,22 @@ onMount(() => {
191
194
  </section>
192
195
  </div>
193
196
 
194
- <div :if="formOpen()" class="flex fixed inset-y-0 right-0 z-[55] items-center justify-center p-4 dashboard-modal-layer">
195
- <button type="button" aria-label="Close author dialog" class="absolute inset-0 bg-black/45" @click="closeForm()"></button>
196
- <section role="dialog" aria-modal="true" :aria-label="editingAuthor() ? 'Edit author' : 'New author'" class="relative max-w-lg w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl">
197
- <form @submit.prevent="saveAuthor">
198
- <div class="p-6"><h2 class="font-semibold text-gray-900 text-lg dark:text-white">{{ editingAuthor() ? 'Edit author' : 'New author' }}</h2><div :if="saveError()" 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">{{ saveError() }}</div><div class="grid gap-4 mt-5"><label><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Name</span><input x-model="name" required minlength="5" maxlength="255" 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" /></label><label><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Email</span><input x-model="email" type="email" 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" /></label><label><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Bio</span><textarea x-model="bio" maxlength="500" rows="3" 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></label><label><span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Avatar URL</span><input x-model="avatar" type="url" placeholder="https://example.com/avatar.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" /></label></div></div>
199
- <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"><Button :disabled="saving()" variant="secondary" @click="closeForm()">Cancel</Button><Button type="submit" :disabled="saving() || name().trim().length < 5 || !email().trim()" :loading="saving()">{{ editingAuthor() ? 'Save changes' : 'Create author' }}</Button></footer>
200
- </form>
201
- </section>
202
- </div>
203
-
204
- <div :if="Boolean(deletingAuthor())" class="flex fixed inset-y-0 right-0 z-[55] items-center justify-center p-4 dashboard-modal-layer">
205
- <button type="button" aria-label="Close delete author dialog" class="absolute inset-0 bg-black/45" @click="closeDelete()"></button>
206
- <section role="dialog" aria-modal="true" aria-label="Delete author?" class="relative p-6 max-w-md w-full bg-white dark:bg-neutral-800 border border-gray-200 rounded-xl dark:border-neutral-700 shadow-xl"><h2 class="font-semibold text-gray-900 text-lg dark:text-white">Delete author?</h2><p class="mt-2 text-gray-500 text-sm dark:text-neutral-400"><strong>{{ deletingAuthor()?.name || '' }}</strong> will be removed. Their posts will be kept without an assigned author.</p><div :if="deleteError()" 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">{{ deleteError() }}</div><div class="flex gap-2 justify-end mt-6"><Button :disabled="deleting()" variant="secondary" @click="closeDelete()">Cancel</Button><Button :loading="deleting()" variant="danger" @click="deleteAuthor()">Delete author</Button></div></section>
207
- </div>
197
+ <AuthorEditorDialog
198
+ :open="formOpen()"
199
+ :author="editingAuthor()"
200
+ :busy="saving()"
201
+ :error="saveError()"
202
+ @close="closeForm"
203
+ @submit="saveAuthor"
204
+ />
205
+
206
+ <ConfirmDialog
207
+ :isOpen="Boolean(deletingAuthor())"
208
+ title="Delete author?"
209
+ :description="deleteDescription()"
210
+ confirmLabel="Delete author"
211
+ :busy="deleting()"
212
+ :error="deleteError()"
213
+ @close="closeDelete"
214
+ @confirm="deleteAuthor"
215
+ />
@@ -168,101 +168,31 @@ export const blogUrl = publicApplicationUrl('/blog')
168
168
  </section>
169
169
  </div>
170
170
 
171
- <!-- Editor -->
172
- <div x-show="isEditorOpen()" class="flex fixed inset-0 overflow-y-auto z-40 items-start justify-center p-4 sm:p-6 dashboard-modal-layer">
173
- <div class="fixed inset-0 bg-neutral-950/60" @click="closeEditor()"></div>
174
-
175
- <div role="dialog" aria-modal="true" aria-labelledby="editor-title" class="relative z-10 my-8 max-w-3xl w-full bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl">
176
- <div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200 dark:border-neutral-800">
177
- <h2 id="editor-title" class="font-semibold text-lg">{{ editingSlug() ? 'Edit post' : 'New post' }}</h2>
178
- <Button variant="ghost" size="sm" iconOnly ariaLabel="Close editor" @click="closeEditor()">
179
- <span class="block h-4 w-4 i-hugeicons-cancel-01"></span>
180
- </Button>
181
- </div>
182
-
183
- <div class="overflow-y-auto p-5 space-y-4 max-h-[70vh]">
184
- <div x-show="formError()" class="flex gap-2 items-start px-3 py-2 text-red-800 text-sm dark:text-red-200 bg-red-50 dark:bg-red-950/40 border border-red-200 rounded-md dark:border-red-900">
185
- <span class="shrink-0 mt-0.5 h-4 w-4 i-hugeicons-alert-02"></span>
186
- <span>{{ formError() }}</span>
187
- </div>
188
-
189
- <div class="grid gap-4 sm:grid-cols-2">
190
- <div class="flex flex-col gap-2 sm:col-span-2">
191
- <label for="post-title" class="font-medium text-sm">Title</label>
192
- <input id="post-title" type="text" x-model="formTitle" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
193
- </div>
194
-
195
- <div class="flex flex-col gap-2 sm:col-span-2">
196
- <label for="post-slug" class="font-medium text-sm">Slug</label>
197
- <input id="post-slug" type="text" x-model="formSlug" :placeholder="suggestedSlug()" class="placeholder-neutral-500 px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
198
- <p class="text-neutral-500 text-xs dark:text-neutral-400">The file name and public URL: /blog/{{ formSlug() || suggestedSlug() || 'your-post' }}</p>
199
- </div>
200
-
201
- <div class="flex flex-col gap-2 sm:col-span-2">
202
- <label for="post-description" class="font-medium text-sm">Description</label>
203
- <input id="post-description" type="text" x-model="formDescription" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
204
- <p class="text-neutral-500 text-xs dark:text-neutral-400">Used as the excerpt, the meta description, and the RSS summary.</p>
205
- </div>
206
-
207
- <div class="flex flex-col gap-2">
208
- <label for="post-date" class="font-medium text-sm">Date</label>
209
- <input id="post-date" type="date" x-model="formDate" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
210
- </div>
211
-
212
- <div class="flex flex-col gap-2">
213
- <label for="post-author" class="font-medium text-sm">Author</label>
214
- <input id="post-author" type="text" x-model="formAuthor" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
215
- </div>
216
-
217
- <div class="flex flex-col gap-2 sm:col-span-2">
218
- <label for="post-poster" class="font-medium text-sm">Poster image</label>
219
- <input id="post-poster" type="text" x-model="formPoster" placeholder="/assets/images/example.svg" class="placeholder-neutral-500 px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
220
- </div>
221
-
222
- <div class="flex flex-col gap-2 sm:col-span-2">
223
- <label for="post-body" class="font-medium text-sm">Content</label>
224
- <textarea id="post-body" x-model="formBody" rows="14" class="px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 resize-y"></textarea>
225
- <p class="text-neutral-500 text-xs dark:text-neutral-400">Markdown. {{ bodyWordCount() }} words, about {{ bodyReadingTime() }} min read.</p>
226
- </div>
227
- </div>
228
-
229
- <div class="flex flex-col gap-3 sm:flex-row sm:gap-6 pt-2 border-neutral-200 border-t dark:border-neutral-800">
230
- <label class="flex gap-2 items-center text-sm">
231
- <input type="checkbox" x-model="formDraft" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
232
- <span>Draft, keep off the public blog</span>
233
- </label>
234
- <label class="flex gap-2 items-center text-sm">
235
- <input type="checkbox" x-model="formFeatured" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
236
- <span>Feature at the top of the listing</span>
237
- </label>
238
- </div>
239
- </div>
240
-
241
- <div class="flex gap-2 justify-end px-5 py-4 border-neutral-200 border-t dark:border-neutral-800">
242
- <Button variant="secondary" :disabled="isSaving()" @click="closeEditor()">Cancel</Button>
243
- <Button :loading="isSaving()" @click="submit()">
244
- {{ isSaving() ? 'Saving' : 'Save post' }}
245
- </Button>
246
- </div>
247
- </div>
248
- </div>
249
-
250
- <!-- Delete confirmation -->
251
- <div x-show="postToDelete()" class="flex fixed inset-0 z-50 items-center justify-center p-4 dashboard-modal-layer">
252
- <div class="fixed inset-0 bg-neutral-950/60" @click="cancelDelete()"></div>
253
-
254
- <div role="dialog" aria-modal="true" aria-labelledby="delete-title" class="relative z-10 p-5 max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl">
255
- <h2 id="delete-title" class="font-semibold text-lg">Delete this post?</h2>
256
- <p class="mt-2 text-neutral-600 text-sm dark:text-neutral-300">
257
- This deletes the file <code class="px-1 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-800 rounded">content/blog/{{ deleteTargetSlug() }}.md</code>. It stays in git history until you commit the deletion.
258
- </p>
259
- <div class="flex gap-2 justify-end mt-5">
260
- <Button variant="secondary" @click="cancelDelete()">Cancel</Button>
261
- <Button variant="danger" @click="confirmDelete()">Delete post</Button>
262
- </div>
263
- </div>
264
- </div>
265
171
  </div>
172
+
173
+ <BlogPostEditorDialog
174
+ :open="isEditorOpen()"
175
+ :post="editingPost()"
176
+ :loadedBody="loadedBody()"
177
+ :bodyLoading="bodyLoading()"
178
+ :bodyLoadFailed="bodyLoadFailed()"
179
+ :busy="isSaving()"
180
+ :error="formError()"
181
+ @close="closeEditor"
182
+ @retry="retryEditorBody"
183
+ @submit="submit"
184
+ />
185
+
186
+ <ConfirmDialog
187
+ :isOpen="Boolean(postToDelete())"
188
+ title="Delete this post?"
189
+ :description="deleteDescription()"
190
+ confirmLabel="Delete post"
191
+ :busy="isDeleting()"
192
+ :error="deleteError()"
193
+ @close="cancelDelete"
194
+ @confirm="confirmDelete"
195
+ />
266
196
  </template>
267
197
 
268
198
  <script client>
@@ -282,33 +212,28 @@ const loadError = state('')
282
212
  const searchQuery = state('')
283
213
  const statusFilter = state('all')
284
214
 
285
- // Editor state. `editingSlug` doubles as the mode flag: empty means "new post",
286
- // and it holds the ORIGINAL slug so a slug edit is sent as a rename.
287
215
  const isEditorOpen = state(false)
288
- const editingSlug = state('')
216
+ const editingPost = state(null)
217
+ const loadedBody = state('')
218
+ const bodyLoading = state(false)
219
+ const bodyLoadFailed = state(false)
289
220
  const formError = state('')
290
- const formTitle = state('')
291
- const formSlug = state('')
292
- const formDescription = state('')
293
- const formDate = state('')
294
- const formAuthor = state('')
295
- const formPoster = state('')
296
- const formBody = state('')
297
- const formDraft = state(false)
298
- const formFeatured = state(false)
299
221
 
300
222
  const postToDelete = state(null)
223
+ const isDeleting = state(false)
224
+ const deleteError = state('')
225
+ let editorRequestId = 0
301
226
 
302
227
  function publicPostUrl(slug) {
303
228
  return `${publicBlogBaseUrl}/${encodeURIComponent(String(slug))}`
304
229
  }
305
230
 
306
- // Read by the delete dialog's copy; kept as a derived signal so the template
307
- // never has to reach through a possibly-null post.
308
- const deleteTargetSlug = derived(() => {
231
+ const deleteDescription = derived(() => {
309
232
  const post = postToDelete()
310
233
 
311
- return post ? post.slug : ''
234
+ return post
235
+ ? `Deletes content/blog/${post.slug}.md. The file stays in git history until you commit the deletion.`
236
+ : ''
312
237
  })
313
238
 
314
239
  const publishedCount = derived(() => posts().filter(p => !p.draft).length)
@@ -331,20 +256,6 @@ const filteredPosts = derived(() => {
331
256
  })
332
257
  })
333
258
 
334
- // Mirrors slugify() in blog-admin.ts so the placeholder matches what the server
335
- // will actually write when the slug field is left empty.
336
- function slugify(input) {
337
- return String(input)
338
- .toLowerCase()
339
- .replace(/[^a-z0-9]+/g, '-')
340
- .replace(/^-+|-+$/g, '')
341
- .slice(0, 80)
342
- }
343
-
344
- const suggestedSlug = derived(() => slugify(formTitle()))
345
- const bodyWordCount = derived(() => formBody().split(/\s+/).filter(Boolean).length)
346
- const bodyReadingTime = derived(() => Math.max(1, Math.round(bodyWordCount() / 200)))
347
-
348
259
  function statusClass(draft) {
349
260
  return draft
350
261
  ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
@@ -368,78 +279,69 @@ function clearFilters() {
368
279
  }
369
280
 
370
281
  async function openEditor(post) {
282
+ const requestId = ++editorRequestId
371
283
  formError.set('')
372
- editingSlug.set(post ? post.slug : '')
284
+ editingPost.set(post)
285
+ loadedBody.set('')
286
+ bodyLoading.set(Boolean(post))
287
+ bodyLoadFailed.set(false)
373
288
  isEditorOpen.set(true)
374
289
 
375
- if (!post) {
376
- formTitle.set('')
377
- formSlug.set('')
378
- formDescription.set('')
379
- formDate.set(new Date().toISOString().slice(0, 10))
380
- formAuthor.set('')
381
- formPoster.set('')
382
- formBody.set('')
383
- formDraft.set(true)
384
- formFeatured.set(false)
290
+ if (!post)
385
291
  return
386
- }
387
292
 
388
- // The list omits the markdown body, so fetch the full post. Seed the fields
389
- // from the list row first so the editor is never blank while that request
390
- // is in flight.
391
- formTitle.set(post.title)
392
- formSlug.set(post.slug)
393
- formDescription.set(post.description)
394
- formDate.set(post.date)
395
- formAuthor.set(post.author)
396
- formPoster.set(post.poster)
397
- formDraft.set(post.draft)
398
- formFeatured.set(post.featured)
399
- formBody.set('')
293
+ await loadEditorBody(post, requestId)
294
+ }
295
+
296
+ async function loadEditorBody(post, requestId = ++editorRequestId) {
297
+ bodyLoading.set(true)
298
+ bodyLoadFailed.set(false)
299
+ formError.set('')
400
300
 
401
301
  try {
402
302
  const full = await fetchBlogPost(post.slug)
403
303
 
404
- // Ignore a late response if the user already closed or switched posts.
405
- if (isEditorOpen() && editingSlug() === post.slug)
406
- formBody.set(full.body)
304
+ if (requestId === editorRequestId && isEditorOpen() && editingPost()?.slug === post.slug)
305
+ loadedBody.set(full.body)
407
306
  }
408
307
  catch (error) {
409
- formError.set(`Couldn't load this post's content: ${error.message}`)
308
+ if (requestId === editorRequestId) {
309
+ bodyLoadFailed.set(true)
310
+ formError.set(`Couldn't load this post's content: ${error.message}`)
311
+ }
410
312
  }
313
+ finally {
314
+ if (requestId === editorRequestId && isEditorOpen() && editingPost()?.slug === post.slug)
315
+ bodyLoading.set(false)
316
+ }
317
+ }
318
+
319
+ function retryEditorBody() {
320
+ const post = editingPost()
321
+ if (post)
322
+ void loadEditorBody(post)
411
323
  }
412
324
 
413
325
  function closeEditor() {
326
+ if (isSaving())
327
+ return
328
+
414
329
  isEditorOpen.set(false)
415
- editingSlug.set('')
330
+ editorRequestId += 1
331
+ editingPost.set(null)
332
+ loadedBody.set('')
333
+ bodyLoading.set(false)
334
+ bodyLoadFailed.set(false)
416
335
  formError.set('')
417
336
  }
418
337
 
419
- async function submit() {
420
- if (!formTitle().trim()) {
421
- formError.set('A title is required.')
422
- return
423
- }
424
-
338
+ async function submit(draft) {
425
339
  isSaving.set(true)
426
340
  formError.set('')
427
341
 
428
342
  try {
429
- const saved = await saveBlogPost({
430
- slug: formSlug().trim() || suggestedSlug(),
431
- title: formTitle().trim(),
432
- description: formDescription().trim(),
433
- date: formDate(),
434
- author: formAuthor().trim(),
435
- authorBio: '',
436
- poster: formPoster().trim(),
437
- featured: formFeatured(),
438
- draft: formDraft(),
439
- body: formBody(),
440
- }, editingSlug())
441
-
442
- const original = editingSlug()
343
+ const original = editingPost()?.slug || ''
344
+ const saved = await saveBlogPost(draft, original)
443
345
 
444
346
  posts.update(list => (original
445
347
  ? list.map(p => (p.slug === original ? saved : p))
@@ -457,11 +359,15 @@ async function submit() {
457
359
  }
458
360
 
459
361
  function askDelete(post) {
362
+ deleteError.set('')
460
363
  postToDelete.set(post)
461
364
  }
462
365
 
463
366
  function cancelDelete() {
464
- postToDelete.set(null)
367
+ if (!isDeleting()) {
368
+ postToDelete.set(null)
369
+ deleteError.set('')
370
+ }
465
371
  }
466
372
 
467
373
  async function confirmDelete() {
@@ -469,14 +375,19 @@ async function confirmDelete() {
469
375
  if (!post)
470
376
  return
471
377
 
472
- postToDelete.set(null)
378
+ isDeleting.set(true)
379
+ deleteError.set('')
473
380
 
474
381
  try {
475
382
  await deleteBlogPost(post.slug)
476
383
  posts.update(list => list.filter(p => p.slug !== post.slug))
384
+ postToDelete.set(null)
477
385
  }
478
386
  catch (error) {
479
- loadError.set(`Couldn't delete "${post.title}": ${error.message}`)
387
+ deleteError.set(`Couldn't delete "${post.title}": ${error.message}`)
388
+ }
389
+ finally {
390
+ isDeleting.set(false)
480
391
  }
481
392
  }
482
393
 
@@ -0,0 +1,195 @@
1
+ <script client>
2
+ interface BlogPostRecord {
3
+ slug: string
4
+ title: string
5
+ description: string
6
+ date: string
7
+ author: string
8
+ poster: string
9
+ draft: boolean
10
+ featured: boolean
11
+ }
12
+
13
+ interface BlogPostDraft {
14
+ slug: string
15
+ title: string
16
+ description: string
17
+ date: string
18
+ author: string
19
+ authorBio: string
20
+ poster: string
21
+ featured: boolean
22
+ draft: boolean
23
+ body: string
24
+ }
25
+
26
+ const open = useReactiveProp('open', false)
27
+ const post = useReactiveProp('post', null as BlogPostRecord | null)
28
+ const loadedBody = useReactiveProp('loadedBody', '')
29
+ const bodyLoading = useReactiveProp('bodyLoading', false)
30
+ const bodyLoadFailed = useReactiveProp('bodyLoadFailed', false)
31
+ const busy = useReactiveProp('busy', false)
32
+ const error = useReactiveProp('error', '')
33
+ const title = state('')
34
+ const slug = state('')
35
+ const description = state('')
36
+ const date = state('')
37
+ const author = state('')
38
+ const poster = state('')
39
+ const body = state('')
40
+ const draft = state(true)
41
+ const featured = state(false)
42
+ const emit = defineEmits()
43
+ let wasOpen = false
44
+ let activeSlug = ''
45
+
46
+ function slugify(input: string): string {
47
+ return input
48
+ .toLowerCase()
49
+ .replace(/[^a-z0-9]+/g, '-')
50
+ .replace(/^-+|-+$/g, '')
51
+ .slice(0, 80)
52
+ }
53
+
54
+ const suggestedSlug = derived(() => slugify(title()))
55
+ const wordCount = derived(() => body().split(/\s+/).filter(Boolean).length)
56
+ const readingTime = derived(() => Math.max(1, Math.round(wordCount() / 200)))
57
+ const dialogTitle = derived(() => post() ? 'Edit post' : 'New post')
58
+ const actionLabel = derived(() => post() ? 'Save post' : 'Create post')
59
+
60
+ effect(() => {
61
+ const isOpen = open()
62
+ if (isOpen && !wasOpen) {
63
+ const record = post()
64
+ activeSlug = record?.slug || ''
65
+ title.set(record?.title || '')
66
+ slug.set(record?.slug || '')
67
+ description.set(record?.description || '')
68
+ date.set(record?.date || new Date().toISOString().slice(0, 10))
69
+ author.set(record?.author || '')
70
+ poster.set(record?.poster || '')
71
+ draft.set(record?.draft ?? true)
72
+ featured.set(Boolean(record?.featured))
73
+ body.set(record ? '' : loadedBody())
74
+ }
75
+ wasOpen = isOpen
76
+ })
77
+
78
+ effect(() => {
79
+ if (open() && post()?.slug === activeSlug && !bodyLoading())
80
+ body.set(loadedBody())
81
+ })
82
+
83
+ function valid(): boolean {
84
+ return title().trim().length > 0 && !bodyLoading() && !bodyLoadFailed()
85
+ }
86
+
87
+ function close(): void {
88
+ if (!busy())
89
+ emit('close')
90
+ }
91
+
92
+ function submit(): void {
93
+ if (!valid())
94
+ return
95
+
96
+ const postDraft: BlogPostDraft = {
97
+ slug: slug().trim() || suggestedSlug(),
98
+ title: title().trim(),
99
+ description: description().trim(),
100
+ date: date(),
101
+ author: author().trim(),
102
+ authorBio: '',
103
+ poster: poster().trim(),
104
+ featured: featured(),
105
+ draft: draft(),
106
+ body: body(),
107
+ }
108
+ emit('submit', postDraft)
109
+ }
110
+ </script>
111
+
112
+ <Modal
113
+ :isOpen="open()"
114
+ :title="dialogTitle()"
115
+ description="Edit the markdown file and its publishing metadata together."
116
+ size="xl"
117
+ :closable="!busy()"
118
+ @close="close"
119
+ >
120
+ <form id="blog-post-editor-form" class="grid gap-4" @submit.prevent="submit">
121
+ <div :if="error()" role="alert" class="flex flex-wrap gap-3 items-center justify-between px-3 py-2 text-red-800 text-sm dark:text-red-200 bg-red-50 dark:bg-red-950/40 border border-red-200 rounded-md dark:border-red-900">
122
+ <span class="flex gap-2 items-start min-w-0">
123
+ <span class="shrink-0 mt-0.5 h-4 w-4 i-hugeicons-alert-02"></span>
124
+ <span>{{ error() }}</span>
125
+ </span>
126
+ <Button :if="bodyLoadFailed()" variant="secondary" size="sm" @click="emit('retry')">Retry loading content</Button>
127
+ </div>
128
+
129
+ <div class="grid gap-4 sm:grid-cols-2">
130
+ <label class="flex flex-col gap-2 sm:col-span-2">
131
+ <span class="font-medium text-sm">Title</span>
132
+ <input x-model="title" name="title" type="text" required autofocus class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
133
+ </label>
134
+
135
+ <label class="flex flex-col gap-2 sm:col-span-2">
136
+ <span class="font-medium text-sm">Slug</span>
137
+ <input x-model="slug" name="slug" type="text" :placeholder="suggestedSlug()" class="placeholder-neutral-500 px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
138
+ <span class="text-neutral-500 text-xs dark:text-neutral-400">The file name and public URL: /blog/{{ slug() || suggestedSlug() || 'your-post' }}</span>
139
+ </label>
140
+
141
+ <label class="flex flex-col gap-2 sm:col-span-2">
142
+ <span class="font-medium text-sm">Description</span>
143
+ <input x-model="description" name="description" type="text" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
144
+ <span class="text-neutral-500 text-xs dark:text-neutral-400">Used as the excerpt, meta description, and RSS summary.</span>
145
+ </label>
146
+
147
+ <label class="flex flex-col gap-2">
148
+ <span class="font-medium text-sm">Date</span>
149
+ <input x-model="date" name="date" type="date" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
150
+ </label>
151
+
152
+ <label class="flex flex-col gap-2">
153
+ <span class="font-medium text-sm">Author</span>
154
+ <input x-model="author" name="author" type="text" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
155
+ </label>
156
+
157
+ <label class="flex flex-col gap-2 sm:col-span-2">
158
+ <span class="font-medium text-sm">Poster image</span>
159
+ <input x-model="poster" name="poster" type="text" placeholder="/assets/images/example.svg" class="placeholder-neutral-500 px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
160
+ </label>
161
+
162
+ <label class="flex flex-col gap-2 sm:col-span-2">
163
+ <span class="flex items-center justify-between gap-3">
164
+ <span class="font-medium text-sm">Content</span>
165
+ <span :if="bodyLoading()" role="status" class="inline-flex gap-1.5 items-center text-neutral-500 text-xs dark:text-neutral-400">
166
+ <span class="h-3.5 w-3.5 animate-spin i-hugeicons-loading-03"></span>
167
+ Loading content
168
+ </span>
169
+ </span>
170
+ <textarea x-model="body" name="body" rows="14" :disabled="bodyLoading() || bodyLoadFailed()" class="px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md disabled:opacity-60 dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 resize-y"></textarea>
171
+ <span class="text-neutral-500 text-xs dark:text-neutral-400">Markdown. {{ wordCount() }} words, about {{ readingTime() }} min read.</span>
172
+ </label>
173
+ </div>
174
+
175
+ <div class="flex flex-col gap-3 sm:flex-row sm:gap-6 pt-2 border-neutral-200 border-t dark:border-neutral-800">
176
+ <label class="flex gap-2 items-center text-sm">
177
+ <input x-model="draft" name="draft" type="checkbox" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
178
+ <span>Draft, keep off the public blog</span>
179
+ </label>
180
+ <label class="flex gap-2 items-center text-sm">
181
+ <input x-model="featured" name="featured" type="checkbox" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
182
+ <span>Feature at the top of the listing</span>
183
+ </label>
184
+ </div>
185
+ </form>
186
+
187
+ <template #footer>
188
+ <div class="flex gap-2 justify-end w-full">
189
+ <Button variant="secondary" :disabled="busy()" @click="close">Cancel</Button>
190
+ <Button type="submit" form="blog-post-editor-form" :disabled="!valid()" :loading="busy()">
191
+ {{ actionLabel() }}
192
+ </Button>
193
+ </div>
194
+ </template>
195
+ </Modal>