@stacksjs/defaults 0.75.263 → 0.75.266

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 (22) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +3 -0
  2. package/ai/skills/stacks-browse/scripts/browse.ts +73 -14
  3. package/ai/skills/stacks-native/CATALOG.md +4 -4
  4. package/ide/vscode/package.json +1 -1
  5. package/package.json +4 -4
  6. package/resources/components/Dashboard/App/RequestsOverview.stx +75 -58
  7. package/resources/components/Dashboard/App/SourceInventory.stx +68 -59
  8. package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
  9. package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
  10. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
  11. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
  12. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
  13. package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
  14. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
  15. package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
  16. package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
  17. package/resources/components/Dashboard/Infrastructure/DnsDashboard.stx +46 -28
  18. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
  19. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
  20. package/resources/components/Dashboard/Queue/QueueDashboard.stx +27 -61
  21. package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +35 -61
  22. package/resources/components/Dashboard/Teams/TeamPeopleDashboard.stx +127 -143
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script server>
2
4
  import { publicApplicationUrl } from '../../../../functions/public-application-url'
3
5
 
@@ -102,57 +104,47 @@ defineClientPayload({ blogUrl })
102
104
  </div>
103
105
 
104
106
  <div x-show="!isLoading() && !loadError() && filteredPosts().length > 0" class="overflow-x-auto max-w-full w-full">
105
- <table class="min-w-full text-sm divide-neutral-200 divide-y dark:divide-neutral-800">
106
- <thead class="font-semibold text-left text-neutral-600 text-xs uppercase dark:text-neutral-300 bg-neutral-50 dark:bg-neutral-950/60">
107
- <tr>
108
- <th scope="col" class="px-4 py-3">Post</th>
109
- <th scope="col" class="px-4 py-3">Status</th>
110
- <th scope="col" class="px-4 py-3">Date</th>
111
- <th scope="col" class="px-4 py-3">Length</th>
112
- <th scope="col" class="px-4 py-3"><span class="sr-only">Actions</span></th>
113
- </tr>
114
- </thead>
115
- <tbody class="divide-neutral-200 divide-y dark:divide-neutral-800">
116
- <template :for="post in filteredPosts">
117
- <tr class="hover:bg-neutral-50 dark:hover:bg-neutral-950/60">
118
- <td class="px-4 py-3">
119
- <div class="flex gap-2 items-center">
120
- <span class="font-medium">{{ post.title }}</span>
121
- <span x-show="post.featured" class="inline-flex px-1.5 py-0.5 font-medium text-[11px] text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded">Featured</span>
122
- </div>
123
- <div class="mt-1 max-w-xl text-neutral-500 text-xs truncate dark:text-neutral-400">{{ post.description || post.slug }}</div>
124
- </td>
125
- <td class="px-4 py-3">
126
- <span :class="statusClass(post.draft)" class="inline-flex px-2 py-0.5 font-medium text-xs rounded-full">
127
- {{ post.draft ? 'Draft' : 'Published' }}
128
- </span>
129
- </td>
130
- <td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">{{ formatDate(post.date) }}</td>
131
- <td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">{{ post.readingTime }} min read</td>
132
- <td class="px-4 py-3">
133
- <div class="flex gap-1 justify-end">
134
- <a
135
- x-show="!post.draft"
136
- :href="publicPostUrl(post.slug)"
137
- target="_blank"
138
- rel="noopener"
139
- :title="'View ' + post.title"
140
- class="p-2 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
141
- >
142
- <span class="block h-4 w-4 i-hugeicons-view"></span>
143
- </a>
144
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + post.title" @click="openEditor(post)">
145
- <span class="block h-4 w-4 i-hugeicons-edit-01"></span>
146
- </Button>
147
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + post.title" @click="askDelete(post)">
148
- <span class="block h-4 w-4 text-red-600 dark:text-red-400 i-hugeicons-waste"></span>
149
- </Button>
150
- </div>
151
- </td>
152
- </tr>
153
- </template>
154
- </tbody>
155
- </table>
107
+ {{--
108
+ The page owns the search, the status filter and the loading, error
109
+ and empty states, so the table brings only sorting and paging. The
110
+ post is slotted for its featured mark; the actions are a slot for the
111
+ view link, which only a published post has.
112
+ --}}
113
+ <Table
114
+ :columns="POST_COLUMNS"
115
+ :data="postRows()"
116
+ row-key="slug"
117
+ paginated
118
+ :page-size="25"
119
+ >
120
+ <template #cell="{ row }">
121
+ <div class="flex gap-2 items-center">
122
+ <span class="font-medium" x-text="row.title"></span>
123
+ <span :if="row.featured" class="inline-flex px-1.5 py-0.5 font-medium text-[11px] text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded">Featured</span>
124
+ </div>
125
+ <div class="mt-1 max-w-xl text-neutral-500 text-xs truncate dark:text-neutral-400" x-text="row.summary"></div>
126
+ </template>
127
+ <template #actions="{ row }">
128
+ <div class="flex gap-1 justify-end">
129
+ <a
130
+ :if="!row.draft"
131
+ :href="row.publicUrl"
132
+ target="_blank"
133
+ rel="noopener"
134
+ :title="'View ' + row.title"
135
+ class="p-2 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
136
+ >
137
+ <span class="block h-4 w-4 i-hugeicons-view"></span>
138
+ </a>
139
+ <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + row.title" @click="openEditor(row.post)">
140
+ <span class="block h-4 w-4 i-hugeicons-edit-01"></span>
141
+ </Button>
142
+ <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + row.title" @click="askDelete(row.post)">
143
+ <span class="block h-4 w-4 text-red-600 dark:text-red-400 i-hugeicons-waste"></span>
144
+ </Button>
145
+ </div>
146
+ </template>
147
+ </Table>
156
148
  </div>
157
149
 
158
150
  <!-- Empty: distinguishes "no posts yet" from "nothing matched the filter". -->
@@ -272,22 +264,36 @@ const filteredPosts = derived(() => {
272
264
  })
273
265
  })
274
266
 
275
- function statusClass(draft: boolean) {
276
- return draft
277
- ? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
278
- : 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200'
279
- }
280
-
281
- function formatDate(value: string | number | Date | null | undefined) {
282
- if (!value)
283
- return 'No date'
284
-
285
- const date = new Date(value)
286
-
287
- return Number.isNaN(date.getTime())
288
- ? value
289
- : date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
290
- }
267
+ const POST_COLUMNS = [
268
+ { key: 'title', label: 'Post', sortable: true, slotted: true },
269
+ {
270
+ key: 'status',
271
+ label: 'Status',
272
+ type: 'badge' as const,
273
+ sortable: true,
274
+ badgeClasses: {
275
+ draft: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
276
+ published: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200',
277
+ },
278
+ badgeLabels: { draft: 'Draft', published: 'Published' },
279
+ },
280
+ { key: 'date', label: 'Date', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'text-neutral-600 dark:text-neutral-300' },
281
+ { key: 'lengthLabel', label: 'Length', searchable: false, class: 'text-neutral-600 dark:text-neutral-300' },
282
+ ]
283
+
284
+ /** What the table reads; `post` is the record the edit and delete actions take. */
285
+ const postRows = derived(() => filteredPosts().map(post => ({
286
+ slug: post.slug,
287
+ title: post.title,
288
+ featured: post.featured,
289
+ draft: post.draft,
290
+ summary: post.description || post.slug,
291
+ status: post.draft ? 'draft' : 'published',
292
+ date: post.date,
293
+ lengthLabel: `${post.readingTime} min read`,
294
+ publicUrl: publicPostUrl(post.slug),
295
+ post,
296
+ })))
291
297
 
292
298
  function clearFilters() {
293
299
  searchQuery.set('')
@@ -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'
@@ -214,14 +216,22 @@ function formatFileSize(bytes: number | null): string {
214
216
  return `${Number.parseFloat((bytes / 1024 ** index).toFixed(2))} ${units[index]}`
215
217
  }
216
218
 
217
- function formatDate(value: string | null): string {
218
- if (!value)
219
- return 'No date'
220
- const date = new Date(value)
221
- return Number.isNaN(date.getTime())
222
- ? 'No date'
223
- : new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date)
224
- }
219
+ const FILE_COLUMNS = [
220
+ { key: 'name', label: 'Name', sortable: true, slotted: true },
221
+ { key: 'size', label: 'Size', sortable: true, slotted: true, searchable: false },
222
+ { key: 'lastModified', label: 'Modified', type: 'date' as const, sortable: true, emptyText: 'No date', class: 'text-sm text-stone-500 dark:text-neutral-400' },
223
+ ]
224
+
225
+ /** What the list view's table reads; `item` is what the click and delete handlers take. */
226
+ const fileRows = derived(() => filteredItems().map(item => ({
227
+ id: item.id,
228
+ name: item.name,
229
+ size: item.type === 'folder' ? -1 : item.size,
230
+ sizeLabel: item.type === 'folder' ? '-' : formatFileSize(item.size),
231
+ lastModified: item.lastModified,
232
+ iconClass: itemIconClass(item),
233
+ item,
234
+ })))
225
235
 
226
236
  async function loadFiles(options: { resetPath?: boolean } = {}): Promise<void> {
227
237
  loading.set(true)
@@ -505,10 +515,27 @@ onMount(() => { void loadFiles() })
505
515
  </template>
506
516
  </div>
507
517
 
508
- <div :else class="overflow-hidden bg-white dark:bg-neutral-800 border border-stone-200 rounded-xl dark:border-neutral-700">
509
- <div class="overflow-x-auto"><table class="min-w-full divide-stone-200 divide-y dark:divide-neutral-700"><thead class="bg-stone-50 dark:bg-neutral-900/50"><tr><th class="px-4 py-3 font-semibold text-left text-stone-500 text-xs dark:text-neutral-400">Name</th><th class="px-4 py-3 font-semibold text-left text-stone-500 text-xs dark:text-neutral-400">Size</th><th class="px-4 py-3 font-semibold text-left text-stone-500 text-xs dark:text-neutral-400">Modified</th><th class="px-4 py-3"><span class="sr-only">Actions</span></th></tr></thead><tbody class="divide-stone-200 divide-y dark:divide-neutral-700">
510
- <template :for="item in filteredItems"><tr class="hover:bg-stone-50 dark:hover:bg-neutral-700/50"><td class="px-4 py-3"><button type="button" class="flex items-center max-w-md text-left" @click="onItemClick(item)"><span :class="itemIconClass(item)"></span><span class="font-medium text-sm text-stone-900 truncate dark:text-white">{{ item.name }}</span></button></td><td class="px-4 py-3 text-sm text-stone-500 dark:text-neutral-400">{{ item.type === 'folder' ? '-' : formatFileSize(item.size) }}</td><td class="px-4 py-3 text-sm text-stone-500 dark:text-neutral-400">{{ formatDate(item.lastModified) }}</td><td class="px-4 py-3 text-right"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + item.name" @click="openDelete(item, $event)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></td></tr></template>
511
- </tbody></table></div>
518
+ {{--
519
+ The list view. The page owns search, the folder path and the loading
520
+ and empty states; the table brings sorting and paging, which a large
521
+ folder had none of. Name is slotted for its icon and the click that
522
+ opens a folder, and size is slotted so it reads as a size while
523
+ sorting on the byte count underneath.
524
+ --}}
525
+ <div :else>
526
+ <Table :columns="FILE_COLUMNS" :data="fileRows()" paginated :page-size="50" empty-title="No items" empty-description="Nothing to show in this folder.">
527
+ <template #cell="{ row, column }">
528
+ <template :if="column.key === 'name'">
529
+ <button type="button" class="flex items-center max-w-md text-left" @click="onItemClick(row.item)"><span :class="row.iconClass"></span><span class="font-medium text-sm text-stone-900 truncate dark:text-white" x-text="row.name"></span></button>
530
+ </template>
531
+ <template :else>
532
+ <span class="text-sm text-stone-500 dark:text-neutral-400" x-text="row.sizeLabel"></span>
533
+ </template>
534
+ </template>
535
+ <template #actions="{ row }">
536
+ <div class="text-right"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + row.name" @click="openDelete(row.item, $event)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div>
537
+ </template>
538
+ </Table>
512
539
  </div>
513
540
  </div>
514
541
  </div>
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type {
3
5
  DashboardActivity,
@@ -53,10 +55,6 @@ const filteredRecords = derived(() => {
53
55
  return records().filter(record => searchableText(record).includes(query))
54
56
  })
55
57
 
56
- const activityRecords = derived(() => filteredRecords() as DashboardActivity[])
57
- const userRecords = derived(() => filteredRecords() as DashboardUser[])
58
- const teamRecords = derived(() => filteredRecords() as DashboardTeam[])
59
- const subscriberRecords = derived(() => filteredRecords() as DashboardSubscriber[])
60
58
 
61
59
  function searchableText(record: DataRecord): string {
62
60
  return Object.values(record)
@@ -69,27 +67,78 @@ function statValue(key: string): number {
69
67
  return Number(stats()[key] || 0)
70
68
  }
71
69
 
72
- function formatDate(value: string): string {
73
- if (!value)
74
- return 'Never'
75
- const date = new Date(value)
76
- return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString()
77
- }
78
-
79
70
  function formatSubject(record: DashboardActivity): string {
80
71
  if (!record.subjectType)
81
72
  return 'Application'
82
73
  return record.subjectId > 0 ? `${record.subjectType} #${record.subjectId}` : record.subjectType
83
74
  }
84
75
 
85
- function statusClass(status: string): string {
76
+ /** A status pill's colour: settled, ended, or still in progress. */
77
+ function statusTone(status: string): string {
86
78
  if (['active', 'subscribed', 'completed'].includes(status))
87
- return 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40'
79
+ return 'positive'
88
80
  if (['bounced', 'inactive', 'unsubscribed'].includes(status))
89
- return 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40'
90
- return 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40'
81
+ return 'negative'
82
+ return 'pending'
83
+ }
84
+
85
+ const TONES = {
86
+ positive: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
87
+ negative: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
88
+ pending: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
89
+ }
90
+
91
+ function capitalize(value: string): string {
92
+ return value ? value.charAt(0).toUpperCase() + value.slice(1) : value
93
+ }
94
+
95
+ const recorded = (key: string, label: string) => ({ key, label, type: 'datetime' as const, align: 'right' as const, sortable: true, emptyText: 'Never', class: 'text-neutral-500 text-xs whitespace-nowrap dark:text-neutral-400' })
96
+ const statusColumn = { key: 'statusTone', label: 'Status', type: 'badge' as const, sortable: true, badgeLabelKey: 'statusLabel', badgeClasses: TONES }
97
+
98
+ const COLUMNS_BY_MODE = {
99
+ activity: [
100
+ { key: 'description', label: 'Activity', sortable: true, subtitleKey: 'type', class: 'font-medium text-neutral-900 dark:text-white' },
101
+ { key: 'subject', label: 'Subject', sortable: true, class: 'text-neutral-600 dark:text-neutral-300' },
102
+ { key: 'causer', label: 'Causer', sortable: true, class: 'text-neutral-600 dark:text-neutral-300' },
103
+ recorded('createdAt', 'Recorded'),
104
+ ],
105
+ users: [
106
+ { key: 'name', label: 'User', sortable: true, subtitleKey: 'email', class: 'font-medium text-neutral-900 dark:text-white' },
107
+ { key: 'verificationTone', label: 'Verification', type: 'badge' as const, sortable: true, badgeLabelKey: 'verificationLabel', badgeClasses: TONES },
108
+ recorded('updatedAt', 'Updated'),
109
+ recorded('createdAt', 'Registered'),
110
+ ],
111
+ teams: [
112
+ { key: 'name', label: 'Team', sortable: true, slotted: true },
113
+ { key: 'memberCount', label: 'Members', type: 'number' as const, align: 'right' as const, sortable: true, class: 'font-mono text-neutral-700 dark:text-neutral-300' },
114
+ statusColumn,
115
+ recorded('createdAt', 'Created'),
116
+ ],
117
+ subscribers: [
118
+ { key: 'email', label: 'Subscriber', sortable: true, class: 'font-medium text-neutral-900 dark:text-white' },
119
+ { key: 'sourceLabel', label: 'Source', sortable: true, class: 'text-neutral-600 dark:text-neutral-300' },
120
+ statusColumn,
121
+ recorded('createdAt', 'Subscribed'),
122
+ ],
91
123
  }
92
124
 
125
+ const recordColumns = derived(() => COLUMNS_BY_MODE[mode()] ?? COLUMNS_BY_MODE.activity)
126
+
127
+ const recordRows = derived(() => filteredRecords().map((record) => {
128
+ const row = record as Record<string, any>
129
+ return {
130
+ ...row,
131
+ subject: mode() === 'activity' ? formatSubject(record as DashboardActivity) : undefined,
132
+ verificationTone: row.emailVerifiedAt ? 'positive' : 'pending',
133
+ verificationLabel: row.emailVerifiedAt ? 'Verified' : 'Unverified',
134
+ statusTone: statusTone(String(row.status ?? '')),
135
+ statusLabel: capitalize(String(row.status ?? '')),
136
+ sourceLabel: capitalize(String(row.source ?? '')),
137
+ descriptionLabel: row.description || 'No description',
138
+ modelLink: `/models/team?q=${encodeURIComponent(String(row.name ?? ''))}`,
139
+ }
140
+ }))
141
+
93
142
  async function loadRecords(): Promise<void> {
94
143
  loading.set(true)
95
144
  loadError.set('')
@@ -202,80 +251,28 @@ onMount(() => {
202
251
 
203
252
  <EmptyState :if="!loading() && !loadError() && filteredRecords().length === 0" :icon="page().icon" :title="search() ? 'No matching records' : page().empty" :description="search() ? 'Try a different search term.' : 'New records will appear here automatically.'" />
204
253
 
205
- <div :if="!loading() && filteredRecords().length > 0" class="overflow-x-auto">
206
- <table class="w-full">
207
- <thead class="bg-neutral-50 dark:bg-neutral-800/50">
208
- <tr :if="mode() === 'activity'">
209
- <th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Activity</th>
210
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Subject</th>
211
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Causer</th>
212
- <th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Recorded</th>
213
- </tr>
214
- <tr :if="mode() === 'users'">
215
- <th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">User</th>
216
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Verification</th>
217
- <th class="px-4 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Updated</th>
218
- <th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Registered</th>
219
- </tr>
220
- <tr :if="mode() === 'teams'">
221
- <th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Team</th>
222
- <th class="px-4 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Members</th>
223
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
224
- <th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Created</th>
225
- </tr>
226
- <tr :if="mode() === 'subscribers'">
227
- <th class="pl-5 pr-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Subscriber</th>
228
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Source</th>
229
- <th class="px-4 py-3 font-semibold text-left text-neutral-500 text-xs tracking-wider uppercase dark:text-neutral-400">Status</th>
230
- <th class="pr-5 py-3 font-semibold text-neutral-500 text-right text-xs tracking-wider uppercase dark:text-neutral-400">Subscribed</th>
231
- </tr>
232
- </thead>
233
- <tbody class="divide-neutral-100 divide-y dark:divide-neutral-700">
234
- <template :if="mode() === 'activity'" :for="record in activityRecords()">
235
- <tr>
236
- <td class="pl-5 pr-4 py-3.5">
237
- <p class="font-medium text-neutral-900 text-sm dark:text-white">{{ record.description }}</p>
238
- <p class="mt-0.5 font-mono text-neutral-500 text-xs dark:text-neutral-400">{{ record.type }}</p>
239
- </td>
240
- <td class="px-4 py-3.5 text-neutral-600 text-sm dark:text-neutral-300">{{ formatSubject(record) }}</td>
241
- <td class="px-4 py-3.5 text-neutral-600 text-sm dark:text-neutral-300">{{ record.causer }}</td>
242
- <td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
243
- </tr>
244
- </template>
245
- <template :if="mode() === 'users'" :for="record in userRecords()">
246
- <tr>
247
- <td class="pl-5 pr-4 py-3.5">
248
- <p class="font-medium text-neutral-900 text-sm dark:text-white">{{ record.name }}</p>
249
- <p class="mt-0.5 text-neutral-500 text-xs dark:text-neutral-400">{{ record.email }}</p>
250
- </td>
251
- <td class="px-4 py-3.5">
252
- <span :class="'inline-flex px-2 py-0.5 font-medium text-xs rounded ' + (record.emailVerifiedAt ? statusClass('active') : statusClass('pending'))">{{ record.emailVerifiedAt ? 'Verified' : 'Unverified' }}</span>
253
- </td>
254
- <td class="px-4 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.updatedAt) }}</td>
255
- <td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
256
- </tr>
257
- </template>
258
- <template :if="mode() === 'teams'" :for="record in teamRecords()">
259
- <tr>
260
- <td class="pl-5 pr-4 py-3.5">
261
- <StxLink :to="'/models/team?q=' + encodeURIComponent(record.name)" class="font-medium text-neutral-900 text-sm dark:text-white hover:text-blue-600 dark:hover:text-blue-400">{{ record.name }}</StxLink>
262
- <p class="mt-0.5 max-w-xl text-neutral-500 text-xs dark:text-neutral-400">{{ record.description || 'No description' }}</p>
263
- </td>
264
- <td class="px-4 py-3.5 font-mono text-neutral-700 text-right text-sm dark:text-neutral-300">{{ record.memberCount }}</td>
265
- <td class="px-4 py-3.5"><span :class="'inline-flex px-2 py-0.5 font-medium text-xs capitalize rounded ' + statusClass(record.status)">{{ record.status }}</span></td>
266
- <td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
267
- </tr>
268
- </template>
269
- <template :if="mode() === 'subscribers'" :for="record in subscriberRecords()">
270
- <tr>
271
- <td class="pl-5 pr-4 py-3.5 font-medium text-neutral-900 text-sm dark:text-white">{{ record.email }}</td>
272
- <td class="px-4 py-3.5 capitalize text-neutral-600 text-sm dark:text-neutral-300">{{ record.source }}</td>
273
- <td class="px-4 py-3.5"><span :class="'inline-flex px-2 py-0.5 font-medium text-xs capitalize rounded ' + statusClass(record.status)">{{ record.status }}</span></td>
274
- <td class="pr-5 py-3.5 text-neutral-500 text-right text-xs whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
275
- </tr>
254
+ {{--
255
+ Four column sets: the mode decides which. The page keeps its search,
256
+ loading and empty states; the table brings sorting and paging. Only a
257
+ team's name is slotted, for the link to its model page.
258
+ --}}
259
+ <div :if="!loading() && filteredRecords().length > 0">
260
+ {{--
261
+ Two tables rather than one with a slot: Table hands every cell to a
262
+ given cell slot when no column is slotted, so the slot can only be
263
+ passed in the mode that has a slotted column.
264
+ --}}
265
+ <template :if="mode() === 'teams'">
266
+ <Table :columns="recordColumns()" :data="recordRows()" paginated :page-size="25">
267
+ <template #cell="{ row }">
268
+ <StxLink :to="row.modelLink" class="font-medium text-neutral-900 text-sm dark:text-white hover:text-blue-600 dark:hover:text-blue-400">{{ row.name }}</StxLink>
269
+ <p class="mt-0.5 max-w-xl text-neutral-500 text-xs dark:text-neutral-400" x-text="row.descriptionLabel"></p>
276
270
  </template>
277
- </tbody>
278
- </table>
271
+ </Table>
272
+ </template>
273
+ <template :else>
274
+ <Table :columns="recordColumns()" :data="recordRows()" paginated :page-size="25" />
275
+ </template>
279
276
  </div>
280
277
  </Card>
281
278
  </div>
@@ -1,22 +1,11 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { DeploymentRecord } from '../../../../functions/deployments'
3
5
 
4
6
  const deployments = useReactiveProp('deployments', [])
5
7
  const loading = useReactiveProp('loading', false)
6
8
 
7
- function rows(): DeploymentRecord[] {
8
- return deployments() as DeploymentRecord[]
9
- }
10
-
11
- function formatDate(value: string): string {
12
- if (!value)
13
- return 'Not recorded'
14
- const date = new Date(value)
15
- return Number.isNaN(date.getTime())
16
- ? value
17
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
18
- }
19
-
20
9
  function formatDuration(value: number | null): string {
21
10
  if (value === null)
22
11
  return '-'
@@ -27,61 +16,57 @@ function formatDuration(value: number | null): string {
27
16
  return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
28
17
  }
29
18
 
30
- function statusClass(status: string): string {
19
+ function statusTone(status: string): string {
31
20
  if (['success', 'successful', 'completed', 'ready'].includes(status))
32
- return 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40'
21
+ return 'succeeded'
33
22
  if (['failed', 'error', 'cancelled'].includes(status))
34
- return 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40'
23
+ return 'failed'
35
24
  if (['pending', 'queued', 'running', 'deploying'].includes(status))
36
- return 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40'
37
- return 'text-gray-700 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700'
25
+ return 'running'
26
+ return 'other'
38
27
  }
39
28
 
40
- function statusLabel(status: string): string {
41
- if (!status)
42
- return 'Unknown'
43
- return status.charAt(0).toUpperCase() + status.slice(1)
44
- }
29
+ const COLUMNS = [
30
+ { key: 'commitLabel', label: 'Commit', sortable: true, subtitleKey: 'commitDetail', class: 'font-medium text-gray-900 dark:text-white' },
31
+ { key: 'environment', label: 'Environment', sortable: true, emptyText: 'Not recorded' },
32
+ {
33
+ key: 'statusTone',
34
+ label: 'Status',
35
+ type: 'badge' as const,
36
+ sortable: true,
37
+ badgeLabelKey: 'statusLabel',
38
+ badgeClasses: {
39
+ succeeded: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/40',
40
+ failed: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
41
+ running: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
42
+ },
43
+ },
44
+ { key: 'durationLabel', label: 'Duration', align: 'right' as const, searchable: false, class: 'font-mono' },
45
+ { key: 'createdAt', label: 'Created', type: 'datetime' as const, align: 'right' as const, sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
46
+ ]
47
+
48
+ const rows = derived(() => (deployments() as DeploymentRecord[]).map(deployment => ({
49
+ ...deployment,
50
+ commitLabel: deployment.commitMessage || 'No commit message',
51
+ commitDetail: `${deployment.commitHash || 'No commit hash'} · ${deployment.branch || 'No branch'}`,
52
+ statusTone: statusTone(deployment.status),
53
+ statusLabel: deployment.status ? deployment.status.charAt(0).toUpperCase() + deployment.status.slice(1) : 'Unknown',
54
+ durationLabel: formatDuration(deployment.duration),
55
+ })))
45
56
  </script>
46
57
 
47
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
48
- <div :if="loading()" class="px-5 py-12 text-center text-gray-500 text-sm dark:text-neutral-400">Loading deployments</div>
49
- <div :if="!loading() && rows().length === 0" class="px-5 py-12 text-center">
50
- <div class="mb-3 mx-auto h-9 w-9 text-gray-400 i-hugeicons-rocket-01"></div>
51
- <p class="font-semibold text-gray-900 dark:text-white">No deployment records found</p>
52
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Records appear when a deployment integration writes to the Deployment model.</p>
53
- </div>
54
- <div :if="rows().length > 0" class="overflow-x-auto">
55
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
56
- <thead class="bg-gray-50 dark:bg-neutral-700/50">
57
- <tr>
58
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Commit</th>
59
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Environment</th>
60
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Status</th>
61
- <th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Duration</th>
62
- <th class="px-4 py-3 font-medium text-gray-500 text-right text-xs uppercase dark:text-neutral-300">Created</th>
63
- <th class="px-4 py-3"><span class="sr-only">Actions</span></th>
64
- </tr>
65
- </thead>
66
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
67
- <template :for="deployment in rows()">
68
- <tr>
69
- <td class="px-4 py-3">
70
- <p class="font-medium text-gray-900 text-sm dark:text-white">{{ deployment.commitMessage || 'No commit message' }}</p>
71
- <p class="mt-0.5 font-mono text-gray-500 text-xs dark:text-neutral-400">{{ deployment.commitHash || 'No commit hash' }} · {{ deployment.branch || 'No branch' }}</p>
72
- </td>
73
- <td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ deployment.environment || 'Not recorded' }}</td>
74
- <td class="px-4 py-3">
75
- <span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-md ' + statusClass(deployment.status)">{{ statusLabel(deployment.status) }}</span>
76
- </td>
77
- <td class="px-4 py-3 font-mono text-gray-500 text-right text-sm dark:text-neutral-300">{{ formatDuration(deployment.duration) }}</td>
78
- <td class="px-4 py-3 text-gray-500 text-right text-sm whitespace-nowrap dark:text-neutral-300">{{ formatDate(deployment.createdAt) }}</td>
79
- <td class="px-4 py-3 text-right">
80
- <StxLink :to="'/deployments/' + deployment.id" class="font-medium text-blue-600 text-sm dark:text-blue-400 hover:text-blue-500">View</StxLink>
81
- </td>
82
- </tr>
83
- </template>
84
- </tbody>
85
- </table>
86
- </div>
87
- </div>
58
+ <Table
59
+ :columns="COLUMNS"
60
+ :data="rows()"
61
+ :loading="loading()"
62
+ paginated
63
+ :page-size="25"
64
+ empty-title="No deployment records found"
65
+ empty-description="Records appear when a deployment integration writes to the Deployment model."
66
+ >
67
+ <template #actions="{ row }">
68
+ <div class="text-right">
69
+ <StxLink :to="'/deployments/' + row.id" class="font-medium text-blue-600 text-sm dark:text-blue-400 hover:text-blue-500">View</StxLink>
70
+ </div>
71
+ </template>
72
+ </Table>