@stacksjs/defaults 0.75.480 → 0.75.483

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 (55) hide show
  1. package/ai/skills/stacks-actions/SKILL.md +1 -1
  2. package/ai/skills/stacks-native/CATALOG.md +7 -7
  3. package/ai/skills/stacks-queue/DURABLE-WORKFLOWS.md +12 -0
  4. package/ai/skills/stacks-server/SKILL.md +13 -0
  5. package/app/Actions/Dashboard/Content/CommentIndexAction.ts +22 -112
  6. package/app/Actions/Dashboard/Content/PageIndexAction.ts +26 -25
  7. package/app/Actions/Dashboard/Content/PostIndexAction.ts +30 -35
  8. package/app/Actions/Dashboard/Content/PostShowAction.ts +41 -0
  9. package/app/Actions/Dashboard/Content/comment-input.ts +25 -1
  10. package/app/Actions/Dashboard/Content/comment-list.ts +311 -0
  11. package/app/Actions/Dashboard/Content/content-list.ts +69 -0
  12. package/app/Actions/Dashboard/Content/page-list.ts +108 -0
  13. package/app/Actions/Dashboard/Content/post-list.ts +140 -0
  14. package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +12 -24
  15. package/app/Actions/Dashboard/Jobs/job-list.ts +168 -0
  16. package/app/Actions/Dashboard/Jobs/job-records.ts +8 -3
  17. package/app/Actions/Dashboard/Models/ModelShowAction.ts +8 -2
  18. package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +20 -10
  19. package/app/Actions/Dashboard/Queries/query-dashboard.ts +56 -9
  20. package/app/Actions/Dashboard/Queries/query-list.ts +161 -0
  21. package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +14 -52
  22. package/app/Actions/Dashboard/Queue/queue-stats.ts +69 -0
  23. package/app/Actions/Monitoring/ErrorIndexAction.ts +9 -4
  24. package/app/Actions/Monitoring/error-provider.ts +85 -7
  25. package/functions/api.ts +14 -10
  26. package/functions/commerce/coupons.ts +4 -6
  27. package/functions/commerce/customers.ts +4 -6
  28. package/functions/commerce/gift-cards.ts +4 -2
  29. package/functions/commerce/orders.ts +4 -2
  30. package/functions/commerce/payments.ts +4 -2
  31. package/functions/commerce/products/categories.ts +4 -6
  32. package/functions/commerce/products/manufacturers.ts +4 -6
  33. package/functions/commerce/products/products.ts +4 -6
  34. package/functions/commerce/products/reviews.ts +4 -6
  35. package/functions/commerce/products/units.ts +4 -6
  36. package/functions/commerce/products/variants.ts +4 -6
  37. package/functions/commerce/taxes.ts +4 -2
  38. package/functions/commerce/waitlist/products.ts +4 -6
  39. package/functions/commerce/waitlist/restaurants.ts +4 -6
  40. package/functions/monitoring/errors.ts +77 -49
  41. package/functions/queries.ts +34 -2
  42. package/ide/vscode/package.json +1 -1
  43. package/package.json +4 -4
  44. package/resources/components/Dashboard/Analytics/AnalyticsHub.stx +4 -0
  45. package/resources/components/Dashboard/Auth/MagicLink.stx +6 -1
  46. package/resources/components/Dashboard/Content/CommentsDashboard.stx +90 -12
  47. package/resources/components/Dashboard/Content/PagesDashboard.stx +100 -16
  48. package/resources/components/Dashboard/Content/PostEditorDialog.stx +28 -8
  49. package/resources/components/Dashboard/Content/PostsDashboard.stx +179 -21
  50. package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +121 -36
  51. package/resources/components/Dashboard/Monitoring/ErrorTable.stx +29 -8
  52. package/resources/components/Dashboard/Queries/QueryDashboard.stx +73 -64
  53. package/resources/components/Dashboard/UI/Table.stx +30 -7
  54. package/routes/dashboard-api.ts +1 -0
  55. package/app/Actions/Buddy/JobsListAction.ts +0 -141
@@ -30,6 +30,24 @@ const deletingPage = state<PageRecord | null>(null)
30
30
  const deleting = state(false)
31
31
  const deleteError = state('')
32
32
 
33
+ // The list is paged, searched and sorted by the server, and these are what
34
+ // it was last asked for (stacksjs/stacks#3192). The cards count every page.
35
+ const listPage = state(1)
36
+ const perPage = state(25)
37
+ const search = state('')
38
+ const sort = state('')
39
+ const sortDirection = state('asc')
40
+ const total = state(0)
41
+ const allCount = state(0)
42
+ const viewCount = state(0)
43
+ const conversionCount = state(0)
44
+ const newestTitle = state('')
45
+ let listRequestId = 0
46
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
47
+
48
+ /** The server's name for each sortable column. */
49
+ const SORT_KEYS: Record<string, string> = { title: 'title', publishState: 'published' }
50
+
33
51
  function normalize(record: any): PageRecord {
34
52
  return {
35
53
  id: String(record?.id || ''),
@@ -64,7 +82,7 @@ const ROW_ACTIONS = [
64
82
  { action: 'delete', label: 'Delete page', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
65
83
  ]
66
84
 
67
- /** `UI/Table` owns the search; the cells each read a field, so derive them. */
85
+ /** The rows are the page the server chose; the cells each read a field, so derive them. */
68
86
  function visiblePages(): PageRecord[] {
69
87
  return pages().map(page => ({
70
88
  ...page,
@@ -82,14 +100,6 @@ function handleRowAction(payload: { action: string, row: PageRecord }): void {
82
100
  openDelete(payload.row)
83
101
  }
84
102
 
85
- function totalViews(): number {
86
- return pages().reduce((sum, page) => sum + page.views, 0)
87
- }
88
-
89
- function totalConversions(): number {
90
- return pages().reduce((sum, page) => sum + page.conversions, 0)
91
- }
92
-
93
103
  function formatNumber(value: number): string {
94
104
  return new Intl.NumberFormat('en-US').format(value)
95
105
  }
@@ -103,24 +113,89 @@ function formatDate(value: string): string {
103
113
  : new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
104
114
  }
105
115
 
116
+ interface PageListResponse {
117
+ pages?: unknown[]
118
+ total?: number
119
+ page?: number
120
+ allCount?: number
121
+ totalViews?: number
122
+ totalConversions?: number
123
+ newestTitle?: string
124
+ }
125
+
126
+ function listQuery(): string {
127
+ const params = new URLSearchParams({ page: String(listPage()), per_page: String(perPage()) })
128
+ if (search())
129
+ params.set('search', search())
130
+ if (sort()) {
131
+ params.set('sort', sort())
132
+ params.set('direction', sortDirection())
133
+ }
134
+ return params.toString()
135
+ }
136
+
106
137
  async function loadPages(): Promise<void> {
138
+ // Only the newest request may land: typing, paging and sorting can each
139
+ // start one while another is still out.
140
+ const requestId = ++listRequestId
107
141
  loading.set(true)
108
142
  loadError.set('')
109
143
  try {
110
- const data = await dashboardApi<{ pages?: unknown[] }>('/api/dashboard/pages')
144
+ const data = await dashboardApi<PageListResponse>(`/api/dashboard/pages?${listQuery()}`)
145
+ if (requestId !== listRequestId)
146
+ return
111
147
  if (!Array.isArray(data.pages))
112
148
  throw new TypeError('Pages were not returned in the expected shape.')
113
149
  pages.set(data.pages.map(normalize))
150
+ total.set(Number(data.total || 0))
151
+ // The server moves back to the last page when a delete or a narrower
152
+ // search leaves this one past the end.
153
+ listPage.set(Number(data.page || 1))
154
+ allCount.set(Number(data.allCount || 0))
155
+ viewCount.set(Number(data.totalViews || 0))
156
+ conversionCount.set(Number(data.totalConversions || 0))
157
+ newestTitle.set(String(data.newestTitle || ''))
114
158
  }
115
159
  catch (error) {
160
+ if (requestId !== listRequestId)
161
+ return
116
162
  loadError.set(error instanceof Error ? error.message : String(error))
117
163
  pushToast('error', 'Could not load pages', { detail: loadError() })
118
164
  }
119
165
  finally {
120
- loading.set(false)
166
+ if (requestId === listRequestId)
167
+ loading.set(false)
121
168
  }
122
169
  }
123
170
 
171
+ function changePage(next: number): void {
172
+ listPage.set(next)
173
+ void loadPages()
174
+ }
175
+
176
+ function changePageSize(size: number): void {
177
+ perPage.set(size)
178
+ listPage.set(1)
179
+ void loadPages()
180
+ }
181
+
182
+ function changeSearch(term: string): void {
183
+ if (searchTimer)
184
+ clearTimeout(searchTimer)
185
+ searchTimer = setTimeout(() => {
186
+ search.set(String(term || '').trim())
187
+ listPage.set(1)
188
+ void loadPages()
189
+ }, 250)
190
+ }
191
+
192
+ function changeSort(next: { key: string, direction: string }): void {
193
+ sort.set(SORT_KEYS[next.key] || '')
194
+ sortDirection.set(next.direction === 'desc' ? 'desc' : 'asc')
195
+ listPage.set(1)
196
+ void loadPages()
197
+ }
198
+
124
199
  function openCreate(): void {
125
200
  editingPage.set(null)
126
201
  saveError.set('')
@@ -208,10 +283,10 @@ onMount(() => {
208
283
  <div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 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"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadPages()">Retry</Button></div>
209
284
 
210
285
  <section aria-label="Page metrics" class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
211
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Total</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ pages().length }}</p></article>
212
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Views</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(totalViews()) }}</p></article>
213
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Conversions</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(totalConversions()) }}</p></article>
214
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Newest</p><p class="mt-2 font-semibold text-gray-900 text-lg truncate dark:text-white">{{ pages()[0]?.title || 'None' }}</p></article>
286
+ <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Total</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ allCount() }}</p></article>
287
+ <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Views</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(viewCount()) }}</p></article>
288
+ <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Conversions</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(conversionCount()) }}</p></article>
289
+ <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Newest</p><p class="mt-2 font-semibold text-gray-900 text-lg truncate dark:text-white">{{ newestTitle() || 'None' }}</p></article>
215
290
  </section>
216
291
 
217
292
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
@@ -222,12 +297,21 @@ onMount(() => {
222
297
  :row-actions="ROW_ACTIONS"
223
298
  action-label-key="title"
224
299
  searchable
300
+ manual-search
225
301
  search-placeholder="Search pages"
226
302
  paginated
227
- :page-size="25"
303
+ manual-pagination
304
+ manual-sort
305
+ :total="total()"
306
+ :current-page="listPage()"
307
+ :page-size="perPage()"
228
308
  empty-title="No pages found"
229
309
  empty-description="Adjust the search or create a page."
230
310
  @row-action="handleRowAction"
311
+ @page-change="changePage"
312
+ @page-size-change="changePageSize"
313
+ @search="changeSearch"
314
+ @sort="changeSort"
231
315
  />
232
316
  </section>
233
317
  </div>
@@ -6,7 +6,6 @@ interface PostRecord {
6
6
  id: string
7
7
  title: string
8
8
  excerpt: string
9
- content: string
10
9
  poster: string
11
10
  status: PostStatus
12
11
  authorId: string
@@ -36,6 +35,12 @@ const editing = useReactiveProp('post', null as PostRecord | null)
36
35
  const categories = useReactiveProp('categories', [] as OptionRecord[])
37
36
  const tags = useReactiveProp('tags', [] as OptionRecord[])
38
37
  const authors = useReactiveProp('authors', [] as AuthorRecord[])
38
+ // The list row carries a preview, not the body, so the parent reads the body
39
+ // when an edit opens and hands it in here (stacksjs/stacks#3191), as the blog
40
+ // editor does.
41
+ const loadedContent = useReactiveProp('loadedContent', '')
42
+ const contentLoading = useReactiveProp('contentLoading', false)
43
+ const contentLoadFailed = useReactiveProp('contentLoadFailed', false)
39
44
  const busy = useReactiveProp('busy', false)
40
45
  const error = useReactiveProp('error', '')
41
46
  const title = state('')
@@ -47,16 +52,18 @@ const authorId = state('')
47
52
  const featured = state(false)
48
53
  const categoryIds = state<string[]>([])
49
54
  const tagIds = state<string[]>([])
50
- const emit = defineEmits(['close', 'submit'])
55
+ const emit = defineEmits(['close', 'submit', 'retry'])
51
56
  let wasOpen = false
57
+ let activeId = ''
52
58
 
53
59
  effect(() => {
54
60
  const isOpen = open()
55
61
  if (isOpen && !wasOpen) {
56
62
  const record = editing()
63
+ activeId = record?.id || ''
57
64
  title.set(record?.title || '')
58
65
  excerpt.set(record?.excerpt || '')
59
- content.set(record?.content || '')
66
+ content.set('')
60
67
  poster.set(record?.poster || '')
61
68
  status.set(record?.status || 'draft')
62
69
  authorId.set(record?.authorId || '')
@@ -67,9 +74,15 @@ effect(() => {
67
74
  wasOpen = isOpen
68
75
  })
69
76
 
77
+ effect(() => {
78
+ if (open() && activeId && editing()?.id === activeId && !contentLoading())
79
+ content.set(loadedContent())
80
+ })
81
+
70
82
  function valid(): boolean {
71
83
  const excerptLength = excerpt().trim().length
72
- return title().trim().length >= 3 && title().trim().length <= 255
84
+ return !contentLoading() && !contentLoadFailed()
85
+ && title().trim().length >= 3 && title().trim().length <= 255
73
86
  && content().trim().length >= 10 && content().length <= 100000
74
87
  && (excerptLength === 0 || excerptLength >= 10) && excerpt().length <= 500
75
88
  }
@@ -139,8 +152,14 @@ function submit(): void {
139
152
  <span class="block mt-1 text-gray-500 text-xs dark:text-gray-400">{{ excerpt().length }}/500</span>
140
153
  </label>
141
154
  <label>
142
- <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Content</span>
143
- <textarea x-model="content" name="content" required minlength="10" maxlength="100000" rows="10" class="mt-1 px-3 py-2 w-full font-mono 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>
155
+ <span class="flex gap-3 items-center justify-between">
156
+ <span class="font-medium text-gray-700 text-sm dark:text-neutral-200">Content</span>
157
+ <span :if="contentLoading()" role="status" class="inline-flex gap-1.5 items-center text-gray-500 text-xs dark:text-neutral-400">
158
+ <span aria-hidden="true" class="h-3.5 w-3.5 animate-spin i-hugeicons-loading-03"></span>
159
+ Loading content
160
+ </span>
161
+ </span>
162
+ <textarea x-model="content" name="content" required minlength="10" maxlength="100000" rows="10" :disabled="contentLoading() || contentLoadFailed()" class="mt-1 px-3 py-2 w-full font-mono text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700 disabled:opacity-60"></textarea>
144
163
  <span class="block mt-1 text-gray-500 text-xs dark:text-gray-400">{{ content().length }}/100000</span>
145
164
  </label>
146
165
  <div class="grid gap-4 lg:grid-cols-2">
@@ -165,8 +184,9 @@ function submit(): void {
165
184
  </div>
166
185
  </form>
167
186
 
168
- <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">
169
- {{ error() }}
187
+ <div :if="error()" role="alert" class="flex flex-wrap gap-3 items-center justify-between 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">
188
+ <span>{{ error() }}</span>
189
+ <Button :if="contentLoadFailed()" variant="secondary" size="sm" @click="emit('retry')">Retry loading content</Button>
170
190
  </div>
171
191
 
172
192
  <template #footer>
@@ -17,7 +17,7 @@ interface PostRecord {
17
17
  id: string
18
18
  title: string
19
19
  excerpt: string
20
- content: string
20
+ preview: string
21
21
  poster: string
22
22
  status: PostStatus
23
23
  views: number
@@ -47,6 +47,27 @@ const loading = state(true)
47
47
  const loadError = state('')
48
48
  const statusFilter = state('all')
49
49
 
50
+ // The list is paged, searched and sorted by the server, and these are what
51
+ // it was last asked for (stacksjs/stacks#3191). The cards count every post.
52
+ const page = state(1)
53
+ const perPage = state(25)
54
+ const search = state('')
55
+ const sort = state('')
56
+ const sortDirection = state('asc')
57
+ const total = state(0)
58
+ const allCount = state(0)
59
+ const publishedCount = state(0)
60
+ const draftCount = state(0)
61
+ const archivedCount = state(0)
62
+ const totalViewCount = state(0)
63
+ let listRequestId = 0
64
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
65
+
66
+ const loadedContent = state('')
67
+ const contentLoading = state(false)
68
+ const contentLoadFailed = state(false)
69
+ let contentRequestId = 0
70
+
50
71
  const formOpen = state(false)
51
72
  const editingPost = state<PostRecord | null>(null)
52
73
  const saving = state(false)
@@ -69,7 +90,7 @@ function normalizePost(record: any): PostRecord {
69
90
  id: String(record?.id || ''),
70
91
  title: String(record?.title || ''),
71
92
  excerpt: String(record?.excerpt || ''),
72
- content: String(record?.content || ''),
93
+ preview: String(record?.preview || ''),
73
94
  poster: String(record?.poster || ''),
74
95
  status: normalizedStatus(record?.status),
75
96
  views: Number(record?.views || 0),
@@ -116,16 +137,15 @@ const ROW_ACTIONS = [
116
137
  ]
117
138
 
118
139
  /**
119
- * `UI/Table` owns the search, so this applies only the status filter. The
120
- * labels are derived onto the row because a slot body reads fields rather than
121
- * calling back into the component.
140
+ * The rows are already the page the server chose, so this only derives the
141
+ * labels onto each one, because a slot body reads fields rather than calling
142
+ * back into the component.
122
143
  */
123
144
  function visiblePosts(): PostRecord[] {
124
145
  return posts()
125
- .filter(post => statusFilter() === 'all' || post.status === statusFilter())
126
146
  .map(post => ({
127
147
  ...post,
128
- summaryLabel: post.excerpt || post.content,
148
+ summaryLabel: post.excerpt || post.preview,
129
149
  authorLabel: authorName(post),
130
150
  categoriesLabel: optionNames(post.categoryIds, categories()),
131
151
  tagsLabel: optionNames(post.tagIds, tags()),
@@ -141,11 +161,7 @@ function handleRowAction(payload: { action: string, row: PostRecord }): void {
141
161
  }
142
162
 
143
163
  function postCount(value: PostStatus): number {
144
- return posts().filter(post => post.status === value).length
145
- }
146
-
147
- function totalViews(): number {
148
- return posts().reduce((sum, post) => sum + post.views, 0)
164
+ return value === 'published' ? publishedCount() : value === 'draft' ? draftCount() : archivedCount()
149
165
  }
150
166
 
151
167
  function formatNumber(value: number): string {
@@ -168,30 +184,159 @@ function optionNames(ids: string[], options: OptionRecord[]): string {
168
184
  return names.length ? names.join(', ') : 'None'
169
185
  }
170
186
 
187
+ interface PostPageResponse {
188
+ posts?: unknown[]
189
+ categories?: unknown[]
190
+ tags?: unknown[]
191
+ authors?: unknown[]
192
+ total?: number
193
+ page?: number
194
+ allCount?: number
195
+ publishedCount?: number
196
+ draftCount?: number
197
+ archivedCount?: number
198
+ totalViews?: number
199
+ }
200
+
201
+ function listQuery(): string {
202
+ const params = new URLSearchParams({ page: String(page()), per_page: String(perPage()) })
203
+ if (search())
204
+ params.set('search', search())
205
+ if (statusFilter() !== 'all')
206
+ params.set('status', statusFilter())
207
+ if (sort()) {
208
+ params.set('sort', sort())
209
+ params.set('direction', sortDirection())
210
+ }
211
+ return params.toString()
212
+ }
213
+
171
214
  async function loadPosts(): Promise<void> {
215
+ // Only the newest request may land: typing, paging and filtering can each
216
+ // start one while another is still out.
217
+ const requestId = ++listRequestId
172
218
  loading.set(true)
173
219
  loadError.set('')
174
220
  try {
175
- const data = await dashboardApi<{ posts?: unknown[], categories?: unknown[], tags?: unknown[], authors?: unknown[] }>('/api/dashboard/posts')
221
+ const data = await dashboardApi<PostPageResponse>(`/api/dashboard/posts?${listQuery()}`)
222
+ if (requestId !== listRequestId)
223
+ return
176
224
  if (!Array.isArray(data.posts) || !Array.isArray(data.categories) || !Array.isArray(data.tags) || !Array.isArray(data.authors))
177
225
  throw new TypeError('Posts were not returned in the expected shape.')
178
226
  posts.set(data.posts.map(normalizePost))
179
227
  categories.set(data.categories.map(normalizeOption))
180
228
  tags.set(data.tags.map(normalizeOption))
181
229
  authors.set(data.authors.map(normalizeAuthor))
230
+ total.set(Number(data.total || 0))
231
+ // The server moves back to the last page when a delete or a narrower
232
+ // search leaves this one past the end.
233
+ page.set(Number(data.page || 1))
234
+ allCount.set(Number(data.allCount || 0))
235
+ publishedCount.set(Number(data.publishedCount || 0))
236
+ draftCount.set(Number(data.draftCount || 0))
237
+ archivedCount.set(Number(data.archivedCount || 0))
238
+ totalViewCount.set(Number(data.totalViews || 0))
182
239
  }
183
240
  catch (error) {
241
+ if (requestId !== listRequestId)
242
+ return
184
243
  loadError.set(error instanceof Error ? error.message : String(error))
185
244
  pushToast('error', 'Could not load posts', { detail: loadError() })
186
245
  }
187
246
  finally {
188
- loading.set(false)
247
+ if (requestId === listRequestId)
248
+ loading.set(false)
189
249
  }
190
250
  }
191
251
 
192
- function openCreate(): void { editingPost.set(null); saveError.set(''); formOpen.set(true) }
193
- function openEdit(post: PostRecord): void { editingPost.set(post); saveError.set(''); formOpen.set(true) }
194
- function closeForm(): void { if (!saving()) formOpen.set(false) }
252
+ function changePage(next: number): void {
253
+ page.set(next)
254
+ void loadPosts()
255
+ }
256
+
257
+ function changePageSize(size: number): void {
258
+ perPage.set(size)
259
+ page.set(1)
260
+ void loadPosts()
261
+ }
262
+
263
+ function changeSearch(term: string): void {
264
+ if (searchTimer)
265
+ clearTimeout(searchTimer)
266
+ searchTimer = setTimeout(() => {
267
+ search.set(String(term || '').trim())
268
+ page.set(1)
269
+ void loadPosts()
270
+ }, 250)
271
+ }
272
+
273
+ function changeSort(next: { key: string, direction: string }): void {
274
+ sort.set(next.key || '')
275
+ sortDirection.set(next.direction === 'desc' ? 'desc' : 'asc')
276
+ page.set(1)
277
+ void loadPosts()
278
+ }
279
+
280
+ function changeStatus(event: Event): void {
281
+ statusFilter.set((event.target as HTMLSelectElement).value)
282
+ page.set(1)
283
+ void loadPosts()
284
+ }
285
+
286
+ async function loadEditorContent(post: PostRecord): Promise<void> {
287
+ const requestId = ++contentRequestId
288
+ contentLoading.set(true)
289
+ contentLoadFailed.set(false)
290
+ saveError.set('')
291
+ try {
292
+ const data = await dashboardApi<{ content?: string }>(`/api/dashboard/posts/${post.id}`)
293
+ if (requestId === contentRequestId && formOpen() && editingPost()?.id === post.id)
294
+ loadedContent.set(String(data.content || ''))
295
+ }
296
+ catch (error) {
297
+ if (requestId === contentRequestId) {
298
+ contentLoadFailed.set(true)
299
+ saveError.set(`Couldn't load this post's content: ${error instanceof Error ? error.message : String(error)}`)
300
+ }
301
+ }
302
+ finally {
303
+ if (requestId === contentRequestId)
304
+ contentLoading.set(false)
305
+ }
306
+ }
307
+
308
+ function retryEditorContent(): void {
309
+ const post = editingPost()
310
+ if (post)
311
+ void loadEditorContent(post)
312
+ }
313
+
314
+ function openCreate(): void {
315
+ contentRequestId += 1
316
+ editingPost.set(null)
317
+ loadedContent.set('')
318
+ contentLoading.set(false)
319
+ contentLoadFailed.set(false)
320
+ saveError.set('')
321
+ formOpen.set(true)
322
+ }
323
+
324
+ function openEdit(post: PostRecord): void {
325
+ editingPost.set(post)
326
+ loadedContent.set('')
327
+ contentLoading.set(true)
328
+ contentLoadFailed.set(false)
329
+ saveError.set('')
330
+ formOpen.set(true)
331
+ void loadEditorContent(post)
332
+ }
333
+
334
+ function closeForm(): void {
335
+ if (saving())
336
+ return
337
+ contentRequestId += 1
338
+ formOpen.set(false)
339
+ }
195
340
  function openDelete(post: PostRecord): void { deletingPost.set(post); deleteError.set('') }
196
341
  function closeDelete(): void { if (!deleting()) deletingPost.set(null) }
197
342
 
@@ -253,10 +398,10 @@ onMount(() => { void loadPosts() })
253
398
  <div :if="loadError()" role="alert" class="flex gap-4 items-center justify-between p-4 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"><span>{{ loadError() }}</span><Button variant="secondary" size="sm" @click="loadPosts()">Retry</Button></div>
254
399
 
255
400
  <section aria-label="Post metrics" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
256
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Total</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ posts().length }}</p></article>
401
+ <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Total</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ allCount() }}</p></article>
257
402
  <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Published</p><p class="mt-2 font-semibold text-2xl text-green-700 dark:text-green-300">{{ postCount('published') }}</p></article>
258
403
  <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Drafts</p><p class="mt-2 font-semibold text-2xl text-yellow-700 dark:text-yellow-300">{{ postCount('draft') }}</p></article>
259
- <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Views</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(totalViews()) }}</p></article>
404
+ <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Views</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ formatNumber(totalViewCount()) }}</p></article>
260
405
  </section>
261
406
 
262
407
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
@@ -267,17 +412,26 @@ onMount(() => { void loadPosts() })
267
412
  :row-actions="ROW_ACTIONS"
268
413
  action-label-key="title"
269
414
  searchable
415
+ manual-search
270
416
  search-placeholder="Search title, excerpt, or content"
271
417
  paginated
272
- :page-size="25"
418
+ manual-pagination
419
+ manual-sort
420
+ :total="total()"
421
+ :current-page="page()"
422
+ :page-size="perPage()"
273
423
  empty-title="No posts found"
274
424
  empty-description="Adjust the search or status filter."
275
425
  @row-action="handleRowAction"
426
+ @page-change="changePage"
427
+ @page-size-change="changePageSize"
428
+ @search="changeSearch"
429
+ @sort="changeSort"
276
430
  >
277
431
  <template #toolbar>
278
432
  <label>
279
433
  <span class="sr-only">Filter by status</span>
280
- <select x-model="statusFilter" class="px-2.5 py-1.5 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600">
434
+ <select :value="statusFilter()" @change="changeStatus($event)" class="px-2.5 py-1.5 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600">
281
435
  <option value="all">All statuses</option>
282
436
  <option value="published">Published</option>
283
437
  <option value="draft">Draft</option>
@@ -310,10 +464,14 @@ onMount(() => { void loadPosts() })
310
464
  :categories="categories()"
311
465
  :tags="tags()"
312
466
  :authors="authors()"
467
+ :loadedContent="loadedContent()"
468
+ :contentLoading="contentLoading()"
469
+ :contentLoadFailed="contentLoadFailed()"
313
470
  :busy="saving()"
314
471
  :error="saveError()"
315
472
  @close="closeForm"
316
473
  @submit="savePost"
474
+ @retry="retryEditorContent"
317
475
  />
318
476
 
319
477
  <ConfirmDialog