@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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-native/CATALOG.md +7 -7
- package/ai/skills/stacks-queue/DURABLE-WORKFLOWS.md +12 -0
- package/ai/skills/stacks-server/SKILL.md +13 -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 +4 -4
- 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
|
@@ -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
|
-
/**
|
|
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<
|
|
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
|
-
|
|
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">{{
|
|
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(
|
|
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(
|
|
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">{{
|
|
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
|
-
|
|
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(
|
|
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
|
|
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="
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
120
|
-
* labels
|
|
121
|
-
*
|
|
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.
|
|
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
|
|
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<
|
|
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
|
-
|
|
247
|
+
if (requestId === listRequestId)
|
|
248
|
+
loading.set(false)
|
|
189
249
|
}
|
|
190
250
|
}
|
|
191
251
|
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
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">{{
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|