@stacksjs/defaults 0.75.243 → 0.75.245
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-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-native/CATALOG.md +9 -9
- package/ai/skills/stacks-path/SKILL.md +18 -1
- package/app/Actions/Dashboard/DashboardHomeAction.ts +7 -1
- package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +2 -1
- package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +2 -1
- package/app/Actions/Dashboard/Source/source-inventory.ts +2 -1
- package/app/Models/QueryLog.ts +10 -0
- package/functions/dashboard.ts +10 -1
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/project/storage/framework/tsconfig.app.json +8 -0
- package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +5 -5
- package/resources/components/Dashboard/Ci/CiDashboard.stx +2 -2
- package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +1 -1
- package/resources/components/Dashboard/Content/AuthorsDashboard.stx +59 -20
- package/resources/components/Dashboard/Content/BlogDashboard.stx +2 -2
- package/resources/components/Dashboard/Content/ContentDashboard.stx +2 -2
- package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +50 -18
- package/resources/components/Dashboard/Content/PagesDashboard.stx +54 -10
- package/resources/components/Dashboard/Content/PostsDashboard.stx +91 -20
- package/resources/components/Dashboard/Content/SeoDashboard.stx +61 -59
- package/resources/components/Dashboard/Email/InboxSettingsDashboard.stx +3 -3
- package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +1 -1
- package/resources/components/Dashboard/Home/DashboardHome.stx +20 -2
- package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +3 -3
- package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +2 -2
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +1 -1
- package/resources/components/Dashboard/Library/ComponentsDashboard.stx +47 -81
- package/resources/components/Dashboard/Library/FunctionsDashboard.stx +24 -15
- package/resources/components/Dashboard/Library/PackagesDashboard.stx +68 -13
- package/resources/components/Dashboard/Management/PermissionsDashboard.stx +12 -15
- package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +3 -3
- package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +3 -3
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +1 -1
- package/resources/components/Dashboard/Teams/TeamInvitationDashboard.stx +2 -2
- package/resources/components/Dashboard/UI/Button.stx +29 -3
- package/resources/components/Dashboard/UI/SearchFilter.stx +46 -0
- package/resources/components/Dashboard/UI/Table.stx +43 -20
- package/resources/functions/dashboard/data.ts +27 -4
- package/resources/functions/dashboard/sidebar.ts +3 -2
- package/resources/functions/dashboard/toggles.ts +47 -11
- package/resources/plugins/preloader.ts +9 -1
- package/views/dashboard/layouts/default.stx +17 -13
- package/views/system-tray/index.stx +1 -1
- package/resources/components/Dashboard/Commerce/Delivery/SearchFilter.stx +0 -24
- package/resources/components/Dashboard/UI/FilterBar.stx +0 -62
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
3
5
|
import { pushToast } from '../../../../functions/toasts'
|
|
@@ -23,7 +25,6 @@ const description = useReactiveProp('description', '')
|
|
|
23
25
|
const records = state<TaxonomyRecord[]>([])
|
|
24
26
|
const loading = state(true)
|
|
25
27
|
const loadError = state('')
|
|
26
|
-
const search = state('')
|
|
27
28
|
const dialogOpen = state(false)
|
|
28
29
|
const saving = state(false)
|
|
29
30
|
const saveError = state('')
|
|
@@ -46,13 +47,41 @@ function normalize(record: any): TaxonomyRecord {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Derived rather than constant: this component serves both categories and
|
|
52
|
+
* tags, and the fourth column is the post count for tags and the active state
|
|
53
|
+
* for categories - a different heading and a different value.
|
|
54
|
+
*/
|
|
55
|
+
function columns() {
|
|
56
|
+
return [
|
|
57
|
+
{ key: 'name', label: 'Name', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
|
|
58
|
+
{ key: 'slug', label: 'Slug', sortable: true, class: 'font-mono text-gray-500 dark:text-neutral-400' },
|
|
59
|
+
{ key: 'description', label: 'Description', emptyText: 'No description', class: 'max-w-md truncate text-gray-500 dark:text-neutral-400' },
|
|
60
|
+
{
|
|
61
|
+
key: 'stateLabel',
|
|
62
|
+
label: resource() === 'tags' ? 'Posts' : 'Status',
|
|
63
|
+
sortable: true,
|
|
64
|
+
class: 'text-gray-500 dark:text-neutral-400',
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ROW_ACTIONS = [
|
|
70
|
+
{ action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
/** `UI/Table` owns the search; the fourth cell is derived onto the row. */
|
|
49
74
|
function visibleRecords(): TaxonomyRecord[] {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
return records().map(record => ({
|
|
76
|
+
...record,
|
|
77
|
+
stateLabel: resource() === 'tags'
|
|
78
|
+
? String(record.postCount)
|
|
79
|
+
: record.active ? 'Active' : 'Inactive',
|
|
80
|
+
})) as TaxonomyRecord[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleRowAction(payload: { action: string, row: TaxonomyRecord }): void {
|
|
84
|
+
openDelete(payload.row)
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
function secondaryMetricLabel(): string {
|
|
@@ -184,17 +213,20 @@ onMount(() => {
|
|
|
184
213
|
</section>
|
|
185
214
|
|
|
186
215
|
<section class="overflow-hidden max-w-full min-w-0 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
<Table
|
|
217
|
+
:columns="columns()"
|
|
218
|
+
:data="visibleRecords()"
|
|
219
|
+
:loading="loading()"
|
|
220
|
+
:row-actions="ROW_ACTIONS"
|
|
221
|
+
action-label-key="name"
|
|
222
|
+
searchable
|
|
223
|
+
:search-placeholder="'Search ' + resource()"
|
|
224
|
+
paginated
|
|
225
|
+
:page-size="25"
|
|
226
|
+
:empty-title="'No ' + resource() + ' found'"
|
|
227
|
+
:empty-description="'Adjust the search or create a ' + singular() + '.'"
|
|
228
|
+
@row-action="handleRowAction"
|
|
229
|
+
/>
|
|
198
230
|
</section>
|
|
199
231
|
</div>
|
|
200
232
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
4
|
import { dashboardApi } from '../../../../functions/dashboard-api'
|
|
3
5
|
import { pushToast } from '../../../../functions/toasts'
|
|
@@ -20,7 +22,6 @@ interface PageDraft {
|
|
|
20
22
|
const pages = state<PageRecord[]>([])
|
|
21
23
|
const loading = state(true)
|
|
22
24
|
const loadError = state('')
|
|
23
|
-
const search = state('')
|
|
24
25
|
const formOpen = state(false)
|
|
25
26
|
const editingPage = state<PageRecord | null>(null)
|
|
26
27
|
const saving = state(false)
|
|
@@ -40,10 +41,45 @@ function normalize(record: any): PageRecord {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
const COLUMNS = [
|
|
45
|
+
{ key: 'title', label: 'Page', sortable: true, subtitleKey: 'template', class: 'font-medium text-gray-900 dark:text-white' },
|
|
46
|
+
{ key: 'viewsLabel', label: 'Traffic', subtitleKey: 'conversionsLabel', class: 'text-gray-500 dark:text-neutral-400' },
|
|
47
|
+
{
|
|
48
|
+
key: 'publishState',
|
|
49
|
+
label: 'Publishing',
|
|
50
|
+
type: 'badge' as const,
|
|
51
|
+
sortable: true,
|
|
52
|
+
// Coloured by state, labelled with the date it happened - which is why
|
|
53
|
+
// `badgeLabels` cannot express it.
|
|
54
|
+
badgeLabelKey: 'publishedLabel',
|
|
55
|
+
badgeClasses: {
|
|
56
|
+
published: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
|
|
57
|
+
draft: 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const ROW_ACTIONS = [
|
|
63
|
+
{ action: 'edit', label: 'Edit page', icon: 'i-hugeicons-edit-02' },
|
|
64
|
+
{ action: 'delete', label: 'Delete page', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
/** `UI/Table` owns the search; the cells each read a field, so derive them. */
|
|
43
68
|
function visiblePages(): PageRecord[] {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
69
|
+
return pages().map(page => ({
|
|
70
|
+
...page,
|
|
71
|
+
viewsLabel: `${formatNumber(page.views)} views`,
|
|
72
|
+
conversionsLabel: `${formatNumber(page.conversions)} conversions`,
|
|
73
|
+
publishState: page.publishedAt ? 'published' : 'draft',
|
|
74
|
+
publishedLabel: formatDate(page.publishedAt),
|
|
75
|
+
})) as PageRecord[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleRowAction(payload: { action: string, row: PageRecord }): void {
|
|
79
|
+
if (payload.action === 'edit')
|
|
80
|
+
openEdit(payload.row)
|
|
81
|
+
else
|
|
82
|
+
openDelete(payload.row)
|
|
47
83
|
}
|
|
48
84
|
|
|
49
85
|
function totalViews(): number {
|
|
@@ -179,12 +215,20 @@ onMount(() => {
|
|
|
179
215
|
</section>
|
|
180
216
|
|
|
181
217
|
<section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
218
|
+
<Table
|
|
219
|
+
:columns="COLUMNS"
|
|
220
|
+
:data="visiblePages()"
|
|
221
|
+
:loading="loading()"
|
|
222
|
+
:row-actions="ROW_ACTIONS"
|
|
223
|
+
action-label-key="title"
|
|
224
|
+
searchable
|
|
225
|
+
search-placeholder="Search pages"
|
|
226
|
+
paginated
|
|
227
|
+
:page-size="25"
|
|
228
|
+
empty-title="No pages found"
|
|
229
|
+
empty-description="Adjust the search or create a page."
|
|
230
|
+
@row-action="handleRowAction"
|
|
231
|
+
/>
|
|
188
232
|
</section>
|
|
189
233
|
</div>
|
|
190
234
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script server>
|
|
2
4
|
import { publicApplicationUrl } from '../../../../functions/public-application-url'
|
|
3
5
|
|
|
@@ -43,7 +45,6 @@ const tags = state<OptionRecord[]>([])
|
|
|
43
45
|
const authors = state<AuthorRecord[]>([])
|
|
44
46
|
const loading = state(true)
|
|
45
47
|
const loadError = state('')
|
|
46
|
-
const search = state('')
|
|
47
48
|
const statusFilter = state('all')
|
|
48
49
|
|
|
49
50
|
const formOpen = state(false)
|
|
@@ -88,12 +89,55 @@ function normalizeAuthor(record: any): AuthorRecord {
|
|
|
88
89
|
return { id: String(record?.id || ''), name: String(record?.name || ''), email: String(record?.email || '') }
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
const COLUMNS = [
|
|
93
|
+
// Two slotted columns, so the single `cell` slot dispatches on `column.key`:
|
|
94
|
+
// the title carries a featured star and two quieter lines, and taxonomy
|
|
95
|
+
// labels its two lines "Categories:" and "Tags:".
|
|
96
|
+
{ key: 'title', label: 'Post', sortable: true, slotted: true, class: 'max-w-md' },
|
|
97
|
+
{ key: 'categoriesLabel', label: 'Taxonomy', slotted: true, class: 'max-w-xs' },
|
|
98
|
+
{
|
|
99
|
+
key: 'status',
|
|
100
|
+
label: 'Publishing',
|
|
101
|
+
type: 'badge' as const,
|
|
102
|
+
sortable: true,
|
|
103
|
+
subtitleKey: 'publishedLabel',
|
|
104
|
+
badgeClasses: {
|
|
105
|
+
published: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
|
|
106
|
+
draft: 'text-yellow-700 dark:text-yellow-300 bg-yellow-50 dark:bg-yellow-950/40',
|
|
107
|
+
archived: 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{ key: 'views', label: 'Views', type: 'number' as const, sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
const ROW_ACTIONS = [
|
|
114
|
+
{ action: 'edit', label: 'Edit post', icon: 'i-hugeicons-edit-02' },
|
|
115
|
+
{ action: 'delete', label: 'Delete post', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `UI/Table` owns the search, so this applies only the status filter. The
|
|
120
|
+
* labels are derived onto the row because a slot body reads fields rather than
|
|
121
|
+
* calling back into the component.
|
|
122
|
+
*/
|
|
91
123
|
function visiblePosts(): PostRecord[] {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
(
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
return posts()
|
|
125
|
+
.filter(post => statusFilter() === 'all' || post.status === statusFilter())
|
|
126
|
+
.map(post => ({
|
|
127
|
+
...post,
|
|
128
|
+
summaryLabel: post.excerpt || post.content,
|
|
129
|
+
authorLabel: authorName(post),
|
|
130
|
+
categoriesLabel: optionNames(post.categoryIds, categories()),
|
|
131
|
+
tagsLabel: optionNames(post.tagIds, tags()),
|
|
132
|
+
publishedLabel: formatDate(post.publishedAt),
|
|
133
|
+
})) as PostRecord[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function handleRowAction(payload: { action: string, row: PostRecord }): void {
|
|
137
|
+
if (payload.action === 'edit')
|
|
138
|
+
openEdit(payload.row)
|
|
139
|
+
else
|
|
140
|
+
openDelete(payload.row)
|
|
97
141
|
}
|
|
98
142
|
|
|
99
143
|
function postCount(value: PostStatus): number {
|
|
@@ -115,14 +159,6 @@ function formatDate(value: string): string {
|
|
|
115
159
|
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
|
|
116
160
|
}
|
|
117
161
|
|
|
118
|
-
function statusClass(value: PostStatus): string {
|
|
119
|
-
return `inline-flex px-2 py-1 font-medium text-xs rounded-full ${({
|
|
120
|
-
published: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
|
|
121
|
-
draft: 'text-yellow-700 dark:text-yellow-300 bg-yellow-50 dark:bg-yellow-950/40',
|
|
122
|
-
archived: 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700',
|
|
123
|
-
}[value])}`
|
|
124
|
-
}
|
|
125
|
-
|
|
126
162
|
function authorName(post: PostRecord): string {
|
|
127
163
|
return post.authorId ? authors().find(author => author.id === post.authorId)?.name || 'Unknown author' : 'No author'
|
|
128
164
|
}
|
|
@@ -224,12 +260,47 @@ onMount(() => { void loadPosts() })
|
|
|
224
260
|
</section>
|
|
225
261
|
|
|
226
262
|
<section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
263
|
+
<Table
|
|
264
|
+
:columns="COLUMNS"
|
|
265
|
+
:data="visiblePosts()"
|
|
266
|
+
:loading="loading()"
|
|
267
|
+
:row-actions="ROW_ACTIONS"
|
|
268
|
+
action-label-key="title"
|
|
269
|
+
searchable
|
|
270
|
+
search-placeholder="Search title, excerpt, or content"
|
|
271
|
+
paginated
|
|
272
|
+
:page-size="25"
|
|
273
|
+
empty-title="No posts found"
|
|
274
|
+
empty-description="Adjust the search or status filter."
|
|
275
|
+
@row-action="handleRowAction"
|
|
276
|
+
>
|
|
277
|
+
<template #toolbar>
|
|
278
|
+
<label>
|
|
279
|
+
<span class="sr-only">Filter by status</span>
|
|
280
|
+
<select x-model="statusFilter" class="px-2.5 py-1.5 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600">
|
|
281
|
+
<option value="all">All statuses</option>
|
|
282
|
+
<option value="published">Published</option>
|
|
283
|
+
<option value="draft">Draft</option>
|
|
284
|
+
<option value="archived">Archived</option>
|
|
285
|
+
</select>
|
|
286
|
+
</label>
|
|
287
|
+
</template>
|
|
288
|
+
|
|
289
|
+
<template #cell="{ row, column }">
|
|
290
|
+
<template :if="column.key === 'title'">
|
|
291
|
+
<div class="flex gap-2 items-center">
|
|
292
|
+
<p class="font-medium text-gray-900 text-sm truncate dark:text-white" x-text="row.title"></p>
|
|
293
|
+
<span :if="row.featured" x-tooltip="Featured" title="Featured" class="flex-shrink-0 h-4 w-4 text-amber-500 i-hugeicons-star"></span>
|
|
294
|
+
</div>
|
|
295
|
+
<p class="mt-1 text-gray-500 text-xs truncate dark:text-neutral-400" x-text="row.summaryLabel"></p>
|
|
296
|
+
<p class="mt-1 text-gray-400 text-xs dark:text-neutral-500" x-text="row.authorLabel"></p>
|
|
297
|
+
</template>
|
|
298
|
+
<template :else>
|
|
299
|
+
<p class="text-gray-500 text-xs truncate dark:text-neutral-400"><span class="font-medium text-gray-700 dark:text-neutral-200">Categories:</span> <span x-text="row.categoriesLabel"></span></p>
|
|
300
|
+
<p class="mt-1 text-gray-500 text-xs truncate dark:text-neutral-400"><span class="font-medium text-gray-700 dark:text-neutral-200">Tags:</span> <span x-text="row.tagsLabel"></span></p>
|
|
301
|
+
</template>
|
|
302
|
+
</template>
|
|
303
|
+
</Table>
|
|
233
304
|
</section>
|
|
234
305
|
</div>
|
|
235
306
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
@import('../UI/Table')
|
|
2
|
+
|
|
1
3
|
<script client>
|
|
2
|
-
import { dashboardApi } from '
|
|
3
|
-
import { pushToast } from '
|
|
4
|
+
import { dashboardApi } from '@stacksjs/defaults/functions/dashboard-api.ts'
|
|
5
|
+
import { pushToast } from '@stacksjs/defaults/functions/toasts.ts'
|
|
4
6
|
|
|
5
7
|
interface PostRow {
|
|
6
8
|
id: number
|
|
@@ -117,6 +119,42 @@ const filteredEntries = derived(() => {
|
|
|
117
119
|
})
|
|
118
120
|
})
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The score pill is banded by range, not by value, so `badgeClasses` is keyed
|
|
124
|
+
* on a derived band and `badgeLabelKey` puts the number itself in the pill.
|
|
125
|
+
*/
|
|
126
|
+
const SEO_ROW_ACTIONS = [
|
|
127
|
+
{ action: 'edit', label: 'Edit SEO for', icon: 'i-hugeicons-edit-02' },
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
const SEO_COLUMNS = [
|
|
131
|
+
{ key: 'title', label: 'Post', sortable: true, subtitleKey: 'postMetaLabel', class: 'font-medium text-neutral-900 dark:text-white' },
|
|
132
|
+
{ key: 'focus_keyword', label: 'Focus keyword', sortable: true, emptyText: 'Not set', class: 'text-neutral-600 dark:text-neutral-300' },
|
|
133
|
+
{ key: 'meta_description', label: 'Meta description', emptyText: 'Not set', class: 'max-w-md truncate text-neutral-600 dark:text-neutral-300' },
|
|
134
|
+
{
|
|
135
|
+
key: 'scoreBand',
|
|
136
|
+
label: 'Score',
|
|
137
|
+
type: 'badge' as const,
|
|
138
|
+
align: 'right' as const,
|
|
139
|
+
sortable: true,
|
|
140
|
+
badgeLabelKey: 'score',
|
|
141
|
+
badgeClasses: {
|
|
142
|
+
good: 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200',
|
|
143
|
+
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-200',
|
|
144
|
+
poor: 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{ key: 'updatedLabel', label: 'Updated', sortable: true, class: 'whitespace-nowrap text-neutral-500 dark:text-neutral-400' },
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
/** The page's own filter bar still owns search and the score filter. */
|
|
151
|
+
const seoRows = derived(() => filteredEntries().map(entry => ({
|
|
152
|
+
...entry,
|
|
153
|
+
postMetaLabel: `Post #${entry.id} \u00B7 ${entry.status || 'unknown'}`,
|
|
154
|
+
scoreBand: entry.score >= 80 ? 'good' : entry.score >= 50 ? 'medium' : 'poor',
|
|
155
|
+
updatedLabel: formatDate(entry.updated_at || entry.created_at),
|
|
156
|
+
})))
|
|
157
|
+
|
|
120
158
|
const totalPosts = derived(() => entries().length)
|
|
121
159
|
const optimizedCount = derived(() => entries().filter(entry => entry.score >= 80).length)
|
|
122
160
|
const needsWorkCount = derived(() => entries().filter(entry => entry.score < 50).length)
|
|
@@ -126,15 +164,6 @@ const avgScore = derived(() => {
|
|
|
126
164
|
return Math.round(entries().reduce((total, entry) => total + entry.score, 0) / entries().length)
|
|
127
165
|
})
|
|
128
166
|
|
|
129
|
-
function scoreBadgeClass(score: number): string {
|
|
130
|
-
const base = 'inline-flex items-center px-2 py-0.5 font-semibold text-xs rounded-full'
|
|
131
|
-
if (score >= 80)
|
|
132
|
-
return `${base} bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200`
|
|
133
|
-
if (score >= 50)
|
|
134
|
-
return `${base} bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-200`
|
|
135
|
-
return `${base} bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200`
|
|
136
|
-
}
|
|
137
|
-
|
|
138
167
|
function formatDate(value?: string | null): string {
|
|
139
168
|
if (!value)
|
|
140
169
|
return 'Not updated'
|
|
@@ -261,54 +290,27 @@ void loadEntries()
|
|
|
261
290
|
@endif
|
|
262
291
|
|
|
263
292
|
<Card variant="default" padding="none">
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
<th class="px-4 py-3"><span class="sr-only">Actions</span></th>
|
|
286
|
-
</tr>
|
|
287
|
-
</thead>
|
|
288
|
-
<tbody class="divide-neutral-200/60 divide-y dark:divide-white/5">
|
|
289
|
-
<template :for="entry in filteredEntries">
|
|
290
|
-
<tr class="hover:bg-neutral-50 dark:hover:bg-white/[0.03]">
|
|
291
|
-
<td class="px-4 py-4">
|
|
292
|
-
<div class="font-medium text-neutral-900 text-sm dark:text-white">{{ entry.title }}</div>
|
|
293
|
-
<div class="mt-0.5 text-neutral-500 text-xs dark:text-neutral-400">Post #{{ entry.id }} · {{ entry.status || 'unknown' }}</div>
|
|
294
|
-
</td>
|
|
295
|
-
<td class="px-4 py-4 text-neutral-600 text-sm dark:text-neutral-300">{{ entry.focus_keyword || 'Not set' }}</td>
|
|
296
|
-
<td class="px-4 py-4">
|
|
297
|
-
<div class="max-w-md text-neutral-600 text-sm truncate dark:text-neutral-300">{{ entry.meta_description || 'Not set' }}</div>
|
|
298
|
-
</td>
|
|
299
|
-
<td class="px-4 py-4 text-right">
|
|
300
|
-
<span :class="scoreBadgeClass(entry.score)">{{ entry.score }}</span>
|
|
301
|
-
</td>
|
|
302
|
-
<td class="px-4 py-4 text-neutral-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ formatDate(entry.updated_at || entry.created_at) }}</td>
|
|
303
|
-
<td class="px-4 py-4 text-right">
|
|
304
|
-
<Button variant="ghost" size="sm" @click="openEdit(entry)">Edit</Button>
|
|
305
|
-
</td>
|
|
306
|
-
</tr>
|
|
307
|
-
</template>
|
|
308
|
-
</tbody>
|
|
309
|
-
</table>
|
|
310
|
-
</div>
|
|
311
|
-
@endif
|
|
293
|
+
{{-- `UI/Table` renders its own loading and empty states, so the three-way
|
|
294
|
+
block this replaced is gone.
|
|
295
|
+
|
|
296
|
+
The edit control is a declarative `rowActions` icon rather than the
|
|
297
|
+
`actions` slot, which would have kept the "Edit" wording. The slot
|
|
298
|
+
hangs the page render outright on the installed stx - verified by
|
|
299
|
+
adding one to a working table, watching `/dependencies` stop
|
|
300
|
+
responding, and removing it again. See stacksjs/stx#2101. --}}
|
|
301
|
+
<Table
|
|
302
|
+
:columns="SEO_COLUMNS"
|
|
303
|
+
:data="seoRows()"
|
|
304
|
+
:loading="loading()"
|
|
305
|
+
row-key="id"
|
|
306
|
+
paginated
|
|
307
|
+
:page-size="25"
|
|
308
|
+
empty-title="No matching posts"
|
|
309
|
+
empty-description="Adjust the search or score filter, or create a CMS post first."
|
|
310
|
+
:row-actions="SEO_ROW_ACTIONS"
|
|
311
|
+
action-label-key="title"
|
|
312
|
+
@row-action="openEdit($event.row)"
|
|
313
|
+
/>
|
|
312
314
|
</Card>
|
|
313
315
|
|
|
314
316
|
<SeoEditorDialog
|
|
@@ -262,9 +262,9 @@ import type {
|
|
|
262
262
|
MailTheme,
|
|
263
263
|
NotificationSound,
|
|
264
264
|
ReplyBehavior,
|
|
265
|
-
} from '
|
|
266
|
-
import { fetchInboxActivity } from '
|
|
267
|
-
import { fetchMailPreference, saveMailPreference } from '
|
|
265
|
+
} from '@stacksjs/defaults/functions/mail-preferences.ts'
|
|
266
|
+
import { fetchInboxActivity } from '@stacksjs/defaults/functions/inbox.ts'
|
|
267
|
+
import { fetchMailPreference, saveMailPreference } from '@stacksjs/defaults/functions/mail-preferences.ts'
|
|
268
268
|
|
|
269
269
|
interface SettingsSection {
|
|
270
270
|
id: string
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script client>
|
|
2
|
-
import { DashboardApiError, dashboardApi } from '
|
|
2
|
+
import { DashboardApiError, dashboardApi } from '@stacksjs/defaults/functions/dashboard-api.ts'
|
|
3
3
|
|
|
4
4
|
interface EnvironmentFileIssue {
|
|
5
5
|
line: number
|
|
@@ -4,13 +4,20 @@ import { useDashboard } from '../../../../functions/dashboard'
|
|
|
4
4
|
const dashboard = useDashboard()
|
|
5
5
|
const loadingCards = [1, 2, 3, 4]
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* `section` names the feature-backed dashboard section a card belongs to.
|
|
9
|
+
* A card with one is only offered when the server says that section exists -
|
|
10
|
+
* otherwise the home page links to pages the sidebar has hidden and that
|
|
11
|
+
* cannot load. See stacksjs/stacks#2928.
|
|
12
|
+
*/
|
|
13
|
+
const allQuickLinks = [
|
|
8
14
|
{
|
|
9
15
|
title: 'Blog',
|
|
10
16
|
description: 'Manage persisted posts',
|
|
11
17
|
href: '/content/dashboard',
|
|
12
18
|
icon: 'i-hugeicons-book-open-01',
|
|
13
19
|
tone: 'blue',
|
|
20
|
+
section: 'content',
|
|
14
21
|
},
|
|
15
22
|
{
|
|
16
23
|
title: 'Commerce',
|
|
@@ -18,6 +25,7 @@ const quickLinks = [
|
|
|
18
25
|
href: '/commerce/dashboard',
|
|
19
26
|
icon: 'i-hugeicons-store-01',
|
|
20
27
|
tone: 'green',
|
|
28
|
+
section: 'commerce',
|
|
21
29
|
},
|
|
22
30
|
{
|
|
23
31
|
title: 'Cloud',
|
|
@@ -35,6 +43,16 @@ const quickLinks = [
|
|
|
35
43
|
},
|
|
36
44
|
]
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Cards without a `section` are always offered. A card with one waits for the
|
|
48
|
+
* server to say that section exists, so the home page never links to a
|
|
49
|
+
* section the sidebar has hidden and whose pages cannot load.
|
|
50
|
+
*/
|
|
51
|
+
function quickLinks() {
|
|
52
|
+
const available = dashboard.sections.value
|
|
53
|
+
return allQuickLinks.filter(link => !link.section || available[link.section] !== false)
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
const resourceLinks = [
|
|
39
57
|
{
|
|
40
58
|
title: 'Documentation',
|
|
@@ -153,7 +171,7 @@ onMount(() => {
|
|
|
153
171
|
</div>
|
|
154
172
|
</section>
|
|
155
173
|
|
|
156
|
-
<QuickLinks class="mb-8" :links="quickLinks" />
|
|
174
|
+
<QuickLinks class="mb-8" :links="quickLinks()" />
|
|
157
175
|
|
|
158
176
|
<div class="grid gap-6 grid-cols-1 lg:grid-cols-2 mt-8">
|
|
159
177
|
<ServiceHealth :services="services()" :loading="dashboard.isLoading.value" />
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
// Both column reorder (drag the column header) and card reorder
|
|
13
13
|
// (drag the card body) share the same /cards/reorder + /columns/reorder
|
|
14
14
|
// mutations on the store.
|
|
15
|
-
import { useNavigation } from '
|
|
16
|
-
import { useRole } from '
|
|
17
|
-
import { kanbanStore } from '
|
|
15
|
+
import { useNavigation } from '@stacksjs/defaults/views/dashboard/composables/useNavigation.ts'
|
|
16
|
+
import { useRole } from '@stacksjs/defaults/views/dashboard/composables/useRole.ts'
|
|
17
|
+
import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban.ts'
|
|
18
18
|
|
|
19
19
|
// The component owns its store dependency. Importing the definition creates
|
|
20
20
|
// and registers the singleton before this component controller executes,
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// Follows #1838 conventions: no <script server>, no vanilla DOM
|
|
6
6
|
// reads, all state via the store. Role gate via useRole() - the
|
|
7
7
|
// surface is dev-mode-only (admins + devs see it, clients don't).
|
|
8
|
-
import { useRole } from '
|
|
9
|
-
import { kanbanStore } from '
|
|
8
|
+
import { useRole } from '@stacksjs/defaults/views/dashboard/composables/useRole.ts'
|
|
9
|
+
import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban.ts'
|
|
10
10
|
|
|
11
11
|
// Keep the page component self-contained: the imported store definition is
|
|
12
12
|
// registered before this controller runs on both direct and SPA navigation.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script client>
|
|
2
|
-
import { kanbanStore } from '
|
|
2
|
+
import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban.ts'
|
|
3
3
|
import { useTimeoutFn } from '@stacksjs/composables'
|
|
4
4
|
|
|
5
5
|
const kanban = kanbanStore
|