@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
@@ -1,5 +1,6 @@
1
1
  import { resolveApiBaseUrl } from '../../api-url'
2
2
  import type { ProductVariants } from '../../../types/defaults'
3
+ import { withCsrfHeader } from '@stacksjs/browser/composables/csrf'
3
4
  import { useStorage } from '@stacksjs/browser/composables/useStorage'
4
5
  import { pushToast } from '../../toasts'
5
6
 
@@ -36,9 +37,7 @@ async function createVariant(variant: ProductVariants) {
36
37
  try {
37
38
  const response = await fetch(`${baseURL}/commerce/products/variants`, {
38
39
  method: 'POST',
39
- headers: {
40
- 'Content-Type': 'application/json',
41
- },
40
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
42
41
  body: JSON.stringify(variant),
43
42
  })
44
43
 
@@ -63,9 +62,7 @@ async function updateVariant(variant: ProductVariants) {
63
62
  try {
64
63
  const response = await fetch(`${baseURL}/commerce/products/variants/${variant.id}`, {
65
64
  method: 'PATCH',
66
- headers: {
67
- 'Content-Type': 'application/json',
68
- },
65
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
69
66
  body: JSON.stringify(variant),
70
67
  })
71
68
 
@@ -93,6 +90,7 @@ async function deleteVariant(id: number) {
93
90
  try {
94
91
  const response = await fetch(`${baseURL}/commerce/products/variants/${id}`, {
95
92
  method: 'DELETE',
93
+ headers: withCsrfHeader(),
96
94
  })
97
95
 
98
96
  if (!response.ok) {
@@ -1,5 +1,6 @@
1
1
  import { resolveApiBaseUrl } from '../api-url'
2
2
  import type { TaxRates } from '../../types/defaults'
3
+ import { withCsrfHeader } from '@stacksjs/browser/composables/csrf'
3
4
  import { useStorage } from '@stacksjs/browser/composables/useStorage'
4
5
  import { pushToast } from '../toasts'
5
6
 
@@ -35,7 +36,7 @@ async function createTaxRate(taxRate: TaxRates) {
35
36
  try {
36
37
  const response = await fetch(`${baseURL}/commerce/tax-rates`, {
37
38
  method: 'POST',
38
- headers: { 'Content-Type': 'application/json' },
39
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
39
40
  body: JSON.stringify(taxRate),
40
41
  })
41
42
 
@@ -61,7 +62,7 @@ async function updateTaxRate(taxRate: TaxRates) {
61
62
  try {
62
63
  const response = await fetch(`${baseURL}/commerce/tax-rates/${taxRate.id}`, {
63
64
  method: 'PATCH',
64
- headers: { 'Content-Type': 'application/json' },
65
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
65
66
  body: JSON.stringify(taxRate),
66
67
  })
67
68
 
@@ -90,6 +91,7 @@ async function deleteTaxRate(id: number) {
90
91
  try {
91
92
  const response = await fetch(`${baseURL}/commerce/tax-rates/${id}`, {
92
93
  method: 'DELETE',
94
+ headers: withCsrfHeader(),
93
95
  })
94
96
 
95
97
  if (!response.ok) {
@@ -1,5 +1,6 @@
1
1
  import { resolveApiBaseUrl } from '../../api-url'
2
2
  import type { WaitlistProduct } from '../../../types/defaults'
3
+ import { withCsrfHeader } from '@stacksjs/browser/composables/csrf'
3
4
  import { useStorage } from '@stacksjs/browser/composables/useStorage'
4
5
  import { pushToast } from '../../toasts'
5
6
 
@@ -36,9 +37,7 @@ async function createWaitlistProduct(waitlistProduct: WaitlistProduct) {
36
37
  try {
37
38
  const response = await fetch(`${baseURL}/commerce/waitlist/products`, {
38
39
  method: 'POST',
39
- headers: {
40
- 'Content-Type': 'application/json',
41
- },
40
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
42
41
  body: JSON.stringify(waitlistProduct),
43
42
  })
44
43
 
@@ -63,9 +62,7 @@ async function updateWaitlistProduct(waitlistProduct: WaitlistProduct) {
63
62
  try {
64
63
  const response = await fetch(`${baseURL}/commerce/waitlist/products/${waitlistProduct.id}`, {
65
64
  method: 'PATCH',
66
- headers: {
67
- 'Content-Type': 'application/json',
68
- },
65
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
69
66
  body: JSON.stringify(waitlistProduct),
70
67
  })
71
68
 
@@ -93,6 +90,7 @@ async function deleteWaitlistProduct(id: number) {
93
90
  try {
94
91
  const response = await fetch(`${baseURL}/commerce/waitlist/products/${id}`, {
95
92
  method: 'DELETE',
93
+ headers: withCsrfHeader(),
96
94
  })
97
95
 
98
96
  if (!response.ok) {
@@ -1,5 +1,6 @@
1
1
  import { resolveApiBaseUrl } from '../../api-url'
2
2
  import type { WaitlistRestaurant } from '../../../types/defaults'
3
+ import { withCsrfHeader } from '@stacksjs/browser/composables/csrf'
3
4
  import { useStorage } from '@stacksjs/browser/composables/useStorage'
4
5
  import { pushToast } from '../../toasts'
5
6
 
@@ -36,9 +37,7 @@ async function createWaitlistRestaurant(waitlistRestaurant: WaitlistRestaurant)
36
37
  try {
37
38
  const response = await fetch(`${baseURL}/commerce/waitlist/restaurants`, {
38
39
  method: 'POST',
39
- headers: {
40
- 'Content-Type': 'application/json',
41
- },
40
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
42
41
  body: JSON.stringify(waitlistRestaurant),
43
42
  })
44
43
 
@@ -63,9 +62,7 @@ async function updateWaitlistRestaurant(waitlistRestaurant: WaitlistRestaurant)
63
62
  try {
64
63
  const response = await fetch(`${baseURL}/commerce/waitlist/restaurants/${waitlistRestaurant.id}`, {
65
64
  method: 'PATCH',
66
- headers: {
67
- 'Content-Type': 'application/json',
68
- },
65
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
69
66
  body: JSON.stringify(waitlistRestaurant),
70
67
  })
71
68
 
@@ -93,6 +90,7 @@ async function deleteWaitlistRestaurant(id: number) {
93
90
  try {
94
91
  const response = await fetch(`${baseURL}/commerce/waitlist/restaurants/${id}`, {
95
92
  method: 'DELETE',
93
+ headers: withCsrfHeader(),
96
94
  })
97
95
 
98
96
  if (!response.ok) {
@@ -1,4 +1,5 @@
1
1
  import { resolveApiBaseUrl } from '../api-url'
2
+ import { dashboardApi } from '../dashboard-api'
2
3
  import { useStorage } from '@stacksjs/browser/composables/useStorage'
3
4
 
4
5
  export interface GroupedError {
@@ -10,7 +11,24 @@ export interface GroupedError {
10
11
  count: number
11
12
  first_seen: string
12
13
  last_seen: string
13
- error_ids: number[]
14
+ }
15
+
16
+ /** What a page of groups asks for; every field may be left out. */
17
+ export interface ErrorGroupQuery {
18
+ page?: number
19
+ perPage?: number
20
+ search?: string
21
+ status?: string
22
+ type?: string
23
+ sort?: string
24
+ direction?: 'asc' | 'desc'
25
+ }
26
+
27
+ /** The page the groups are, and what the pager and the type filter need. */
28
+ export interface ErrorGroupPageInfo {
29
+ total: number
30
+ page: number
31
+ types: string[]
14
32
  }
15
33
 
16
34
  export interface ErrorRecord {
@@ -40,6 +58,10 @@ export interface ErrorTimelinePoint {
40
58
 
41
59
  // Create persistent storage for errors data
42
60
  const groupedErrors = useStorage<GroupedError[]>('grouped-errors', [])
61
+ // Only the newest page request may land: typing, paging and filtering can
62
+ // each start one while another is still out.
63
+ let groupRequestId = 0
64
+ const groupedErrorsPage = useStorage<ErrorGroupPageInfo>('grouped-errors-page', { total: 0, page: 1, types: [] })
43
65
  const errorStats = useStorage<ErrorStats | null>('error-stats', null)
44
66
  const errorTimeline = useStorage<ErrorTimelinePoint[]>('error-timeline', [])
45
67
 
@@ -49,23 +71,54 @@ export function errorGroupParams(type: string, message: string): URLSearchParams
49
71
  return new URLSearchParams({ type, message })
50
72
  }
51
73
 
74
+ /** The page query as URL parameters, leaving out what is empty. */
75
+ export function errorGroupPageParams(query: ErrorGroupQuery): URLSearchParams {
76
+ const params = new URLSearchParams()
77
+ if (query.page)
78
+ params.set('page', String(query.page))
79
+ if (query.perPage)
80
+ params.set('per_page', String(query.perPage))
81
+ if (query.search)
82
+ params.set('search', query.search)
83
+ if (query.status)
84
+ params.set('status', query.status)
85
+ if (query.type)
86
+ params.set('type', query.type)
87
+ if (query.sort) {
88
+ params.set('sort', query.sort)
89
+ params.set('direction', query.direction ?? 'asc')
90
+ }
91
+ return params
92
+ }
93
+
52
94
  /**
53
- * Fetch all grouped errors
95
+ * Fetch one page of grouped errors. The server searches, filters, sorts and
96
+ * pages them (stacksjs/stacks#3195); this keeps the page and what the pager
97
+ * and the type filter need.
54
98
  */
55
- async function fetchGroupedErrors(): Promise<GroupedError[]> {
99
+ async function fetchGroupedErrors(query: ErrorGroupQuery = {}): Promise<GroupedError[]> {
100
+ const requestId = ++groupRequestId
56
101
  try {
57
- const response = await fetch(`${baseURL}/errors`)
102
+ const params = errorGroupPageParams(query).toString()
103
+ const response = await fetch(`${baseURL}/errors${params ? `?${params}` : ''}`)
58
104
  if (!response.ok) {
59
105
  throw new Error(`HTTP error! status: ${response.status}`)
60
106
  }
61
- const { data } = await response.json() as { data: GroupedError[] }
107
+ const { data } = await response.json() as { data: { groups?: GroupedError[], total?: number, page?: number, types?: string[] } }
108
+ if (requestId !== groupRequestId)
109
+ return groupedErrors.value
62
110
 
63
- if (Array.isArray(data)) {
64
- groupedErrors.value = data
65
- return data
111
+ if (data && Array.isArray(data.groups)) {
112
+ groupedErrors.value = data.groups
113
+ groupedErrorsPage.value = {
114
+ total: Number(data.total || 0),
115
+ page: Number(data.page || 1),
116
+ types: Array.isArray(data.types) ? data.types.map(String) : [],
117
+ }
118
+ return data.groups
66
119
  }
67
120
  else {
68
- console.error('Expected array of errors but received:', typeof data)
121
+ console.error('Expected a page of error groups but received:', typeof data)
69
122
  return []
70
123
  }
71
124
  }
@@ -154,17 +207,10 @@ async function fetchErrorTimeline(): Promise<ErrorTimelinePoint[]> {
154
207
  */
155
208
  async function resolveErrorGroup(type: string, message: string): Promise<boolean> {
156
209
  try {
157
- const response = await fetch(`${baseURL}/errors/resolve`, {
158
- method: 'PATCH',
159
- headers: {
160
- 'Content-Type': 'application/json',
161
- },
162
- body: JSON.stringify({ type, message }),
163
- })
164
-
165
- if (!response.ok) {
166
- throw new Error(`HTTP error! status: ${response.status}`)
167
- }
210
+ // Through dashboardApi, which sends the CSRF token: a bare fetch was
211
+ // refused with 403, so every resolve from this page did nothing
212
+ // (stacksjs/stacks#3194).
213
+ await dashboardApi(`${baseURL}/errors/resolve`, { method: 'PATCH', body: { type, message } })
168
214
 
169
215
  // Update local state
170
216
  const group = groupedErrors.value.find(
@@ -186,17 +232,10 @@ async function resolveErrorGroup(type: string, message: string): Promise<boolean
186
232
  */
187
233
  async function ignoreErrorGroup(type: string, message: string): Promise<boolean> {
188
234
  try {
189
- const response = await fetch(`${baseURL}/errors/ignore`, {
190
- method: 'PATCH',
191
- headers: {
192
- 'Content-Type': 'application/json',
193
- },
194
- body: JSON.stringify({ type, message }),
195
- })
196
-
197
- if (!response.ok) {
198
- throw new Error(`HTTP error! status: ${response.status}`)
199
- }
235
+ // Through dashboardApi, which sends the CSRF token: a bare fetch was
236
+ // refused with 403, so every ignore from this page did nothing
237
+ // (stacksjs/stacks#3194).
238
+ await dashboardApi(`${baseURL}/errors/ignore`, { method: 'PATCH', body: { type, message } })
200
239
 
201
240
  // Update local state
202
241
  const group = groupedErrors.value.find(
@@ -218,17 +257,10 @@ async function ignoreErrorGroup(type: string, message: string): Promise<boolean>
218
257
  */
219
258
  async function unresolveErrorGroup(type: string, message: string): Promise<boolean> {
220
259
  try {
221
- const response = await fetch(`${baseURL}/errors/unresolve`, {
222
- method: 'PATCH',
223
- headers: {
224
- 'Content-Type': 'application/json',
225
- },
226
- body: JSON.stringify({ type, message }),
227
- })
228
-
229
- if (!response.ok) {
230
- throw new Error(`HTTP error! status: ${response.status}`)
231
- }
260
+ // Through dashboardApi, which sends the CSRF token: a bare fetch was
261
+ // refused with 403, so every unresolve from this page did nothing
262
+ // (stacksjs/stacks#3194).
263
+ await dashboardApi(`${baseURL}/errors/unresolve`, { method: 'PATCH', body: { type, message } })
232
264
 
233
265
  // Update local state
234
266
  const group = groupedErrors.value.find(
@@ -250,13 +282,8 @@ async function unresolveErrorGroup(type: string, message: string): Promise<boole
250
282
  */
251
283
  async function deleteErrorGroup(type: string, message: string): Promise<boolean> {
252
284
  try {
253
- const response = await fetch(`${baseURL}/errors?${errorGroupParams(type, message)}`, {
254
- method: 'DELETE',
255
- })
256
-
257
- if (!response.ok) {
258
- throw new Error(`HTTP error! status: ${response.status}`)
259
- }
285
+ // Through dashboardApi for the CSRF token, as the triage writes above.
286
+ await dashboardApi(`${baseURL}/errors?${errorGroupParams(type, message)}`, { method: 'DELETE' })
260
287
 
261
288
  // Update local state
262
289
  groupedErrors.value = groupedErrors.value.filter(
@@ -274,6 +301,7 @@ async function deleteErrorGroup(type: string, message: string): Promise<boolean>
274
301
  export function useErrors() {
275
302
  return {
276
303
  groupedErrors,
304
+ groupedErrorsPage,
277
305
  errorStats,
278
306
  errorTimeline,
279
307
  fetchGroupedErrors,
@@ -21,19 +21,51 @@ export interface DashboardQueryLog {
21
21
  suggestions: string[]
22
22
  }
23
23
 
24
+ export interface DashboardQueryCounts {
25
+ total: number
26
+ reads: number
27
+ writes: number
28
+ slow: number
29
+ failed: number
30
+ averageDuration: number
31
+ }
32
+
33
+ /** One page of the query log, chosen by the server (stacksjs/stacks#3204). */
24
34
  export interface DashboardQueryResponse {
25
35
  enabled: boolean
26
36
  slowThreshold: number
27
37
  queries: DashboardQueryLog[]
38
+ total: number
39
+ page: number
40
+ perPage: number
41
+ counts: DashboardQueryCounts
42
+ connections: string[]
28
43
  error?: string
29
44
  }
30
45
 
46
+ export interface DashboardQueryFilters {
47
+ range: 'hour' | 'day' | 'week'
48
+ status: DashboardQueryLog['status'] | 'all'
49
+ type: DashboardQueryLog['type'] | 'all'
50
+ connection: string
51
+ page: number
52
+ perPage: number
53
+ }
54
+
31
55
  export interface DashboardQueryShowResponse {
32
56
  query: DashboardQueryLog | null
33
57
  }
34
58
 
35
- export async function fetchDashboardQueries(): Promise<DashboardQueryResponse> {
36
- return dashboardApi<DashboardQueryResponse>('/api/dashboard/queries')
59
+ export async function fetchDashboardQueries(filters: DashboardQueryFilters): Promise<DashboardQueryResponse> {
60
+ const params = new URLSearchParams({
61
+ range: filters.range,
62
+ status: filters.status,
63
+ type: filters.type,
64
+ connection: filters.connection,
65
+ page: String(filters.page),
66
+ per_page: String(filters.perPage),
67
+ })
68
+ return dashboardApi<DashboardQueryResponse>(`/api/dashboard/queries?${params}`)
37
69
  }
38
70
 
39
71
  export async function fetchDashboardQuery(id: string): Promise<DashboardQueryLog | null> {
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.480",
5
+ "version": "0.75.483",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.480",
5
+ "version": "0.75.483",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -45,9 +45,9 @@
45
45
  "dependencies": {
46
46
  "@iconify-json/f7": "^1.2.2",
47
47
  "@iconify-json/hugeicons": "^1.2.35",
48
- "@stacksjs/mobile": "0.75.480",
49
- "@stacksjs/sanitizer": "0.2.800",
50
- "@stacksjs/stx": "0.2.800",
48
+ "@stacksjs/mobile": "0.75.483",
49
+ "@stacksjs/sanitizer": "0.2.807",
50
+ "@stacksjs/stx": "0.2.807",
51
51
  "@xterm/addon-fit": "^0.11.0",
52
52
  "@xterm/xterm": "^6.0.0",
53
53
  "ts-qr-codes": "^0.1.8"
@@ -5,6 +5,10 @@ import { loadDiscoveredManifest } from '@stacksjs/defaults/resources/functions/d
5
5
  // off its models are never loaded, so the page it opens could only answer
6
6
  // 503 (stacksjs/stacks#3183).
7
7
  export const commerceAnalytics = loadDiscoveredManifest().sections.commerce
8
+
9
+ // Declared, so the client block gets it typed rather than off the implicit
10
+ // bridge, as the dashboard layout does with its role map.
11
+ defineClientPayload({ commerceAnalytics })
8
12
  </script>
9
13
 
10
14
  <script client>
@@ -1,4 +1,6 @@
1
1
  <script client>
2
+ import { withCsrfHeader } from '@stacksjs/browser/composables/csrf'
3
+
2
4
  /**
3
5
  * The magic-link interstitial.
4
6
  *
@@ -23,9 +25,12 @@ async function consume() {
23
25
 
24
26
  status.set('working')
25
27
  try {
28
+ // With the CSRF token, as every other sign-in form sends it: consume is
29
+ // a POST the server checks, and without the header every link came back
30
+ // "CSRF token mismatch" (stacksjs/stacks#3196).
26
31
  const reply = await fetch('/auth/magic-link/consume', {
27
32
  method: 'POST',
28
- headers: { 'Content-Type': 'application/json' },
33
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
29
34
  credentials: 'same-origin',
30
35
  body: JSON.stringify({ token: String(route.params.token || '') }),
31
36
  })
@@ -1,3 +1,5 @@
1
+ @import('../UI/Pagination')
2
+
1
3
  <script client>
2
4
  import { dashboardApi } from '../../../../functions/dashboard-api'
3
5
  import { pushToast } from '../../../../functions/toasts'
@@ -31,6 +33,16 @@ const deletingComment = state<CommentRecord | null>(null)
31
33
  const deleting = state(false)
32
34
  const deleteError = state('')
33
35
 
36
+ // The list is one page, searched and filtered by the server, newest first
37
+ // across both tables (stacksjs/stacks#3193). The cards count every comment.
38
+ const listPage = state(1)
39
+ const perPage = 25
40
+ const appliedSearch = state('')
41
+ const total = state(0)
42
+ const counts = state<Record<string, number>>({ all: 0, pending: 0, approved: 0, spam: 0, trash: 0 })
43
+ let listRequestId = 0
44
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
45
+
34
46
  function normalizeStatus(value: unknown): CommentStatus | null {
35
47
  const status = String(value || '').toLowerCase()
36
48
  return ['pending', 'approved', 'spam', 'trash'].includes(status) ? status as CommentStatus : null
@@ -52,17 +64,17 @@ function normalize(record: any): CommentRecord {
52
64
  }
53
65
  }
54
66
 
67
+ /** The page the server chose: its search and status are already applied. */
55
68
  function visibleComments(): CommentRecord[] {
56
- const query = search().trim().toLowerCase()
57
- return comments().filter(comment =>
58
- (statusFilter() === 'all' || comment.status === statusFilter())
59
- && (!query || [comment.content, comment.authorName, comment.authorEmail, comment.postTitle]
60
- .some(value => value.toLowerCase().includes(query))),
61
- )
69
+ return comments()
62
70
  }
63
71
 
64
72
  function statusCount(status: CommentStatus): number {
65
- return comments().filter(comment => comment.status === status).length
73
+ return Number(counts()[status] || 0)
74
+ }
75
+
76
+ function allCount(): number {
77
+ return Number(counts().all || 0)
66
78
  }
67
79
 
68
80
  function statusClass(status: CommentStatus): string {
@@ -83,24 +95,85 @@ function formatDate(value: string): string {
83
95
  : new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
84
96
  }
85
97
 
98
+ interface CommentPageResponse {
99
+ comments?: unknown[]
100
+ total?: number
101
+ page?: number
102
+ counts?: Record<string, number>
103
+ }
104
+
105
+ function listQuery(): string {
106
+ const params = new URLSearchParams({ page: String(listPage()), per_page: String(perPage) })
107
+ if (appliedSearch())
108
+ params.set('search', appliedSearch())
109
+ if (statusFilter() !== 'all')
110
+ params.set('status', statusFilter())
111
+ return params.toString()
112
+ }
113
+
86
114
  async function loadComments(): Promise<void> {
115
+ // Only the newest request may land: typing, paging and filtering can each
116
+ // start one while another is still out.
117
+ const requestId = ++listRequestId
87
118
  loading.set(true)
88
119
  loadError.set('')
89
120
  try {
90
- const data = await dashboardApi<{ comments?: unknown[] }>('/api/dashboard/comments')
121
+ const data = await dashboardApi<CommentPageResponse>(`/api/dashboard/comments?${listQuery()}`)
122
+ if (requestId !== listRequestId)
123
+ return
91
124
  if (!Array.isArray(data.comments))
92
125
  throw new TypeError('Comments were not returned in the expected shape.')
93
126
  comments.set(data.comments.map(normalize))
127
+ total.set(Number(data.total || 0))
128
+ // The server moves back to the last page when moderation or a narrower
129
+ // search leaves this one past the end.
130
+ listPage.set(Number(data.page || 1))
131
+ counts.set(data.counts && typeof data.counts === 'object' ? data.counts : {})
94
132
  }
95
133
  catch (error) {
134
+ if (requestId !== listRequestId)
135
+ return
96
136
  loadError.set(error instanceof Error ? error.message : String(error))
97
137
  pushToast('error', 'Could not load comments', { detail: loadError() })
98
138
  }
99
139
  finally {
100
- loading.set(false)
140
+ if (requestId === listRequestId)
141
+ loading.set(false)
101
142
  }
102
143
  }
103
144
 
145
+ function changePage(next: number): void {
146
+ listPage.set(next)
147
+ void loadComments()
148
+ }
149
+
150
+ // The search box and the status select stay plain x-model bindings; a change
151
+ // to either asks the server for page 1, the search once typing pauses.
152
+ let shownSearch = search()
153
+ effect(() => {
154
+ const term = search().trim()
155
+ if (term === shownSearch)
156
+ return
157
+ shownSearch = term
158
+ if (searchTimer)
159
+ clearTimeout(searchTimer)
160
+ searchTimer = setTimeout(() => {
161
+ appliedSearch.set(term)
162
+ listPage.set(1)
163
+ void loadComments()
164
+ }, 250)
165
+ })
166
+
167
+ let shownStatus = statusFilter()
168
+ effect(() => {
169
+ const status = statusFilter()
170
+ if (status === shownStatus)
171
+ return
172
+ shownStatus = status
173
+ listPage.set(1)
174
+ void loadComments()
175
+ })
176
+
104
177
  async function setStatus(comment: CommentRecord, status: CommentStatus): Promise<void> {
105
178
  updatingId.set(comment.key)
106
179
  try {
@@ -111,8 +184,10 @@ async function setStatus(comment: CommentRecord, status: CommentStatus): Promise
111
184
  // The server says what the comment now is: a trait comment has one hidden
112
185
  // state, so asking for trash comes back as spam.
113
186
  const next = normalizeStatus(updated?.status) ?? status
114
- comments.set(comments().map(record => record.key === comment.key ? { ...record, status: next } : record))
115
187
  pushToast('success', `Comment marked ${next}`)
188
+ // Reread the page: under a status filter the comment leaves it, and the
189
+ // cards move.
190
+ await loadComments()
116
191
  }
117
192
  catch (error) {
118
193
  pushToast('error', 'Could not update comment', {
@@ -146,9 +221,9 @@ async function deleteComment(): Promise<void> {
146
221
  deleteError.set('')
147
222
  try {
148
223
  await dashboardApi(`/api/dashboard/comments/${comment.id}?source=${comment.source}`, { method: 'DELETE' })
149
- comments.set(comments().filter(record => record.key !== comment.key))
150
224
  deletingComment.set(null)
151
225
  pushToast('success', 'Comment permanently deleted')
226
+ await loadComments()
152
227
  }
153
228
  catch (error) {
154
229
  deleteError.set(error instanceof Error ? error.message : String(error))
@@ -172,7 +247,7 @@ onMount(() => {
172
247
  <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="loadComments()">Retry</Button></div>
173
248
 
174
249
  <section aria-label="Comment metrics" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
175
- <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">{{ comments().length }}</p></article>
250
+ <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>
176
251
  <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">Pending</p><p class="mt-2 font-semibold text-2xl text-yellow-700 dark:text-yellow-300">{{ statusCount('pending') }}</p></article>
177
252
  <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">Approved</p><p class="mt-2 font-semibold text-2xl text-green-700 dark:text-green-300">{{ statusCount('approved') }}</p></article>
178
253
  <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">Spam or trash</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ statusCount('spam') + statusCount('trash') }}</p></article>
@@ -185,6 +260,9 @@ onMount(() => {
185
260
  <div :else-if="visibleComments().length === 0" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-comment-01"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No comments found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the search or status filter.</p></div>
186
261
  <template :else><template :for="comment in visibleComments()"><article class="p-4"><div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"><div class="min-w-0"><div class="flex flex-wrap gap-2 items-center"><p class="font-semibold text-gray-900 text-sm dark:text-white">{{ comment.authorName || 'Anonymous' }}</p><span :class="statusClass(comment.status)">{{ comment.status }}</span><span class="text-gray-500 text-xs dark:text-gray-400">{{ formatDate(comment.createdAt) }}</span></div><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ comment.authorEmail }}<span :if="comment.postTitle"> on {{ comment.postTitle }}</span></p><p class="mt-3 max-w-4xl text-gray-700 text-sm whitespace-pre-wrap dark:text-neutral-200">{{ comment.content }}</p></div><div class="flex flex-shrink-0 flex-wrap gap-2"><Button :if="comment.status !== 'approved'" variant="success" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'approved')">Approve</Button><Button :if="comment.status !== 'pending'" variant="secondary" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'pending')">Restore</Button><Button :if="comment.status !== 'spam'" variant="warning" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'spam')">Spam</Button><Button :if="comment.status !== 'trash' && comment.source === 'comments'" variant="outline" size="sm" :disabled="Boolean(updatingId())" @click="setStatus(comment, 'trash')">Trash</Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Permanently delete comment by ' + comment.authorName" :disabled="Boolean(updatingId())" @click="openDelete(comment)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></div></article></template></template>
187
262
  </div>
263
+ <div :if="total() > perPage" class="px-4 py-3 border-gray-200 border-t dark:border-neutral-700">
264
+ <Pagination :currentPage="listPage()" :totalItems="total()" :itemsPerPage="perPage" showTotalItems @page="changePage" />
265
+ </div>
188
266
  </section>
189
267
  </div>
190
268