@stacksjs/defaults 0.75.482 → 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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-native/CATALOG.md +4 -4
- package/ai/skills/stacks-queue/DURABLE-WORKFLOWS.md +12 -0
- package/app/Actions/Dashboard/Content/CommentIndexAction.ts +22 -112
- package/app/Actions/Dashboard/Content/PageIndexAction.ts +26 -25
- package/app/Actions/Dashboard/Content/PostIndexAction.ts +30 -35
- package/app/Actions/Dashboard/Content/PostShowAction.ts +41 -0
- package/app/Actions/Dashboard/Content/comment-input.ts +25 -1
- package/app/Actions/Dashboard/Content/comment-list.ts +311 -0
- package/app/Actions/Dashboard/Content/content-list.ts +69 -0
- package/app/Actions/Dashboard/Content/page-list.ts +108 -0
- package/app/Actions/Dashboard/Content/post-list.ts +140 -0
- package/app/Actions/Dashboard/Jobs/JobIndexAction.ts +12 -24
- package/app/Actions/Dashboard/Jobs/job-list.ts +168 -0
- package/app/Actions/Dashboard/Jobs/job-records.ts +8 -3
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +8 -2
- package/app/Actions/Dashboard/Queries/QueryDashboardAction.ts +20 -10
- package/app/Actions/Dashboard/Queries/query-dashboard.ts +56 -9
- package/app/Actions/Dashboard/Queries/query-list.ts +161 -0
- package/app/Actions/Dashboard/Queue/QueueStatsAction.ts +14 -52
- package/app/Actions/Dashboard/Queue/queue-stats.ts +69 -0
- package/app/Actions/Monitoring/ErrorIndexAction.ts +9 -4
- package/app/Actions/Monitoring/error-provider.ts +85 -7
- package/functions/api.ts +14 -10
- package/functions/commerce/coupons.ts +4 -6
- package/functions/commerce/customers.ts +4 -6
- package/functions/commerce/gift-cards.ts +4 -2
- package/functions/commerce/orders.ts +4 -2
- package/functions/commerce/payments.ts +4 -2
- package/functions/commerce/products/categories.ts +4 -6
- package/functions/commerce/products/manufacturers.ts +4 -6
- package/functions/commerce/products/products.ts +4 -6
- package/functions/commerce/products/reviews.ts +4 -6
- package/functions/commerce/products/units.ts +4 -6
- package/functions/commerce/products/variants.ts +4 -6
- package/functions/commerce/taxes.ts +4 -2
- package/functions/commerce/waitlist/products.ts +4 -6
- package/functions/commerce/waitlist/restaurants.ts +4 -6
- package/functions/monitoring/errors.ts +77 -49
- package/functions/queries.ts +34 -2
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Analytics/AnalyticsHub.stx +4 -0
- package/resources/components/Dashboard/Auth/MagicLink.stx +6 -1
- package/resources/components/Dashboard/Content/CommentsDashboard.stx +90 -12
- package/resources/components/Dashboard/Content/PagesDashboard.stx +100 -16
- package/resources/components/Dashboard/Content/PostEditorDialog.stx +28 -8
- package/resources/components/Dashboard/Content/PostsDashboard.stx +179 -21
- package/resources/components/Dashboard/Monitoring/ErrorDashboard.stx +121 -36
- package/resources/components/Dashboard/Monitoring/ErrorTable.stx +29 -8
- package/resources/components/Dashboard/Queries/QueryDashboard.stx +73 -64
- package/resources/components/Dashboard/UI/Table.stx +30 -7
- package/routes/dashboard-api.ts +1 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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,
|
package/functions/queries.ts
CHANGED
|
@@ -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
|
-
|
|
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> {
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
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.
|
|
5
|
+
"version": "0.75.483",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@iconify-json/f7": "^1.2.2",
|
|
47
47
|
"@iconify-json/hugeicons": "^1.2.35",
|
|
48
|
-
"@stacksjs/mobile": "0.75.
|
|
48
|
+
"@stacksjs/mobile": "0.75.483",
|
|
49
49
|
"@stacksjs/sanitizer": "0.2.807",
|
|
50
50
|
"@stacksjs/stx": "0.2.807",
|
|
51
51
|
"@xterm/addon-fit": "^0.11.0",
|
|
@@ -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
|
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
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">{{
|
|
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
|
|