@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.
- package/ai/skills/stacks-api/SKILL.md +5 -4
- package/ai/skills/stacks-browse/SKILL.md +1 -0
- package/ai/skills/stacks-browse/scripts/browse.ts +40 -4
- package/ai/skills/stacks-email/SKILL.md +12 -8
- package/ai/skills/stacks-env/SKILL.md +1 -1
- package/ai/skills/stacks-lint/SKILL.md +9 -0
- package/ai/skills/stacks-mail/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +32 -1
- package/ai/skills/stacks-models/SKILL.md +20 -0
- package/ai/skills/stacks-native/CATALOG.md +19 -19
- package/ai/skills/stacks-scheduler/SKILL.md +11 -0
- package/ai/skills/stacks-testing/SKILL.md +14 -0
- package/app/Models/Content/Author.ts +3 -3
- package/app/Models/User.ts +4 -4
- package/bootstrap.ts +122 -102
- package/ide/vscode/package.json +1 -1
- package/package.json +4 -4
- package/resources/components/Dashboard/Auth/AccessTokens.stx +57 -37
- package/resources/components/Dashboard/Billing/BillingTransactionHistory.stx +27 -33
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +85 -49
- package/resources/components/Dashboard/Commerce/CommerceTaxesTable.stx +63 -66
- package/resources/components/Dashboard/Commerce/CommerceVariantsTable.stx +67 -23
- package/resources/components/Dashboard/Content/BlogDashboard.stx +73 -67
- package/resources/components/Dashboard/Content/FileManagerDashboard.stx +39 -12
- package/resources/components/Dashboard/Data/DataRecords.stx +85 -88
- package/resources/components/Dashboard/Deployments/DeploymentTable.stx +49 -64
- package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +72 -61
- package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +74 -70
- 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
|
-
|
|
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 '
|
|
23
|
+
return 'settled'
|
|
27
24
|
if (status === 'failed')
|
|
28
|
-
return '
|
|
25
|
+
return 'failed'
|
|
29
26
|
if (status === 'refunded' || status === 'partiallyRefunded')
|
|
30
|
-
return '
|
|
31
|
-
return '
|
|
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
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
</
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
: '
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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('')
|