@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.
Files changed (47) hide show
  1. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  2. package/ai/skills/stacks-native/CATALOG.md +9 -9
  3. package/ai/skills/stacks-path/SKILL.md +18 -1
  4. package/app/Actions/Dashboard/DashboardHomeAction.ts +7 -1
  5. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +2 -1
  6. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +2 -1
  7. package/app/Actions/Dashboard/Source/source-inventory.ts +2 -1
  8. package/app/Models/QueryLog.ts +10 -0
  9. package/functions/dashboard.ts +10 -1
  10. package/ide/vscode/package.json +1 -1
  11. package/package.json +2 -2
  12. package/project/storage/framework/tsconfig.app.json +8 -0
  13. package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +5 -5
  14. package/resources/components/Dashboard/Ci/CiDashboard.stx +2 -2
  15. package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +1 -1
  16. package/resources/components/Dashboard/Content/AuthorsDashboard.stx +59 -20
  17. package/resources/components/Dashboard/Content/BlogDashboard.stx +2 -2
  18. package/resources/components/Dashboard/Content/ContentDashboard.stx +2 -2
  19. package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +50 -18
  20. package/resources/components/Dashboard/Content/PagesDashboard.stx +54 -10
  21. package/resources/components/Dashboard/Content/PostsDashboard.stx +91 -20
  22. package/resources/components/Dashboard/Content/SeoDashboard.stx +61 -59
  23. package/resources/components/Dashboard/Email/InboxSettingsDashboard.stx +3 -3
  24. package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +1 -1
  25. package/resources/components/Dashboard/Home/DashboardHome.stx +20 -2
  26. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +3 -3
  27. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +2 -2
  28. package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +1 -1
  29. package/resources/components/Dashboard/Library/ComponentsDashboard.stx +47 -81
  30. package/resources/components/Dashboard/Library/FunctionsDashboard.stx +24 -15
  31. package/resources/components/Dashboard/Library/PackagesDashboard.stx +68 -13
  32. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +12 -15
  33. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +3 -3
  34. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +3 -3
  35. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +1 -1
  36. package/resources/components/Dashboard/Teams/TeamInvitationDashboard.stx +2 -2
  37. package/resources/components/Dashboard/UI/Button.stx +29 -3
  38. package/resources/components/Dashboard/UI/SearchFilter.stx +46 -0
  39. package/resources/components/Dashboard/UI/Table.stx +43 -20
  40. package/resources/functions/dashboard/data.ts +27 -4
  41. package/resources/functions/dashboard/sidebar.ts +3 -2
  42. package/resources/functions/dashboard/toggles.ts +47 -11
  43. package/resources/plugins/preloader.ts +9 -1
  44. package/views/dashboard/layouts/default.stx +17 -13
  45. package/views/system-tray/index.stx +1 -1
  46. package/resources/components/Dashboard/Commerce/Delivery/SearchFilter.stx +0 -24
  47. 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
- const query = search().trim().toLowerCase()
51
- return records().filter(record => !query || [
52
- record.name,
53
- record.slug,
54
- record.description,
55
- ].some(value => value.toLowerCase().includes(query)))
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
- <div class="p-4 border-b border-gray-200 dark:border-neutral-700"><label><span class="sr-only">Search {{ resource() }}</span><input x-model="search" type="search" :placeholder="'Search ' + resource()" class="px-3 py-2 max-w-md w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label></div>
188
- <div class="overflow-x-auto max-w-full w-full">
189
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
190
- <thead class="bg-gray-50 dark:bg-neutral-900/50"><tr><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Name</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Slug</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Description</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">{{ resource() === 'tags' ? 'Posts' : 'Status' }}</th><th class="px-4 py-3"><span class="sr-only">Actions</span></th></tr></thead>
191
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
192
- <template :if="loading()"><tr><td colspan="5" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400"><span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading {{ resource() }}</td></tr></template>
193
- <template :else-if="visibleRecords().length === 0"><tr><td colspan="5" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-tags"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No {{ resource() }} found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the search or create a {{ singular() }}.</p></td></tr></template>
194
- <template :else><template :for="record in visibleRecords()"><tr><td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ record.name }}</td><td class="px-4 py-3 font-mono text-gray-500 text-sm dark:text-neutral-400">{{ record.slug }}</td><td class="px-4 py-3 max-w-md text-gray-500 text-sm truncate dark:text-neutral-400">{{ record.description || 'No description' }}</td><td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ resource() === 'tags' ? record.postCount : record.active ? 'Active' : 'Inactive' }}</td><td class="px-4 py-3 text-right"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + singular() + ' ' + record.name" @click="openDelete(record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></td></tr></template></template>
195
- </tbody>
196
- </table>
197
- </div>
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
- const query = search().trim().toLowerCase()
45
- return pages().filter(page => !query || [page.title, page.template]
46
- .some(value => value.toLowerCase().includes(query)))
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
- <div class="p-4 border-b border-gray-200 dark:border-neutral-700"><label><span class="sr-only">Search pages</span><input x-model="search" type="search" placeholder="Search pages" class="px-3 py-2 max-w-md w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label></div>
183
- <div class="overflow-x-auto"><table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700"><thead class="bg-gray-50 dark:bg-neutral-900/50"><tr><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Page</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Traffic</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Publishing</th><th class="px-4 py-3"><span class="sr-only">Actions</span></th></tr></thead><tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
184
- <template :if="loading()"><tr><td colspan="4" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400"><span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading pages</td></tr></template>
185
- <template :else-if="visiblePages().length === 0"><tr><td colspan="4" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-file-02"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No pages found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the search or create a page.</p></td></tr></template>
186
- <template :else><template :for="page in visiblePages()"><tr><td class="px-4 py-3"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ page.title }}</p><p class="mt-1 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ page.template }}</p></td><td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400"><p>{{ formatNumber(page.views) }} views</p><p class="mt-1 text-xs">{{ formatNumber(page.conversions) }} conversions</p></td><td class="px-4 py-3"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + (page.publishedAt ? 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40' : 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700')">{{ formatDate(page.publishedAt) }}</span></td><td class="px-4 py-3"><div class="flex justify-end"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit page ' + page.title" @click="openEdit(page)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-02"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete page ' + page.title" @click="openDelete(page)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></td></tr></template></template>
187
- </tbody></table></div>
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
- const query = search().trim().toLowerCase()
93
- return posts().filter(post =>
94
- (statusFilter() === 'all' || post.status === statusFilter())
95
- && (!query || [post.title, post.excerpt, post.content].some(value => value.toLowerCase().includes(query))),
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
- <div class="flex flex-col gap-3 sm:flex-row p-4 border-b border-gray-200 dark:border-neutral-700"><label class="flex-1"><span class="sr-only">Search posts</span><input x-model="search" type="search" placeholder="Search title, excerpt, or content" class="px-3 py-2 max-w-xl w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label><label><span class="sr-only">Filter by status</span><select x-model="statusFilter" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700"><option value="all">All statuses</option><option value="published">Published</option><option value="draft">Draft</option><option value="archived">Archived</option></select></label></div>
228
- <div class="overflow-x-auto"><table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700"><thead class="bg-gray-50 dark:bg-neutral-900/50"><tr><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Post</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Taxonomy</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Publishing</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Views</th><th class="px-4 py-3"><span class="sr-only">Actions</span></th></tr></thead><tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
229
- <template :if="loading()"><tr><td colspan="5" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400"><span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Loading posts</td></tr></template>
230
- <template :else-if="visiblePosts().length === 0"><tr><td colspan="5" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-quill-write-02"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No posts found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters or create a post.</p></td></tr></template>
231
- <template :else><template :for="post in visiblePosts()"><tr><td class="px-4 py-3 max-w-md"><div class="flex gap-2 items-center"><p class="font-medium text-gray-900 text-sm truncate dark:text-white">{{ post.title }}</p><span :if="post.featured" x-tooltip="Featured" title="Featured" class="flex-shrink-0 h-4 w-4 text-amber-500 i-hugeicons-star"></span></div><p class="mt-1 text-gray-500 text-xs truncate dark:text-neutral-400">{{ post.excerpt || post.content }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ authorName(post) }}</p></td><td class="px-4 py-3 max-w-xs text-gray-500 text-xs dark:text-neutral-400"><p class="truncate"><span class="font-medium text-gray-700 dark:text-neutral-200">Categories:</span> {{ optionNames(post.categoryIds, categories()) }}</p><p class="mt-1 truncate"><span class="font-medium text-gray-700 dark:text-neutral-200">Tags:</span> {{ optionNames(post.tagIds, tags()) }}</p></td><td class="px-4 py-3"><span :class="statusClass(post.status)">{{ post.status }}</span><p class="mt-2 text-gray-400 text-xs dark:text-neutral-500">{{ formatDate(post.publishedAt) }}</p></td><td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ formatNumber(post.views) }}</td><td class="px-4 py-3"><div class="flex justify-end"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit post ' + post.title" @click="openEdit(post)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-02"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete post ' + post.title" @click="openDelete(post)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></td></tr></template></template>
232
- </tbody></table></div>
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 '~/storage/framework/defaults/functions/dashboard-api'
3
- import { pushToast } from '~/storage/framework/defaults/functions/toasts'
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
- @if(loading())
265
- <div class="flex items-center justify-center min-h-64 text-neutral-500 text-sm dark:text-neutral-400">
266
- <span class="mr-2 h-5 w-5 animate-spin i-hugeicons-loading-03"></span>
267
- Loading post metadata
268
- </div>
269
- @elseif(filteredEntries().length === 0)
270
- <div class="px-6 py-14 text-center">
271
- <span class="inline-block h-8 w-8 text-neutral-400 i-hugeicons-search-remove"></span>
272
- <h2 class="mt-3 font-semibold text-neutral-900 dark:text-white">No matching posts</h2>
273
- <p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Adjust the search or score filter, or create a CMS post first.</p>
274
- </div>
275
- @else
276
- <div class="overflow-x-auto">
277
- <table class="min-w-full divide-neutral-200/60 divide-y dark:divide-white/5">
278
- <thead class="bg-neutral-50 dark:bg-white/[0.03]">
279
- <tr>
280
- <th class="px-4 py-3 font-semibold text-left text-neutral-700 text-xs dark:text-neutral-300">Post</th>
281
- <th class="px-4 py-3 font-semibold text-left text-neutral-700 text-xs dark:text-neutral-300">Focus keyword</th>
282
- <th class="px-4 py-3 font-semibold text-left text-neutral-700 text-xs dark:text-neutral-300">Meta description</th>
283
- <th class="px-4 py-3 font-semibold text-neutral-700 text-right text-xs dark:text-neutral-300">Score</th>
284
- <th class="px-4 py-3 font-semibold text-left text-neutral-700 text-xs dark:text-neutral-300">Updated</th>
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 '~/storage/framework/defaults/functions/mail-preferences'
266
- import { fetchInboxActivity } from '~/storage/framework/defaults/functions/inbox'
267
- import { fetchMailPreference, saveMailPreference } from '~/storage/framework/defaults/functions/mail-preferences'
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 '~/storage/framework/defaults/functions/dashboard-api'
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
- const quickLinks = [
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 '~/storage/framework/defaults/views/dashboard/composables/useNavigation'
16
- import { useRole } from '~/storage/framework/defaults/views/dashboard/composables/useRole'
17
- import { kanbanStore } from '~/storage/framework/defaults/views/dashboard/stores/kanban'
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 '~/storage/framework/defaults/views/dashboard/composables/useRole'
9
- import { kanbanStore } from '~/storage/framework/defaults/views/dashboard/stores/kanban'
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 '~/storage/framework/defaults/views/dashboard/stores/kanban'
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