@stacksjs/defaults 0.75.244 → 0.75.246

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 (42) hide show
  1. package/ai/skills/stacks-dashboard/SKILL.md +2 -2
  2. package/ai/skills/stacks-native/CATALOG.md +7 -7
  3. package/ai/skills/stacks-path/SKILL.md +19 -1
  4. package/app/Actions/Dashboard/Models/ModelsIndexAction.ts +2 -1
  5. package/app/Actions/Dashboard/Search/GlobalSearchAction.ts +2 -1
  6. package/app/Actions/Dashboard/Source/source-inventory.ts +2 -1
  7. package/ide/vscode/package.json +1 -1
  8. package/package.json +2 -2
  9. package/project/storage/framework/tsconfig.app.json +8 -0
  10. package/resources/components/Dashboard/Analytics/WebAnalyticsDashboard.stx +5 -5
  11. package/resources/components/Dashboard/Ci/CiDashboard.stx +2 -2
  12. package/resources/components/Dashboard/Ci/CiRunHistoryDrawer.stx +1 -1
  13. package/resources/components/Dashboard/Content/AuthorsDashboard.stx +59 -20
  14. package/resources/components/Dashboard/Content/BlogDashboard.stx +2 -2
  15. package/resources/components/Dashboard/Content/ContentDashboard.stx +2 -2
  16. package/resources/components/Dashboard/Content/ContentTaxonomyDashboard.stx +50 -18
  17. package/resources/components/Dashboard/Content/PagesDashboard.stx +54 -10
  18. package/resources/components/Dashboard/Content/PostsDashboard.stx +91 -20
  19. package/resources/components/Dashboard/Content/SeoDashboard.stx +61 -59
  20. package/resources/components/Dashboard/Email/InboxSettingsDashboard.stx +3 -3
  21. package/resources/components/Dashboard/Environment/EnvironmentDashboard.stx +1 -1
  22. package/resources/components/Dashboard/Kanban/KanbanBoardDashboard.stx +3 -3
  23. package/resources/components/Dashboard/Kanban/KanbanBoardsDashboard.stx +2 -2
  24. package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +1 -1
  25. package/resources/components/Dashboard/Library/ComponentsDashboard.stx +47 -81
  26. package/resources/components/Dashboard/Library/FunctionsDashboard.stx +24 -15
  27. package/resources/components/Dashboard/Library/PackagesDashboard.stx +68 -13
  28. package/resources/components/Dashboard/Management/PermissionsDashboard.stx +12 -15
  29. package/resources/components/Dashboard/Models/ModelRecordsDashboard.stx +3 -3
  30. package/resources/components/Dashboard/Settings/AppearanceSettingsDashboard.stx +3 -3
  31. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +1 -1
  32. package/resources/components/Dashboard/Teams/TeamInvitationDashboard.stx +2 -2
  33. package/resources/components/Dashboard/UI/Button.stx +29 -3
  34. package/resources/components/Dashboard/UI/SearchFilter.stx +46 -0
  35. package/resources/components/Dashboard/UI/Table.stx +43 -20
  36. package/resources/functions/dashboard/data.ts +27 -4
  37. package/resources/functions/dashboard/sidebar.ts +3 -2
  38. package/resources/plugins/preloader.ts +9 -1
  39. package/views/dashboard/layouts/default.stx +17 -13
  40. package/views/system-tray/index.stx +1 -1
  41. package/resources/components/Dashboard/Commerce/Delivery/SearchFilter.stx +0 -24
  42. package/resources/components/Dashboard/UI/FilterBar.stx +0 -62
@@ -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'
5
+ import { pushToast } from '@stacksjs/defaults/functions/toasts'
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'
266
+ import { fetchInboxActivity } from '@stacksjs/defaults/functions/inbox'
267
+ import { fetchMailPreference, saveMailPreference } from '@stacksjs/defaults/functions/mail-preferences'
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'
3
3
 
4
4
  interface EnvironmentFileIssue {
5
5
  line: number
@@ -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'
16
+ import { useRole } from '@stacksjs/defaults/views/dashboard/composables/useRole'
17
+ import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban'
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'
9
+ import { kanbanStore } from '@stacksjs/defaults/views/dashboard/stores/kanban'
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'
3
3
  import { useTimeoutFn } from '@stacksjs/composables'
4
4
 
5
5
  const kanban = kanbanStore
@@ -1,4 +1,5 @@
1
1
  @import('../UI/Button')
2
+ @import('../UI/Table')
2
3
 
3
4
  <script client>
4
5
  import { dashboardApi } from '../../../../functions/dashboard-api'
@@ -25,18 +26,28 @@ const totalBytes = state(0)
25
26
  const sourceRoot = state('resources/components')
26
27
  const loading = state(true)
27
28
  const loadError = state('')
28
- const search = state('')
29
29
  const selectedCategory = state('All')
30
30
  const dialogOpen = state(false)
31
31
  const saving = state(false)
32
32
  const saveError = state('')
33
33
 
34
- function visibleComponents(): ComponentRow[] {
35
- const query = search().trim().toLowerCase()
36
- return components().filter(component => (
37
- (selectedCategory() === 'All' || component.category === selectedCategory())
38
- && (!query || [component.name, component.path, component.category].some(value => value.toLowerCase().includes(query)))
39
- ))
34
+ const COLUMNS = [
35
+ { key: 'name', label: 'Component', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
36
+ // A neutral pill with no per-value styling, which is what a badge column
37
+ // renders when it is given no `badgeClasses`.
38
+ { key: 'category', label: 'Category', type: 'badge' as const, sortable: true },
39
+ { key: 'path', label: 'Path', sortable: true, class: 'font-mono text-xs text-gray-500 dark:text-neutral-400' },
40
+ // `sizeLabel` is what the cell shows; `bytes` stays on the row so a future
41
+ // numeric sort has something to read.
42
+ { key: 'sizeLabel', label: 'Size', sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
43
+ { key: 'updatedAt', label: 'Updated', type: 'datetime' as const, sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
44
+ ]
45
+
46
+ /** `UI/Table` owns the search, so this applies only the category filter. */
47
+ function visibleComponents(): Array<ComponentRow & { sizeLabel: string }> {
48
+ return components()
49
+ .filter(component => selectedCategory() === 'All' || component.category === selectedCategory())
50
+ .map(component => ({ ...component, sizeLabel: formatBytes(component.bytes) }))
40
51
  }
41
52
 
42
53
  function formatBytes(bytes: number): string {
@@ -45,13 +56,6 @@ function formatBytes(bytes: number): string {
45
56
  return `${(bytes / 1024).toFixed(1)} kB`
46
57
  }
47
58
 
48
- function formatDate(value: string): string {
49
- const date = new Date(value)
50
- return Number.isNaN(date.getTime())
51
- ? value
52
- : new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
53
- }
54
-
55
59
  async function loadComponents(): Promise<void> {
56
60
  loading.set(true)
57
61
  loadError.set('')
@@ -167,73 +171,35 @@ onMount(() => {
167
171
  </section>
168
172
 
169
173
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
170
- <div class="flex flex-col gap-3 sm:flex-row p-4 border-b border-gray-200 dark:border-neutral-700">
171
- <label class="flex-1 min-w-0">
172
- <span class="sr-only">Search components</span>
173
- <input
174
- x-model="search"
175
- type="search"
176
- placeholder="Search name, category, or path"
177
- class="px-3 py-2 max-w-lg 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"
178
- >
179
- </label>
180
- <label>
181
- <span class="sr-only">Filter by category</span>
182
- <select
183
- x-model="selectedCategory"
184
- aria-label="Filter by category"
185
- class="px-3 py-2 w-full sm:w-48 text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700"
186
- >
187
- <option :for="category in categories()" :value="category">{{ category }}</option>
188
- </select>
189
- </label>
190
- </div>
191
-
192
- <div class="overflow-x-auto">
193
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
194
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
195
- <tr>
196
- <th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Component</th>
197
- <th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Category</th>
198
- <th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Path</th>
199
- <th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Size</th>
200
- <th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Updated</th>
201
- </tr>
202
- </thead>
203
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
204
- <template :if="loading()">
205
- <tr>
206
- <td colspan="5" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400">
207
- <span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
208
- Scanning component sources
209
- </td>
210
- </tr>
211
- </template>
212
- <template :else-if="visibleComponents().length === 0">
213
- <tr>
214
- <td colspan="5" class="px-4 py-14 text-center">
215
- <span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-layout-table-01"></span>
216
- <p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No components found</p>
217
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Create a component or adjust the filters.</p>
218
- </td>
219
- </tr>
220
- </template>
221
- <template :else>
222
- <template :for="component in visibleComponents()">
223
- <tr>
224
- <td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ component.name }}</td>
225
- <td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">
226
- <span class="px-2 py-1 text-xs bg-gray-100 dark:bg-neutral-700 rounded-full" :text="component.category"></span>
227
- </td>
228
- <td class="px-4 py-3 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ component.path }}</td>
229
- <td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ formatBytes(component.bytes) }}</td>
230
- <td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ formatDate(component.updatedAt) }}</td>
231
- </tr>
232
- </template>
233
- </template>
234
- </tbody>
235
- </table>
236
- </div>
174
+ <Table
175
+ :columns="COLUMNS"
176
+ :data="visibleComponents()"
177
+ :loading="loading()"
178
+ row-key="path"
179
+ searchable
180
+ search-placeholder="Search name, category, or path"
181
+ paginated
182
+ :page-size="25"
183
+ empty-title="No components found"
184
+ empty-description="Adjust the search or category filter."
185
+ >
186
+ <template #toolbar>
187
+ <label>
188
+ <span class="sr-only">Filter by category</span>
189
+ {{-- `x-text` rather than `{{ category }}`: an interpolation inside
190
+ slot content is preserved but never evaluated on the installed
191
+ stx, while the attribute forms read the loop variable fine.
192
+ See stacksjs/stx#2095. --}}
193
+ <select
194
+ x-model="selectedCategory"
195
+ aria-label="Filter by category"
196
+ 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"
197
+ >
198
+ <option :for="category in categories()" :value="category" x-text="category"></option>
199
+ </select>
200
+ </label>
201
+ </template>
202
+ </Table>
237
203
  </section>
238
204
  </div>
239
205
 
@@ -1,4 +1,5 @@
1
1
  @import('../UI/Button')
2
+ @import('../UI/Table')
2
3
 
3
4
  <script client>
4
5
  import { dashboardApi } from '../../../../functions/dashboard-api'
@@ -18,14 +19,21 @@ const totalBytes = state(0)
18
19
  const sourceRoot = state('resources/functions')
19
20
  const loading = state(true)
20
21
  const loadError = state('')
21
- const search = state('')
22
22
  const dialogOpen = state(false)
23
23
  const saving = state(false)
24
24
  const saveError = state('')
25
25
 
26
- function visibleFunctions(): FunctionRow[] {
27
- const query = search().trim().toLowerCase()
28
- return functions().filter(fn => !query || [fn.name, fn.path, fn.extension].some(value => value.toLowerCase().includes(query)))
26
+ const COLUMNS = [
27
+ { key: 'name', label: 'Function', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
28
+ { key: 'path', label: 'Path', sortable: true, class: 'font-mono text-xs text-gray-500 dark:text-neutral-400' },
29
+ // `bytes` so it sorts numerically; `sizeLabel` is what the cell shows.
30
+ { key: 'sizeLabel', label: 'Size', sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
31
+ { key: 'updatedAt', label: 'Updated', type: 'datetime' as const, sortable: true, class: 'text-gray-500 dark:text-neutral-400' },
32
+ ]
33
+
34
+ /** `UI/Table` owns the search, so this only shapes the rows it reads. */
35
+ function visibleFunctions(): Array<FunctionRow & { sizeLabel: string }> {
36
+ return functions().map(fn => ({ ...fn, sizeLabel: formatBytes(fn.bytes) }))
29
37
  }
30
38
 
31
39
  function formatBytes(bytes: number): string {
@@ -34,11 +42,6 @@ function formatBytes(bytes: number): string {
34
42
  return `${(bytes / 1024).toFixed(1)} kB`
35
43
  }
36
44
 
37
- function formatDate(value: string): string {
38
- const date = new Date(value)
39
- return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
40
- }
41
-
42
45
  async function loadFunctions(): Promise<void> {
43
46
  loading.set(true)
44
47
  loadError.set('')
@@ -107,12 +110,18 @@ onMount(() => { void loadFunctions() })
107
110
  </section>
108
111
 
109
112
  <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
110
- <div class="p-4 border-b border-gray-200 dark:border-neutral-700"><label><span class="sr-only">Search functions</span><input x-model="search" type="search" placeholder="Search name or path" 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>
111
- <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">Function</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Path</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Size</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Updated</th></tr></thead><tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
112
- <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>Scanning function sources</td></tr></template>
113
- <template :else-if="visibleFunctions().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-function"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No functions found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Create a function or adjust the search.</p></td></tr></template>
114
- <template :else><template :for="fn in visibleFunctions()"><tr><td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ fn.name }}</td><td class="px-4 py-3 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ fn.path }}</td><td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ formatBytes(fn.bytes) }}</td><td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-400">{{ formatDate(fn.updatedAt) }}</td></tr></template></template>
115
- </tbody></table></div>
113
+ <Table
114
+ :columns="COLUMNS"
115
+ :data="visibleFunctions()"
116
+ :loading="loading()"
117
+ row-key="path"
118
+ searchable
119
+ search-placeholder="Search name or path"
120
+ paginated
121
+ :page-size="25"
122
+ empty-title="No functions found"
123
+ empty-description="Create a function or adjust the search."
124
+ />
116
125
  </section>
117
126
  </div>
118
127