@stacksjs/defaults 0.75.263 → 0.75.267

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 (23) 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-chat/SKILL.md +32 -0
  4. package/ai/skills/stacks-native/CATALOG.md +8 -8
  5. package/ide/vscode/package.json +1 -1
  6. package/package.json +4 -4
  7. package/resources/components/Dashboard/App/RequestsOverview.stx +75 -58
  8. package/resources/components/Dashboard/App/SourceInventory.stx +68 -59
  9. package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
  10. package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
  11. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
  12. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
  13. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
  14. package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
  15. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
  16. package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
  17. package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
  18. package/resources/components/Dashboard/Infrastructure/DnsDashboard.stx +46 -28
  19. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
  20. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
  21. package/resources/components/Dashboard/Queue/QueueDashboard.stx +27 -61
  22. package/resources/components/Dashboard/Releases/ReleaseDashboard.stx +35 -61
  23. package/resources/components/Dashboard/Teams/TeamPeopleDashboard.stx +127 -143
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { TaxRateRecord } from '../../../../app/Actions/Dashboard/Commerce/tax-rate-records'
3
5
 
@@ -5,72 +7,67 @@ const records = useReactiveProp('records', [] as TaxRateRecord[])
5
7
  const loading = useReactiveProp('loading', false)
6
8
  const emit = defineEmits()
7
9
 
8
- function formatDate(value: string): string {
9
- const date = new Date(value)
10
- return !value || Number.isNaN(date.getTime())
11
- ? 'Unknown'
12
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)
10
+ /**
11
+ * The name cell is slotted: the default, code and exemptible badges sit
12
+ * beside the name because each changes what a customer is charged, and the
13
+ * code is what application code matches on, so renaming the rate must not
14
+ * change the bill. Everything else is a column option.
15
+ */
16
+ const COLUMNS = [
17
+ { key: 'name', label: 'Tax rate', sortable: true, slotted: true },
18
+ { key: 'rateLabel', label: 'Rate', sortable: true, searchable: false, class: 'font-medium text-gray-900 whitespace-nowrap dark:text-white' },
19
+ { key: 'country', label: 'Location', sortable: true, subtitleKey: 'region' },
20
+ {
21
+ key: 'status',
22
+ label: 'Status',
23
+ type: 'badge' as const,
24
+ sortable: true,
25
+ badgeClasses: { active: 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40' },
26
+ badgeLabels: { active: 'Active', inactive: 'Inactive' },
27
+ },
28
+ { key: 'createdAt', label: 'Created', type: 'date' as const, sortable: true, class: 'whitespace-nowrap' },
29
+ ]
30
+
31
+ const ROW_ACTIONS = [
32
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
33
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
34
+ ]
35
+
36
+ const rows = derived(() => records().map(record => ({
37
+ ...record,
38
+ rateLabel: `${record.rate}%`,
39
+ // Anything but active reads as inactive, as the hand-rolled pill did.
40
+ status: record.status === 'active' ? 'active' : 'inactive',
41
+ })))
42
+
43
+ function handleRowAction(payload: { action: string, row: TaxRateRecord }): void {
44
+ emit(payload.action, payload.row)
13
45
  }
14
46
  </script>
15
47
 
16
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
17
- <div :if="loading() && records().length === 0" class="flex items-center justify-center py-16 text-gray-500 text-sm dark:text-neutral-400">
18
- <span class="mr-2 h-5 w-5 animate-spin i-hugeicons-loading-03"></span>
19
- Loading tax rates
20
- </div>
21
- <div :else-if="records().length === 0" class="px-6 py-16 text-center">
22
- <span class="inline-block h-8 w-8 text-gray-400 i-hugeicons-percent-circle"></span>
23
- <p class="mt-3 font-medium text-gray-900 text-sm dark:text-white">No tax rates found</p>
24
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters or add a TaxRate record.</p>
25
- </div>
26
- <div :else class="overflow-x-auto">
27
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
28
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
29
- <tr>
30
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Tax rate</th>
31
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Rate</th>
32
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Location</th>
33
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Status</th>
34
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Created</th>
35
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs dark:text-neutral-300"><span class="sr-only">Actions</span></th>
36
- </tr>
37
- </thead>
38
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
39
- <template :for="record in records()" :key="record.id">
40
- <tr class="align-top">
41
- <td class="px-4 py-4">
42
- <div class="flex flex-wrap gap-2 items-center">
43
- <p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.name }}</p>
44
- <span :if="record.isDefault" class="px-2 py-0.5 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-full">Default</span>
45
- {{--
46
- Both badges change what a customer is charged, so they sit
47
- next to the name rather than in a column someone has to
48
- scroll to. The code is what application code matches on;
49
- renaming the rate must not change the bill.
50
- --}}
51
- <span :if="record.code" class="px-2 py-0.5 font-mono text-gray-600 text-xs dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700 rounded-full">{{ record.code }}</span>
52
- <span :if="record.exemptible" class="px-2 py-0.5 font-medium text-amber-700 text-xs dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 rounded-full" x-tooltip="Not charged to a customer who qualifies for an exemption" title="Not charged to a customer who qualifies for an exemption">Exemptible</span>
53
- </div>
54
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.type }}</p>
55
- </td>
56
- <td class="px-4 py-4 font-medium text-gray-900 text-sm whitespace-nowrap dark:text-white">{{ record.rate }}%</td>
57
- <td class="px-4 py-4">
58
- <p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.country }}</p>
59
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.region }}</p>
60
- </td>
61
- <td class="px-4 py-4">
62
- <span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + (record.status === 'active' ? 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40' : 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700')">{{ record.status === 'active' ? 'Active' : 'Inactive' }}</span>
63
- </td>
64
- <td class="px-4 py-4 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
65
- <td class="px-4 py-4">
66
- <div class="flex gap-1 justify-end">
67
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + record.name" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span></Button>
68
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + record.name" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button>
69
- </div>
70
- </td>
71
- </tr>
72
- </template>
73
- </tbody>
74
- </table>
75
- </div>
76
- </div>
48
+ {{--
49
+ No `searchable` or `filterable`: the parent dashboard owns the search box
50
+ and the filter selects. Paging is what was missing, and Table brings it.
51
+ --}}
52
+ <Table
53
+ :columns="COLUMNS"
54
+ :data="rows()"
55
+ :loading="loading()"
56
+ :row-actions="ROW_ACTIONS"
57
+ action-label-key="name"
58
+ paginated
59
+ :page-size="25"
60
+ empty-title="No tax rates found"
61
+ empty-description="Adjust the filters or add a TaxRate record."
62
+ @row-action="handleRowAction"
63
+ >
64
+ <template #cell="{ row }">
65
+ <div class="flex flex-wrap gap-2 items-center">
66
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.name"></p>
67
+ <span :if="row.isDefault" class="px-2 py-0.5 font-medium text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-full">Default</span>
68
+ <span :if="row.code" class="px-2 py-0.5 font-mono text-gray-600 text-xs dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700 rounded-full" x-text="row.code"></span>
69
+ <span :if="row.exemptible" class="px-2 py-0.5 font-medium text-amber-700 text-xs dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40 rounded-full" x-tooltip="Not charged to a customer who qualifies for an exemption" title="Not charged to a customer who qualifies for an exemption">Exemptible</span>
70
+ </div>
71
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400" x-text="row.type"></p>
72
+ </template>
73
+ </Table>
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { ProductVariantRecord } from '../../../../app/Actions/Dashboard/Commerce/product-variant-records'
3
5
 
@@ -5,30 +7,72 @@ const records = useReactiveProp('records', [] as ProductVariantRecord[])
5
7
  const loading = useReactiveProp('loading', false)
6
8
  const emit = defineEmits()
7
9
 
8
- function formatDate(value: string): string {
9
- const date = new Date(value)
10
- return !value || Number.isNaN(date.getTime()) ? 'Unknown' : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)
10
+ /**
11
+ * Two slotted columns, dispatched on `column.key`: the variant with its type
12
+ * and description beneath it, and the option pills, of which the first four
13
+ * show and the rest are counted.
14
+ */
15
+ const COLUMNS = [
16
+ { key: 'variant', label: 'Variant', sortable: true, slotted: true },
17
+ { key: 'productName', label: 'Product', sortable: true, emptyText: 'Unassigned' },
18
+ { key: 'optionsLabel', label: 'Options', slotted: true, class: 'max-w-sm' },
19
+ {
20
+ key: 'status',
21
+ label: 'Status',
22
+ type: 'badge' as const,
23
+ sortable: true,
24
+ badgeClasses: {
25
+ active: 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40',
26
+ draft: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
27
+ },
28
+ },
29
+ { key: 'createdAt', label: 'Created', type: 'date' as const, sortable: true, class: 'whitespace-nowrap' },
30
+ ]
31
+
32
+ const ROW_ACTIONS = [
33
+ { action: 'edit', label: 'Edit', icon: 'i-hugeicons-edit-01' },
34
+ { action: 'delete', label: 'Delete', icon: 'i-hugeicons-delete-02', class: 'text-red-500' },
35
+ ]
36
+
37
+ const rows = derived(() => records().map(record => ({
38
+ ...record,
39
+ // Searchable text for the options, and the parts the cell draws.
40
+ optionsLabel: record.options.join(', '),
41
+ shownOptions: record.options.slice(0, 4),
42
+ moreOptions: record.options.length > 4 ? `+${record.options.length - 4}` : '',
43
+ })))
44
+
45
+ function handleRowAction(payload: { action: string, row: ProductVariantRecord }): void {
46
+ emit(payload.action, payload.row)
11
47
  }
12
48
  </script>
13
49
 
14
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
15
- <div :if="loading() && records().length === 0" class="flex items-center justify-center py-16 text-gray-500 text-sm dark:text-neutral-400"><span class="mr-2 h-5 w-5 animate-spin i-hugeicons-loading-03"></span>Loading product variants</div>
16
- <div :else-if="records().length === 0" class="px-6 py-16 text-center"><span class="inline-block h-8 w-8 text-gray-400 i-hugeicons-layer"></span><p class="mt-3 font-medium text-gray-900 text-sm dark:text-white">No product variants found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters or add a ProductVariant record.</p></div>
17
- <div :else class="overflow-x-auto">
18
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
19
- <thead class="bg-gray-50 dark:bg-neutral-900/50"><tr><th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Variant</th><th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Product</th><th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Options</th><th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Status</th><th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Created</th><th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs dark:text-neutral-300"><span class="sr-only">Actions</span></th></tr></thead>
20
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
21
- <template :for="record in records()" :key="record.id">
22
- <tr class="align-top">
23
- <td class="px-4 py-4"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ record.variant }}</p><p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ record.type }}</p><p class="mt-2 max-w-sm text-gray-500 text-xs dark:text-neutral-400">{{ record.description }}</p></td>
24
- <td class="px-4 py-4 text-gray-600 text-sm dark:text-neutral-300">{{ record.productName || 'Unassigned' }}</td>
25
- <td class="px-4 py-4 max-w-sm"><div class="flex flex-wrap gap-1"><template :for="option in record.options.slice(0, 4)" :key="option"><span class="px-2 py-0.5 text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-full">{{ option }}</span></template><span :if="record.options.length > 4" class="px-2 py-0.5 text-gray-600 text-xs dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700 rounded-full">+{{ record.options.length - 4 }}</span><span :if="record.options.length === 0" class="text-gray-400 text-xs">No options</span></div></td>
26
- <td class="px-4 py-4"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + (record.status === 'active' ? 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40' : record.status === 'draft' ? 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40' : 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700')">{{ record.status }}</span></td>
27
- <td class="px-4 py-4 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ formatDate(record.createdAt) }}</td>
28
- <td class="px-4 py-4"><div class="flex gap-1 justify-end"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit ' + record.variant" @click="emit('edit', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span></Button><Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete ' + record.variant" @click="emit('delete', record)"><span class="block h-4 w-4 text-red-500 i-hugeicons-delete-02"></span></Button></div></td>
29
- </tr>
50
+ <Table
51
+ :columns="COLUMNS"
52
+ :data="rows()"
53
+ :loading="loading()"
54
+ :row-actions="ROW_ACTIONS"
55
+ action-label-key="variant"
56
+ paginated
57
+ :page-size="25"
58
+ empty-title="No product variants found"
59
+ empty-description="Adjust the filters or add a ProductVariant record."
60
+ @row-action="handleRowAction"
61
+ >
62
+ <template #cell="{ row, column }">
63
+ <template :if="column.key === 'variant'">
64
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.variant"></p>
65
+ <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400" x-text="row.type"></p>
66
+ <p class="mt-2 max-w-sm text-gray-500 text-xs dark:text-neutral-400" x-text="row.description"></p>
67
+ </template>
68
+ <template :else>
69
+ <div class="flex flex-wrap gap-1">
70
+ <template :for="option in row.shownOptions" :key="option">
71
+ <span class="px-2 py-0.5 text-blue-700 text-xs dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40 rounded-full" x-text="option"></span>
30
72
  </template>
31
- </tbody>
32
- </table>
33
- </div>
34
- </div>
73
+ <span :if="row.moreOptions" class="px-2 py-0.5 text-gray-600 text-xs dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700 rounded-full" x-text="row.moreOptions"></span>
74
+ <span :if="row.shownOptions.length === 0" class="text-gray-400 text-xs">No options</span>
75
+ </div>
76
+ </template>
77
+ </template>
78
+ </Table>
@@ -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>