@stacksjs/defaults 0.75.262 → 0.75.264

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 (29) hide show
  1. package/ai/skills/stacks-api/SKILL.md +5 -4
  2. package/ai/skills/stacks-browse/SKILL.md +1 -0
  3. package/ai/skills/stacks-browse/scripts/browse.ts +40 -4
  4. package/ai/skills/stacks-email/SKILL.md +12 -8
  5. package/ai/skills/stacks-env/SKILL.md +1 -1
  6. package/ai/skills/stacks-lint/SKILL.md +9 -0
  7. package/ai/skills/stacks-mail/SKILL.md +1 -1
  8. package/ai/skills/stacks-migrations/SKILL.md +32 -1
  9. package/ai/skills/stacks-models/SKILL.md +20 -0
  10. package/ai/skills/stacks-native/CATALOG.md +19 -19
  11. package/ai/skills/stacks-scheduler/SKILL.md +11 -0
  12. package/ai/skills/stacks-testing/SKILL.md +14 -0
  13. package/app/Models/Content/Author.ts +3 -3
  14. package/app/Models/User.ts +4 -4
  15. package/bootstrap.ts +122 -102
  16. package/ide/vscode/package.json +1 -1
  17. package/package.json +4 -4
  18. package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
  19. package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
  20. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
  21. package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
  22. package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
  23. package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
  24. package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
  25. package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
  26. package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
  27. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
  28. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
  29. package/resources/components/Dashboard/Queue/QueueDashboard.stx +27 -61
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { PaymentRecord } from '../../../../app/Actions/Dashboard/Commerce/payment-records'
3
5
  import { formatCurrency } from '@stacksjs/commerce/money'
@@ -6,61 +8,95 @@ const records = useReactiveProp('records', [] as PaymentRecord[])
6
8
  const loading = useReactiveProp('loading', false)
7
9
  const emit = defineEmits()
8
10
 
11
+ /** `partiallyRefunded` and `bank_transfer` as words, each capitalized. */
9
12
  function label(value: string): string {
10
- return value.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ')
11
- }
12
-
13
- function money(value: number, currency: string): string {
14
- return formatCurrency(value, currency)
15
- }
16
-
17
- function date(value: string): string {
18
- const parsed = new Date(value.includes('T') ? value : value.replace(' ', 'T'))
19
- return !value || Number.isNaN(parsed.getTime())
20
- ? 'Unknown'
21
- : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed)
13
+ return value.replace(/([a-z])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
22
14
  }
23
15
 
24
- function statusClass(status: string): string {
16
+ /**
17
+ * The status pill's colour, which is a tone rather than the status itself so
18
+ * that every status the ledger has not named still reads as pending, as the
19
+ * hand-rolled pill did.
20
+ */
21
+ function statusTone(status: string): string {
25
22
  if (status === 'completed' || status === 'succeeded')
26
- return 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40'
23
+ return 'settled'
27
24
  if (status === 'failed')
28
- return 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40'
25
+ return 'failed'
29
26
  if (status === 'refunded' || status === 'partiallyRefunded')
30
- return 'text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-950/40'
31
- return 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40'
27
+ return 'refunded'
28
+ return 'pending'
29
+ }
30
+
31
+ /**
32
+ * Only the amount is slotted, for its refund line, whose colour says whether
33
+ * the refund was recorded. The rest are values with a second line, which is
34
+ * `subtitleKey`.
35
+ */
36
+ const COLUMNS = [
37
+ { key: 'paymentLabel', label: 'Payment', sortable: true, subtitleKey: 'referenceLabel', class: 'font-medium text-gray-900 dark:text-white' },
38
+ { key: 'billingLabel', label: 'Billing', sortable: true, subtitleKey: 'orderLabel' },
39
+ { key: 'amount', label: 'Amount', sortable: true, slotted: true },
40
+ // Capitalized in `label()` rather than with a class, which would reach the
41
+ // processor line beneath it too.
42
+ { key: 'methodLabel', label: 'Method', sortable: true, subtitleKey: 'processorLabel' },
43
+ {
44
+ key: 'statusTone',
45
+ label: 'Status',
46
+ type: 'badge' as const,
47
+ sortable: true,
48
+ badgeLabelKey: 'statusLabel',
49
+ badgeClasses: {
50
+ settled: 'text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40',
51
+ failed: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/40',
52
+ refunded: 'text-violet-700 dark:text-violet-300 bg-violet-50 dark:bg-violet-950/40',
53
+ pending: 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/40',
54
+ },
55
+ },
56
+ { key: 'createdAt', label: 'Created', type: 'datetime' as const, sortable: true, class: 'whitespace-nowrap' },
57
+ ]
58
+
59
+ const ROW_ACTIONS = [
60
+ { action: 'view', label: 'View payment', icon: 'i-hugeicons-view' },
61
+ ]
62
+
63
+ const rows = derived(() => records().map(record => ({
64
+ ...record,
65
+ paymentLabel: `#${record.id}`,
66
+ referenceLabel: record.referenceNumber || record.transactionId || 'No reference',
67
+ billingLabel: record.billingEmail || 'No billing email',
68
+ orderLabel: record.orderId ? `Order #${record.orderId}` : 'No linked order',
69
+ amountLabel: formatCurrency(record.amount, record.currency),
70
+ refundLabel: record.refundAmount > 0 ? `Refunded ${formatCurrency(record.refundAmount, record.currency)}` : '',
71
+ refundMissing: record.refundAmount <= 0 && record.status === 'refunded' && !record.refundRecorded,
72
+ methodLabel: label(record.method),
73
+ processorLabel: record.cardBrand || record.cardLastFour
74
+ ? [record.cardBrand, record.cardLastFour].filter(Boolean).join(' · ')
75
+ : record.paymentProvider || 'No processor details',
76
+ statusTone: statusTone(record.status),
77
+ statusLabel: label(record.status),
78
+ })))
79
+
80
+ function handleRowAction(payload: { action: string, row: PaymentRecord }): void {
81
+ emit(payload.action, payload.row)
32
82
  }
33
83
  </script>
34
84
 
35
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
36
- <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 payments</div>
37
- <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-credit-card"></span><p class="mt-3 font-medium text-gray-900 text-sm dark:text-white">No payments found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the filters to inspect another part of the native ledger.</p></div>
38
- <div :else class="overflow-x-auto">
39
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
40
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
41
- <tr>
42
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Payment</th>
43
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Billing</th>
44
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Amount</th>
45
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Method</th>
46
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Status</th>
47
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Created</th>
48
- <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>
49
- </tr>
50
- </thead>
51
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
52
- <template :for="record in records()" :key="record.id">
53
- <tr>
54
- <td class="px-4 py-4"><p class="font-medium text-gray-900 text-sm dark:text-white">#{{ record.id }}</p><p class="mt-1 max-w-44 text-gray-400 text-xs truncate dark:text-neutral-500">{{ record.referenceNumber || record.transactionId || 'No reference' }}</p></td>
55
- <td class="px-4 py-4"><p class="text-gray-700 text-sm dark:text-neutral-200">{{ record.billingEmail || 'No billing email' }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.orderId ? 'Order #' + record.orderId : 'No linked order' }}</p></td>
56
- <td class="px-4 py-4"><p class="font-medium text-gray-900 text-sm dark:text-white">{{ money(record.amount, record.currency) }}</p><p :if="record.refundAmount > 0" class="mt-1 text-violet-600 text-xs dark:text-violet-300">Refunded {{ money(record.refundAmount, record.currency) }}</p><p :else-if="record.status === 'refunded' && !record.refundRecorded" class="mt-1 text-amber-600 text-xs dark:text-amber-300">Refund amount not recorded</p></td>
57
- <td class="px-4 py-4"><p class="capitalize text-gray-700 text-sm dark:text-neutral-200">{{ label(record.method) }}</p><p class="mt-1 text-gray-400 text-xs dark:text-neutral-500">{{ record.cardBrand }}<span :if="record.cardLastFour"> · {{ record.cardLastFour }}</span><span :if="!record.cardBrand && !record.cardLastFour">{{ record.paymentProvider || 'No processor details' }}</span></p></td>
58
- <td class="px-4 py-4"><span :class="'inline-flex px-2 py-1 font-medium text-xs capitalize rounded-full ' + statusClass(record.status)">{{ label(record.status) }}</span></td>
59
- <td class="px-4 py-4 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-400">{{ date(record.createdAt) }}</td>
60
- <td class="px-4 py-4 text-right"><Button variant="ghost" size="sm" iconOnly :ariaLabel="'View payment ' + record.id" @click="emit('view', record)"><span class="block h-4 w-4 text-blue-500 i-hugeicons-view"></span></Button></td>
61
- </tr>
62
- </template>
63
- </tbody>
64
- </table>
65
- </div>
66
- </div>
85
+ <Table
86
+ :columns="COLUMNS"
87
+ :data="rows()"
88
+ :loading="loading()"
89
+ :row-actions="ROW_ACTIONS"
90
+ action-label-key="id"
91
+ paginated
92
+ :page-size="25"
93
+ empty-title="No payments found"
94
+ empty-description="Adjust the filters to inspect another part of the native ledger."
95
+ @row-action="handleRowAction"
96
+ >
97
+ <template #cell="{ row }">
98
+ <p class="font-medium text-gray-900 text-sm dark:text-white" x-text="row.amountLabel"></p>
99
+ <p :if="row.refundLabel" class="mt-1 text-violet-600 text-xs dark:text-violet-300" x-text="row.refundLabel"></p>
100
+ <p :if="row.refundMissing" class="mt-1 text-amber-600 text-xs dark:text-amber-300">Refund amount not recorded</p>
101
+ </template>
102
+ </Table>
@@ -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('')