@stacksjs/defaults 0.75.242 → 0.75.244

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.
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { ProductWaitlistOptions, ProductWaitlistRecord } from '../../../../app/Actions/Dashboard/Commerce/product-waitlist-records'
3
5
 
@@ -9,73 +11,59 @@ const options = useReactiveProp('options', {
9
11
  const loading = useReactiveProp('loading', false)
10
12
  const emit = defineEmits()
11
13
 
12
- function formatDate(value: string): string {
13
- if (!value)
14
- return 'Not recorded'
15
- const date = new Date(value)
16
- return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)
14
+ const STATUS_CLASSES = {
15
+ purchased: 'text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/30',
16
+ notified: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30',
17
+ waiting: 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/30',
17
18
  }
18
19
 
20
+ const COLUMNS = [
21
+ { key: 'name', label: 'Customer', sortable: true, emptyText: 'Unnamed', subtitleKey: 'contact', class: 'font-medium text-gray-900 dark:text-white' },
22
+ { key: 'productName', label: 'Product', sortable: true },
23
+ { key: 'quantity', label: 'Quantity', type: 'number' as const, sortable: true, align: 'right' as const, class: 'font-mono' },
24
+ { key: 'status', label: 'Status', type: 'badge' as const, sortable: true, badgeClasses: STATUS_CLASSES, emptyText: 'unknown' },
25
+ { key: 'notificationPreference', label: 'Preference', sortable: true, emptyText: 'Not recorded', class: 'capitalize' },
26
+ { key: 'createdAt', label: 'Created', type: 'date' as const, sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
27
+ ]
28
+
29
+ const ROW_ACTIONS = [
30
+ { action: 'edit', label: 'Edit waitlist entry for', icon: 'i-hugeicons-edit-01' },
31
+ { action: 'delete', label: 'Delete waitlist entry for', icon: 'i-hugeicons-waste', class: 'text-red-500' },
32
+ ]
33
+
19
34
  function productLabel(productId: string): string {
20
35
  return options().products.find(option => option.id === productId)?.label || (productId ? `Product ${productId}` : 'Not linked')
21
36
  }
22
37
 
23
- function statusClass(value: string): string {
24
- if (value === 'purchased')
25
- return 'text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/30'
26
- if (value === 'notified')
27
- return 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30'
28
- if (value === 'waiting')
29
- return 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/30'
30
- return 'text-gray-700 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700'
38
+ /**
39
+ * `contact` and `productName` are computed per row because a column reads a
40
+ * field: the contact line falls back through email then phone, and the product
41
+ * name is looked up in the options the parent passed.
42
+ */
43
+ const rows = derived(() => records().map(entry => ({
44
+ ...entry,
45
+ contact: entry.email || entry.phone || 'No contact recorded',
46
+ productName: productLabel(entry.productId),
47
+ })))
48
+
49
+ function handleRowAction(payload: { action: string, row: ProductWaitlistRecord }): void {
50
+ emit(payload.action, payload.row)
31
51
  }
32
52
  </script>
33
53
 
34
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
35
- <div :if="loading()" class="flex items-center justify-center p-8 text-gray-500 text-sm dark:text-neutral-400">
36
- <span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
37
- Loading waitlist records
38
- </div>
39
- <div :else-if="records().length === 0" class="p-8 text-center">
40
- <p class="font-medium text-gray-900 text-sm dark:text-white">No product waitlist records found</p>
41
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Add an entry or adjust the current filters.</p>
42
- </div>
43
- <div :else class="overflow-x-auto">
44
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
45
- <thead class="bg-gray-50 dark:bg-neutral-900/50">
46
- <tr>
47
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Customer</th>
48
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Product</th>
49
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs uppercase dark:text-neutral-400">Quantity</th>
50
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Status</th>
51
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Preference</th>
52
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Created</th>
53
- <th scope="col" class="relative px-4 py-3"><span class="sr-only">Actions</span></th>
54
- </tr>
55
- </thead>
56
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
57
- <template :for="entry in records()" :key="entry.id">
58
- <tr class="hover:bg-gray-50/60 dark:hover:bg-neutral-700/50">
59
- <td class="px-4 py-3">
60
- <p class="font-medium text-gray-900 text-sm dark:text-white">{{ entry.name || 'Unnamed' }}</p>
61
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ entry.email || entry.phone || 'No contact recorded' }}</p>
62
- </td>
63
- <td class="px-4 py-3 text-gray-700 text-sm dark:text-neutral-200">{{ productLabel(entry.productId) }}</td>
64
- <td class="px-4 py-3 font-mono text-gray-700 text-right text-sm dark:text-neutral-200">{{ entry.quantity }}</td>
65
- <td class="px-4 py-3"><span :class="'inline-flex px-2 py-1 font-medium text-xs capitalize rounded-full ' + statusClass(entry.status)">{{ entry.status || 'unknown' }}</span></td>
66
- <td class="px-4 py-3 capitalize text-gray-600 text-sm dark:text-neutral-300">{{ entry.notificationPreference || 'Not recorded' }}</td>
67
- <td class="px-4 py-3 text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300">{{ formatDate(entry.createdAt) }}</td>
68
- <td class="px-4 py-3 text-right whitespace-nowrap">
69
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Edit waitlist entry for ' + (entry.name || 'customer')" @click="emit('edit', entry)">
70
- <span class="block h-4 w-4 text-blue-500 i-hugeicons-edit-01"></span>
71
- </Button>
72
- <Button variant="ghost" size="sm" iconOnly :ariaLabel="'Delete waitlist entry for ' + (entry.name || 'customer')" @click="emit('delete', entry)">
73
- <span class="block h-4 w-4 text-red-500 i-hugeicons-waste"></span>
74
- </Button>
75
- </td>
76
- </tr>
77
- </template>
78
- </tbody>
79
- </table>
80
- </div>
81
- </div>
54
+ {{--
55
+ No `searchable` or `filterable`: the parent dashboard owns the search box and
56
+ the filter selects. Paging is what was missing.
57
+ --}}
58
+ <Table
59
+ :columns="COLUMNS"
60
+ :data="rows()"
61
+ :loading="loading()"
62
+ :row-actions="ROW_ACTIONS"
63
+ action-label-key="name"
64
+ paginated
65
+ :page-size="25"
66
+ empty-title="No product waitlist records found"
67
+ empty-description="Add an entry or adjust the current filters."
68
+ @row-action="handleRowAction"
69
+ />
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import {
3
5
  restaurantWaitlistTimestamp,
@@ -9,9 +11,38 @@ const loading = useReactiveProp('loading', false)
9
11
  const busyId = useReactiveProp('busyId', '')
10
12
  const emit = defineEmits()
11
13
 
14
+ const STATUS_CLASSES = {
15
+ seated: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30',
16
+ waiting: 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/30',
17
+ no_show: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/30',
18
+ }
19
+
20
+ const COLUMNS = [
21
+ { key: 'name', label: 'Party', sortable: true, emptyText: 'Unnamed party', subtitleKey: 'contact', class: 'font-medium text-gray-900 dark:text-white' },
22
+ { key: 'partySize', label: 'Size', type: 'number' as const, sortable: true, align: 'right' as const, class: 'font-mono' },
23
+ // `no_show` renders as "No show" from the badge's own humanising, so no
24
+ // label map is needed for it.
25
+ { key: 'status', label: 'Status', type: 'badge' as const, sortable: true, badgeClasses: STATUS_CLASSES },
26
+ { key: 'quotedWait', label: 'Quoted wait', sortable: true, align: 'right' as const, class: 'font-mono' },
27
+ { key: 'preference', label: 'Preference', sortable: true, class: 'capitalize' },
28
+ { key: 'checkIn', label: 'Check in', sortable: true, emptyText: 'Not recorded', class: 'whitespace-nowrap' },
29
+ ]
30
+
31
+ /**
32
+ * Seating and marking a no-show only apply while the party is still waiting,
33
+ * which is what `when` expresses. All four are disabled on the row whose write
34
+ * is in flight, via `busy-key`/`busy-value`.
35
+ */
36
+ const ROW_ACTIONS = [
37
+ { action: 'seat', label: 'Seat party for', icon: 'i-hugeicons-checkmark-circle-02', class: 'text-green-600 dark:text-green-300', when: { key: 'status', equals: 'waiting' } },
38
+ { action: 'no-show', label: 'Mark no show for', icon: 'i-hugeicons-user-remove-01', class: 'text-red-600 dark:text-red-300', when: { key: 'status', equals: 'waiting' } },
39
+ { action: 'edit', label: 'Edit restaurant waitlist entry for', icon: 'i-hugeicons-edit-01' },
40
+ { action: 'delete', label: 'Delete restaurant waitlist entry for', icon: 'i-hugeicons-waste', class: 'text-red-600 dark:text-red-300' },
41
+ ]
42
+
12
43
  function formatDateTime(value: string): string {
13
44
  if (!value)
14
- return 'Not recorded'
45
+ return ''
15
46
  const timestamp = restaurantWaitlistTimestamp(value)
16
47
  if (!timestamp)
17
48
  return value
@@ -22,79 +53,43 @@ function formatDateTime(value: string): string {
22
53
  }
23
54
 
24
55
  function preferenceLabel(value: string): string {
25
- return value === 'no_preference'
26
- ? 'No preference'
27
- : value.replaceAll('_', ' ')
56
+ return value === 'no_preference' ? 'No preference' : value.replaceAll('_', ' ')
28
57
  }
29
58
 
30
- function statusLabel(value: string): string {
31
- return value.replaceAll('_', ' ')
32
- }
59
+ /**
60
+ * Computed per row because a column reads a field. `checkIn` keeps this
61
+ * component's own parser - a restaurant check-in is stored as a unix
62
+ * timestamp, which `UI/Table`'s `datetime` type does not read - and the
63
+ * quoted wait carries its unit.
64
+ */
65
+ const rows = derived(() => records().map(entry => ({
66
+ ...entry,
67
+ contact: entry.phone || entry.email || 'No contact recorded',
68
+ quotedWait: `${entry.quotedWaitTime} min`,
69
+ preference: preferenceLabel(entry.tablePreference),
70
+ checkIn: formatDateTime(entry.checkInTime),
71
+ })))
33
72
 
34
- function statusClass(value: string): string {
35
- if (value === 'seated')
36
- return 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30'
37
- if (value === 'waiting')
38
- return 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/30'
39
- if (value === 'no_show')
40
- return 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950/30'
41
- return 'text-gray-700 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700'
73
+ function handleRowAction(payload: { action: string, row: RestaurantWaitlistRecord }): void {
74
+ emit(payload.action, payload.row)
42
75
  }
43
76
  </script>
44
77
 
45
- <div class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
46
- <div :if="loading()" class="flex items-center justify-center p-8 text-gray-500 text-sm dark:text-neutral-400">
47
- <span class="mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
48
- Loading restaurant waitlist
49
- </div>
50
- <div :else-if="records().length === 0" class="p-8 text-center">
51
- <p class="font-medium text-gray-900 text-sm dark:text-white">No restaurant waitlist records found</p>
52
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Add a party or adjust the current filters.</p>
53
- </div>
54
- <div :else 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-900/50">
57
- <tr>
58
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Party</th>
59
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs uppercase dark:text-neutral-400">Size</th>
60
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Status</th>
61
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-right text-xs uppercase dark:text-neutral-400">Quoted wait</th>
62
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Preference</th>
63
- <th scope="col" class="px-4 py-3 font-semibold text-gray-600 text-left text-xs uppercase dark:text-neutral-400">Check in</th>
64
- <th scope="col" class="relative px-4 py-3"><span class="sr-only">Actions</span></th>
65
- </tr>
66
- </thead>
67
- <tbody class="divide-gray-100 divide-y dark:divide-neutral-700">
68
- <template :for="entry in records()" :key="entry.id">
69
- <tr class="hover:bg-gray-50/60 dark:hover:bg-neutral-700/50">
70
- <td class="px-4 py-3">
71
- <p class="font-medium text-gray-900 text-sm dark:text-white">{{ entry.name || 'Unnamed party' }}</p>
72
- <p class="mt-1 text-gray-500 text-xs dark:text-neutral-400">{{ entry.phone || entry.email || 'No contact recorded' }}</p>
73
- </td>
74
- <td class="px-4 py-3 font-mono text-gray-700 text-right text-sm dark:text-neutral-200">{{ entry.partySize }}</td>
75
- <td class="px-4 py-3">
76
- <span :class="'inline-flex px-2 py-1 font-medium text-xs capitalize rounded-full ' + statusClass(entry.status)">{{ statusLabel(entry.status) }}</span>
77
- </td>
78
- <td class="px-4 py-3 font-mono text-gray-700 text-right text-sm dark:text-neutral-200">{{ entry.quotedWaitTime }} min</td>
79
- <td class="px-4 py-3 capitalize text-gray-600 text-sm dark:text-neutral-300">{{ preferenceLabel(entry.tablePreference) }}</td>
80
- <td class="px-4 py-3 text-gray-600 text-sm whitespace-nowrap dark:text-neutral-300">{{ formatDateTime(entry.checkInTime) }}</td>
81
- <td class="px-4 py-3 text-right whitespace-nowrap">
82
- <Button :if="entry.status === 'waiting'" variant="ghost" size="md" iconOnly :disabled="busyId() === entry.id" :ariaLabel="'Seat party for ' + (entry.name || 'customer')" @click="emit('seat', entry)">
83
- <span class="block h-5 w-5 text-green-600 dark:text-green-300 i-hugeicons-checkmark-circle-02"></span>
84
- </Button>
85
- <Button :if="entry.status === 'waiting'" variant="ghost" size="md" iconOnly :disabled="busyId() === entry.id" :ariaLabel="'Mark no show for ' + (entry.name || 'customer')" @click="emit('no-show', entry)">
86
- <span class="block h-5 w-5 text-red-600 dark:text-red-300 i-hugeicons-user-remove-01"></span>
87
- </Button>
88
- <Button variant="ghost" size="md" iconOnly :disabled="busyId() === entry.id" :ariaLabel="'Edit restaurant waitlist entry for ' + (entry.name || 'customer')" @click="emit('edit', entry)">
89
- <span class="block h-5 w-5 text-blue-600 dark:text-blue-300 i-hugeicons-edit-01"></span>
90
- </Button>
91
- <Button variant="ghost" size="md" iconOnly :disabled="busyId() === entry.id" :ariaLabel="'Delete restaurant waitlist entry for ' + (entry.name || 'customer')" @click="emit('delete', entry)">
92
- <span class="block h-5 w-5 text-red-600 dark:text-red-300 i-hugeicons-waste"></span>
93
- </Button>
94
- </td>
95
- </tr>
96
- </template>
97
- </tbody>
98
- </table>
99
- </div>
100
- </div>
78
+ {{--
79
+ No `searchable` or `filterable`: the parent dashboard owns the search box and
80
+ the filter selects. Paging is what was missing.
81
+ --}}
82
+ <Table
83
+ :columns="COLUMNS"
84
+ :data="rows()"
85
+ :loading="loading()"
86
+ :row-actions="ROW_ACTIONS"
87
+ action-label-key="name"
88
+ busy-key="id"
89
+ :busy-value="busyId()"
90
+ paginated
91
+ :page-size="25"
92
+ empty-title="No restaurant waitlist records found"
93
+ empty-description="Add a party or adjust the current filters."
94
+ @row-action="handleRowAction"
95
+ />
@@ -4,13 +4,20 @@ import { useDashboard } from '../../../../functions/dashboard'
4
4
  const dashboard = useDashboard()
5
5
  const loadingCards = [1, 2, 3, 4]
6
6
 
7
- const quickLinks = [
7
+ /**
8
+ * `section` names the feature-backed dashboard section a card belongs to.
9
+ * A card with one is only offered when the server says that section exists -
10
+ * otherwise the home page links to pages the sidebar has hidden and that
11
+ * cannot load. See stacksjs/stacks#2928.
12
+ */
13
+ const allQuickLinks = [
8
14
  {
9
15
  title: 'Blog',
10
16
  description: 'Manage persisted posts',
11
17
  href: '/content/dashboard',
12
18
  icon: 'i-hugeicons-book-open-01',
13
19
  tone: 'blue',
20
+ section: 'content',
14
21
  },
15
22
  {
16
23
  title: 'Commerce',
@@ -18,6 +25,7 @@ const quickLinks = [
18
25
  href: '/commerce/dashboard',
19
26
  icon: 'i-hugeicons-store-01',
20
27
  tone: 'green',
28
+ section: 'commerce',
21
29
  },
22
30
  {
23
31
  title: 'Cloud',
@@ -35,6 +43,16 @@ const quickLinks = [
35
43
  },
36
44
  ]
37
45
 
46
+ /**
47
+ * Cards without a `section` are always offered. A card with one waits for the
48
+ * server to say that section exists, so the home page never links to a
49
+ * section the sidebar has hidden and whose pages cannot load.
50
+ */
51
+ function quickLinks() {
52
+ const available = dashboard.sections.value
53
+ return allQuickLinks.filter(link => !link.section || available[link.section] !== false)
54
+ }
55
+
38
56
  const resourceLinks = [
39
57
  {
40
58
  title: 'Documentation',
@@ -153,7 +171,7 @@ onMount(() => {
153
171
  </div>
154
172
  </section>
155
173
 
156
- <QuickLinks class="mb-8" :links="quickLinks" />
174
+ <QuickLinks class="mb-8" :links="quickLinks()" />
157
175
 
158
176
  <div class="grid gap-6 grid-cols-1 lg:grid-cols-2 mt-8">
159
177
  <ServiceHealth :services="services()" :loading="dashboard.isLoading.value" />
@@ -1,4 +1,5 @@
1
1
  @import('../UI/Button')
2
+ @import('../UI/Table')
2
3
 
3
4
  <script client>
4
5
  import { dashboardApi } from '../../../../functions/dashboard-api'
@@ -11,13 +12,31 @@ interface DependencyRow {
11
12
  isScoped?: boolean
12
13
  }
13
14
 
15
+ type DependencyKind = 'workspace' | 'transitive' | 'third-party'
16
+
17
+ const COLUMNS = [
18
+ { key: 'name', label: 'Package', sortable: true, class: 'font-medium text-gray-900 dark:text-white' },
19
+ { key: 'version', label: 'Version', sortable: true, class: 'font-mono text-gray-500 dark:text-neutral-400' },
20
+ {
21
+ key: 'kind',
22
+ label: 'Source',
23
+ type: 'badge' as const,
24
+ sortable: true,
25
+ badgeClasses: {
26
+ 'workspace': 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40',
27
+ 'transitive': 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700',
28
+ 'third-party': 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700',
29
+ },
30
+ badgeLabels: { 'workspace': 'Workspace', 'transitive': 'Transitive', 'third-party': 'Third-party' },
31
+ },
32
+ ]
33
+
14
34
  const declared = state<DependencyRow[]>([])
15
35
  const transitive = state<DependencyRow[]>([])
16
36
  const available = state(true)
17
37
  const source = state('bun.lock')
18
38
  const loading = state(true)
19
39
  const loadError = state('')
20
- const search = state('')
21
40
  const dataset = state('declared')
22
41
  const scope = state('all')
23
42
 
@@ -25,12 +44,22 @@ function activeRows(): DependencyRow[] {
25
44
  return dataset() === 'transitive' ? transitive() : declared()
26
45
  }
27
46
 
28
- function visibleRows(): DependencyRow[] {
29
- const query = search().trim().toLowerCase()
30
- return activeRows().filter(dependency =>
31
- (dataset() === 'transitive' || scope() === 'all' || (scope() === 'workspace' ? dependency.isWorkspace : !dependency.isWorkspace))
32
- && (!query || [dependency.name, dependency.version].some(value => value.toLowerCase().includes(query))),
33
- )
47
+ /**
48
+ * `UI/Table` owns search, sort and paging, so this only applies the two
49
+ * filters that are specific to dependencies: which dataset is being read and,
50
+ * for the declared set, whether workspace packages are in scope.
51
+ *
52
+ * `kind` is computed here rather than in the cell because a badge column maps
53
+ * a row value to a label, and the right label for a non-workspace package
54
+ * depends on the dataset rather than the row.
55
+ */
56
+ function visibleRows(): Array<DependencyRow & { kind: DependencyKind }> {
57
+ return activeRows()
58
+ .filter(dependency => dataset() === 'transitive' || scope() === 'all' || (scope() === 'workspace' ? dependency.isWorkspace : !dependency.isWorkspace))
59
+ .map(dependency => ({
60
+ ...dependency,
61
+ kind: dependency.isWorkspace ? 'workspace' : dataset() === 'transitive' ? 'transitive' : 'third-party',
62
+ }))
34
63
  }
35
64
 
36
65
  function workspaceCount(): number {
@@ -81,12 +110,37 @@ onMount(() => { void loadDependencies() })
81
110
  <article class="p-4 bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700"><p class="text-gray-500 text-xs dark:text-neutral-400">Transitive</p><p class="mt-2 font-semibold text-2xl text-gray-900 dark:text-white">{{ transitive().length }}</p></article>
82
111
  </section>
83
112
 
84
- <section class="overflow-hidden bg-white dark:bg-neutral-800 border border-gray-200 rounded-lg dark:border-neutral-700">
85
- <div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] p-4 border-b border-gray-200 dark:border-neutral-700"><label><span class="sr-only">Search dependencies</span><input x-model="search" type="search" placeholder="Search package or version" class="px-3 py-2 max-w-xl w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700" /></label><label><span class="sr-only">Choose dependency dataset</span><select x-model="dataset" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700"><option value="declared">Declared</option><option value="transitive">Transitive</option></select></label><label><span class="sr-only">Filter dependency scope</span><select x-model="scope" :disabled="dataset() === 'transitive'" class="px-3 py-2 w-full text-gray-900 text-sm dark:text-white bg-white dark:bg-neutral-900 border border-gray-300 rounded-md dark:border-neutral-700 disabled:opacity-50"><option value="all">All scopes</option><option value="workspace">Workspace</option><option value="third-party">Third-party</option></select></label></div>
86
- <div class="overflow-x-auto"><table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700"><thead class="bg-gray-50 dark:bg-neutral-900/50"><tr><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Package</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Version</th><th class="px-4 py-3 font-semibold text-gray-600 text-left text-xs dark:text-neutral-300">Source</th></tr></thead><tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
87
- <template :if="loading()"><tr><td colspan="3" class="px-4 py-12 text-center text-gray-500 text-sm dark:text-neutral-400"><span class="inline-block mr-2 h-4 w-4 animate-spin i-hugeicons-loading-03"></span>Reading dependency graph</td></tr></template>
88
- <template :else-if="visibleRows().length === 0"><tr><td colspan="3" class="px-4 py-14 text-center"><span class="inline-block h-6 w-6 text-gray-400 i-hugeicons-package"></span><p class="mt-2 font-medium text-gray-900 text-sm dark:text-white">No dependencies found</p><p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Adjust the dataset, scope, or search.</p></td></tr></template>
89
- <template :else><template :for="dependency in visibleRows()"><tr><td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ dependency.name }}</td><td class="px-4 py-3 font-mono text-gray-500 text-sm dark:text-neutral-400">{{ dependency.version }}</td><td class="px-4 py-3"><span :class="'inline-flex px-2 py-1 font-medium text-xs rounded-full ' + (dependency.isWorkspace ? 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/40' : 'text-gray-600 dark:text-neutral-300 bg-gray-100 dark:bg-neutral-700')">{{ dependency.isWorkspace ? 'Workspace' : (dataset() === 'transitive' ? 'Transitive' : 'Third-party') }}</span></td></tr></template></template>
90
- </tbody></table></div>
113
+ <section aria-label="Dependencies">
114
+ <Table
115
+ :columns="COLUMNS"
116
+ :data="visibleRows()"
117
+ :loading="loading()"
118
+ row-key="name"
119
+ searchable
120
+ search-placeholder="Search package or version"
121
+ paginated
122
+ :page-size="50"
123
+ striped
124
+ empty-title="No dependencies found"
125
+ empty-description="Adjust the dataset, scope, or search."
126
+ >
127
+ <template #toolbar>
128
+ <label>
129
+ <span class="sr-only">Choose dependency dataset</span>
130
+ <select x-model="dataset" class="px-2.5 py-1.5 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600">
131
+ <option value="declared">Declared</option>
132
+ <option value="transitive">Transitive</option>
133
+ </select>
134
+ </label>
135
+ <label>
136
+ <span class="sr-only">Filter dependency scope</span>
137
+ <select x-model="scope" :disabled="dataset() === 'transitive'" class="px-2.5 py-1.5 text-sm bg-white dark:bg-neutral-800 border border-stone-300 rounded-lg dark:border-neutral-600 disabled:opacity-50">
138
+ <option value="all">All scopes</option>
139
+ <option value="workspace">Workspace</option>
140
+ <option value="third-party">Third-party</option>
141
+ </select>
142
+ </label>
143
+ </template>
144
+ </Table>
91
145
  </section>
92
146
  </div>
@@ -1,3 +1,5 @@
1
+ @import('../UI/Table')
2
+
1
3
  <script client>
2
4
  import type { RealtimeDashboardResponse, RealtimeEvent } from '../../../../functions/realtime'
3
5
  import { fetchRealtimeDashboard } from '../../../../functions/realtime'
@@ -31,6 +33,18 @@ function formatNumber(value: number): string {
31
33
  return new Intl.NumberFormat().format(value)
32
34
  }
33
35
 
36
+ /**
37
+ * The recorded-events table. `occurredAt` is a `datetime` rather than a
38
+ * `date`: an event log is read to the minute, and this column is also the one
39
+ * it sorts by.
40
+ */
41
+ const EVENT_COLUMNS = [
42
+ { key: 'occurredAt', label: 'Time', type: 'datetime' as const, sortable: true, class: 'whitespace-nowrap' },
43
+ { key: 'type', label: 'Type', sortable: true, filterable: true, class: 'font-medium text-gray-900 dark:text-white' },
44
+ { key: 'socket', label: 'Socket', sortable: true, class: 'font-mono text-xs' },
45
+ { key: 'details', label: 'Details', emptyText: 'No details' },
46
+ ]
47
+
34
48
  function formatDate(value: string): string {
35
49
  if (!value)
36
50
  return 'Not recorded'
@@ -208,31 +222,16 @@ onDestroy(() => {
208
222
  <h2 class="font-semibold text-gray-900 dark:text-white">Recorded Websocket events</h2>
209
223
  <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Persisted through the Websocket model. No latency or throughput is estimated.</p>
210
224
  </div>
211
- <div :if="!isLoading() && recordedEvents().length === 0" class="px-5 py-12 text-center">
212
- <p class="font-semibold text-gray-900 dark:text-white">No realtime events recorded</p>
213
- <p class="mt-1 text-gray-500 text-sm dark:text-neutral-400">Connection lifecycle events appear after they are persisted through the Websocket model.</p>
214
- </div>
215
- <div :if="recordedEvents().length > 0" class="overflow-x-auto">
216
- <table class="min-w-full divide-gray-200 divide-y dark:divide-neutral-700">
217
- <thead class="bg-gray-50 dark:bg-neutral-700/50">
218
- <tr>
219
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Time</th>
220
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Type</th>
221
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Socket</th>
222
- <th class="px-4 py-3 font-medium text-gray-500 text-left text-xs uppercase dark:text-neutral-300">Details</th>
223
- </tr>
224
- </thead>
225
- <tbody class="divide-gray-200 divide-y dark:divide-neutral-700">
226
- <template :for="event in recordedEvents()">
227
- <tr>
228
- <td class="px-4 py-3 text-gray-500 text-sm whitespace-nowrap dark:text-neutral-300">{{ formatDate(event.occurredAt) }}</td>
229
- <td class="px-4 py-3 font-medium text-gray-900 text-sm dark:text-white">{{ event.type }}</td>
230
- <td class="px-4 py-3 font-mono text-gray-500 text-xs dark:text-neutral-300">{{ event.socket }}</td>
231
- <td class="px-4 py-3 text-gray-500 text-sm dark:text-neutral-300">{{ event.details }}</td>
232
- </tr>
233
- </template>
234
- </tbody>
235
- </table>
236
- </div>
225
+ <Table
226
+ :columns="EVENT_COLUMNS"
227
+ :data="recordedEvents()"
228
+ :loading="isLoading()"
229
+ searchable
230
+ search-placeholder="Search recorded events"
231
+ paginated
232
+ :page-size="25"
233
+ empty-title="No realtime events recorded"
234
+ empty-description="Connection lifecycle events appear after they are persisted through the Websocket model."
235
+ />
237
236
  </section>
238
237
  </div>