@modern-admin/react 0.1.0

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 (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,1596 @@
1
+ // List page powered by @tanstack/react-table. Server-side sorting / filtering
2
+ // / pagination — TanStack just handles state + UI. Each visible PropertyJSON
3
+ // becomes a column; reference cells link to the related record's show page,
4
+ // id cells link to their own show page, and clicking anywhere else in a row
5
+ // opens edit. A toolbar offers global search, per-column filters and column
6
+ // visibility, plus a paginator with page-size selector.
7
+ import * as React from 'react';
8
+ import { flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table';
9
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, cn, DatePicker, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, Input, Kbd, Label, Popover, PopoverContent, PopoverTrigger, ScrollArea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, Skeleton, TableBody, TableCell, TableHead, TableHeader, TableRow, Tooltip, TooltipContent, TooltipTrigger, } from '@modern-admin/ui';
10
+ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Download, Eye, Inbox, ListFilter, MoreHorizontal, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Trash2, X, Zap, } from 'lucide-react';
11
+ import { useBulkDeleteRecords, useDeleteRecord, useDistinctValues, useInvokeBulkAction, useInvokeRecordAction, useInvokeResourceAction, useRecords, useResource, } from '../hooks.js';
12
+ import { PropertyDisplay } from '../property-renderer.js';
13
+ import { ReferenceCombobox, ReferenceLink, ReferenceLinkList } from '../reference.js';
14
+ import { Link, useNavigate, useOpenInNewTab, useRoute, } from '../router.js';
15
+ import { useI18n } from '../i18n.js';
16
+ import { useNotify } from '../notify.js';
17
+ import { useDialogs } from '../dialogs.js';
18
+ import { useHotkey } from '../use-hotkey.js';
19
+ import { homeCrumb, PageBreadcrumbs } from '../breadcrumbs.js';
20
+ import { ExportDialog } from './export-dialog.js';
21
+ import { ActionMenu, ActionMenuItems } from '../action-menu.js';
22
+ import { visibleRecordProperties } from '../relations.js';
23
+ import { confirmGuard } from '../action-guard.js';
24
+ const PAGE_SIZES = [10, 20, 50, 100];
25
+ // Cycling widths for skeleton cells — varied so rows don't look identical.
26
+ const SKEL_WIDTHS = ['w-16', 'w-24', 'w-20', 'w-32', 'w-14', 'w-28', 'w-18', 'w-22'];
27
+ /** Attach click-and-drag horizontal scrolling to a scroll container.
28
+ *
29
+ * Used on the table wrapper and the pagination buttons row so users on a
30
+ * mouse can drag horizontally without first scrolling to the native scroll-
31
+ * bar. Mouse-only — touch devices already get smooth native momentum
32
+ * scrolling. Engages only after a small movement threshold so plain clicks
33
+ * on rows/buttons still fire, and swallows the synthetic post-drag click to
34
+ * avoid accidental navigation. Returns a cleanup function. */
35
+ function attachDragScroll(el) {
36
+ const DRAG_THRESHOLD = 5;
37
+ let startX = 0;
38
+ let startScrollLeft = 0;
39
+ let pointerId = null;
40
+ let dragging = false;
41
+ let armed = false;
42
+ const onPointerDown = (e) => {
43
+ if (e.pointerType !== 'mouse')
44
+ return;
45
+ const target = e.target;
46
+ if (!target)
47
+ return;
48
+ if (e.button !== 0)
49
+ return;
50
+ if (target.closest('button, a, input, label, [role="checkbox"], [role="menuitem"], [data-resize-handle], [contenteditable="true"]'))
51
+ return;
52
+ if (el.scrollWidth <= el.clientWidth)
53
+ return;
54
+ armed = true;
55
+ dragging = false;
56
+ pointerId = e.pointerId;
57
+ startX = e.clientX;
58
+ startScrollLeft = el.scrollLeft;
59
+ };
60
+ const onPointerMove = (e) => {
61
+ if (!armed || pointerId !== e.pointerId)
62
+ return;
63
+ const dx = e.clientX - startX;
64
+ if (!dragging) {
65
+ if (Math.abs(dx) < DRAG_THRESHOLD)
66
+ return;
67
+ dragging = true;
68
+ el.setPointerCapture(pointerId);
69
+ el.style.cursor = 'grabbing';
70
+ el.style.userSelect = 'none';
71
+ }
72
+ el.scrollLeft = startScrollLeft - dx;
73
+ e.preventDefault();
74
+ };
75
+ const endDrag = (e) => {
76
+ if (pointerId !== e.pointerId)
77
+ return;
78
+ armed = false;
79
+ if (dragging) {
80
+ dragging = false;
81
+ if (el.hasPointerCapture(pointerId))
82
+ el.releasePointerCapture(pointerId);
83
+ el.style.cursor = '';
84
+ el.style.userSelect = '';
85
+ const swallow = (ev) => {
86
+ ev.stopPropagation();
87
+ ev.preventDefault();
88
+ };
89
+ el.addEventListener('click', swallow, { capture: true, once: true });
90
+ }
91
+ pointerId = null;
92
+ };
93
+ el.addEventListener('pointerdown', onPointerDown);
94
+ el.addEventListener('pointermove', onPointerMove);
95
+ el.addEventListener('pointerup', endDrag);
96
+ el.addEventListener('pointercancel', endDrag);
97
+ return () => {
98
+ el.removeEventListener('pointerdown', onPointerDown);
99
+ el.removeEventListener('pointermove', onPointerMove);
100
+ el.removeEventListener('pointerup', endDrag);
101
+ el.removeEventListener('pointercancel', endDrag);
102
+ };
103
+ }
104
+ /** Reasonable starting width per property type. Users can resize from there
105
+ * and the chosen widths are persisted per-resource in localStorage. */
106
+ function defaultColumnSize(property) {
107
+ if (property.isId)
108
+ return 100;
109
+ switch (property.type) {
110
+ case 'boolean':
111
+ return 110;
112
+ case 'date':
113
+ return 140;
114
+ case 'datetime':
115
+ return 180;
116
+ case 'number':
117
+ case 'float':
118
+ case 'money':
119
+ case 'currency':
120
+ return 120;
121
+ case 'color':
122
+ return 140;
123
+ case 'reference':
124
+ return 200;
125
+ case 'richtext':
126
+ case 'textarea':
127
+ return 320;
128
+ default:
129
+ return 200;
130
+ }
131
+ }
132
+ const COLUMN_SIZE_STORAGE_PREFIX = 'modern-admin:colSizes:';
133
+ // Internal system columns (_select, _actions) must never have their sizes
134
+ // persisted — their widths are determined by the layout logic, not the user.
135
+ const isSystemCol = (id) => id.startsWith('_');
136
+ function loadColumnSizing(resourceId) {
137
+ if (typeof window === 'undefined')
138
+ return {};
139
+ try {
140
+ const raw = window.localStorage.getItem(COLUMN_SIZE_STORAGE_PREFIX + resourceId);
141
+ if (!raw)
142
+ return {};
143
+ const parsed = JSON.parse(raw);
144
+ return Object.fromEntries(Object.entries(parsed).filter(([k]) => !isSystemCol(k)));
145
+ }
146
+ catch {
147
+ return {};
148
+ }
149
+ }
150
+ function saveColumnSizing(resourceId, sizing) {
151
+ if (typeof window === 'undefined')
152
+ return;
153
+ try {
154
+ const toSave = Object.fromEntries(Object.entries(sizing).filter(([k]) => !isSystemCol(k)));
155
+ window.localStorage.setItem(COLUMN_SIZE_STORAGE_PREFIX + resourceId, JSON.stringify(toSave));
156
+ }
157
+ catch { /* quota / private mode — ignore */
158
+ }
159
+ }
160
+ export function ResourceListPage({ resourceId, query: controlledQuery, onQueryChange, lockedFilters, features, selectedIds: controlledSelectedIds, onSelectionChange, disableRowNavigation, }) {
161
+ const resource = useResource(resourceId);
162
+ const navigate = useNavigate();
163
+ const openInNewTab = useOpenInNewTab();
164
+ const route = useRoute();
165
+ const remove = useDeleteRecord(resourceId);
166
+ const bulkRemove = useBulkDeleteRecords(resourceId);
167
+ const invokeRecord = useInvokeRecordAction(resourceId);
168
+ const invokeBulk = useInvokeBulkAction(resourceId);
169
+ const invokeResource = useInvokeResourceAction(resourceId);
170
+ const { t } = useI18n();
171
+ const notify = useNotify();
172
+ const dialogs = useDialogs();
173
+ const isSelectionControlled = controlledSelectedIds !== undefined && onSelectionChange !== undefined;
174
+ const [internalRowSelection, setInternalRowSelection] = React.useState({});
175
+ const controlledRowSelection = React.useMemo(() => {
176
+ if (!isSelectionControlled)
177
+ return {};
178
+ const next = {};
179
+ for (const id of controlledSelectedIds)
180
+ next[id] = true;
181
+ return next;
182
+ }, [isSelectionControlled, controlledSelectedIds]);
183
+ const rowSelection = isSelectionControlled ? controlledRowSelection : internalRowSelection;
184
+ const setRowSelection = React.useCallback((updater) => {
185
+ if (isSelectionControlled) {
186
+ const prev = controlledRowSelection;
187
+ const next = typeof updater === 'function'
188
+ ? updater(prev)
189
+ : updater;
190
+ onSelectionChange(Object.keys(next).filter((id) => next[id]));
191
+ return;
192
+ }
193
+ setInternalRowSelection(updater);
194
+ }, [isSelectionControlled, controlledRowSelection, onSelectionChange]);
195
+ const isControlled = controlledQuery !== undefined && onQueryChange !== undefined;
196
+ const f = React.useMemo(() => ({
197
+ breadcrumbs: features?.breadcrumbs ?? true,
198
+ title: features?.title ?? true,
199
+ refresh: features?.refresh ?? true,
200
+ filters: features?.filters ?? true,
201
+ columns: features?.columns ?? true,
202
+ export: features?.export ?? true,
203
+ create: features?.create ?? true,
204
+ bulk: features?.bulk ?? true,
205
+ actions: features?.actions ?? true,
206
+ headerFilters: features?.headerFilters ?? true,
207
+ card: features?.card ?? true,
208
+ }), [features]);
209
+ // ── URL-driven (or prop-driven) query state ──
210
+ // In standalone mode, filters/page/perPage/sortBy/direction live in the URL
211
+ // hash (`?page=2&perPage=50&sortBy=name&direction=asc&filters[email]=ada`)
212
+ // so they survive refresh, back, and link sharing. In embedded mode, the
213
+ // parent component owns the same state shape and passes it via `query`.
214
+ const urlQuery = React.useMemo(() => isControlled
215
+ ? (controlledQuery ?? {})
216
+ : ((route.name === 'list' && route.query) || {}), [isControlled, controlledQuery, route]);
217
+ const sorting = React.useMemo(() => urlQuery.sortBy
218
+ ? [{ id: urlQuery.sortBy, desc: urlQuery.direction === 'desc' }]
219
+ : [], [urlQuery.sortBy, urlQuery.direction]);
220
+ const columnFilters = React.useMemo(() => urlQuery.filters
221
+ ? Object.entries(urlQuery.filters).map(([id, value]) => ({ id, value }))
222
+ : [], [urlQuery.filters]);
223
+ const pagination = React.useMemo(() => ({
224
+ pageIndex: (urlQuery.page ?? 1) - 1,
225
+ pageSize: urlQuery.perPage ?? 20,
226
+ }), [urlQuery.page, urlQuery.perPage]);
227
+ const [columnVisibility, setColumnVisibility] = React.useState({});
228
+ const [columnSizing, setColumnSizing] = React.useState(() => loadColumnSizing(resourceId));
229
+ // Reload + persist coherently. When resourceId changes we reload first
230
+ // (and skip saving the old state under the new key); on subsequent updates
231
+ // we persist the user's resize choices.
232
+ const lastResourceIdRef = React.useRef(resourceId);
233
+ React.useEffect(() => {
234
+ if (lastResourceIdRef.current !== resourceId) {
235
+ lastResourceIdRef.current = resourceId;
236
+ setColumnSizing(loadColumnSizing(resourceId));
237
+ return;
238
+ }
239
+ saveColumnSizing(resourceId, columnSizing);
240
+ }, [resourceId, columnSizing]);
241
+ // Track the wrapper width so the last visible column can flex to fill any
242
+ // leftover space (mirrors unitify's "distribute remaining space" pattern
243
+ // without re-layouting all columns on every observe).
244
+ const [wrapperWidth, setWrapperWidth] = React.useState(0);
245
+ const roRef = React.useRef(null);
246
+ const dragCleanupRef = React.useRef(null);
247
+ // Callback ref: re-attaches ResizeObserver and click-and-drag handlers
248
+ // whenever the wrapper mounts. (Plain useRef + useEffect runs only once,
249
+ // so if the wrapper is initially unmounted due to conditional rendering,
250
+ // the observer never attaches.)
251
+ const tableWrapperRef = React.useCallback((el) => {
252
+ if (roRef.current) {
253
+ roRef.current.disconnect();
254
+ roRef.current = null;
255
+ }
256
+ if (dragCleanupRef.current) {
257
+ dragCleanupRef.current();
258
+ dragCleanupRef.current = null;
259
+ }
260
+ if (!el)
261
+ return;
262
+ const ro = new ResizeObserver((entries) => {
263
+ const w = entries[0]?.contentRect.width ?? 0;
264
+ setWrapperWidth(w);
265
+ });
266
+ ro.observe(el);
267
+ roRef.current = ro;
268
+ setWrapperWidth(el.clientWidth);
269
+ dragCleanupRef.current = attachDragScroll(el);
270
+ }, []);
271
+ const [filterOpen, setFilterOpen] = React.useState(false);
272
+ const updateUrlQuery = React.useCallback((changes) => {
273
+ const merged = { ...urlQuery, ...changes };
274
+ const next = {};
275
+ if (merged.page && merged.page > 1)
276
+ next.page = merged.page;
277
+ if (merged.perPage && merged.perPage !== 20)
278
+ next.perPage = merged.perPage;
279
+ if (merged.sortBy)
280
+ next.sortBy = merged.sortBy;
281
+ if (merged.direction)
282
+ next.direction = merged.direction;
283
+ if (merged.filters && Object.keys(merged.filters).length > 0)
284
+ next.filters = merged.filters;
285
+ if (isControlled) {
286
+ onQueryChange(next);
287
+ return;
288
+ }
289
+ navigate({
290
+ name: 'list',
291
+ resourceId,
292
+ ...(Object.keys(next).length > 0 ? { query: next } : {}),
293
+ });
294
+ }, [isControlled, onQueryChange, navigate, resourceId, urlQuery]);
295
+ const handleSortingChange = React.useCallback((updater) => {
296
+ const next = typeof updater === 'function' ? updater(sorting) : updater;
297
+ const first = next[0];
298
+ updateUrlQuery({
299
+ sortBy: first?.id,
300
+ direction: first ? (first.desc ? 'desc' : 'asc') : undefined,
301
+ page: 1,
302
+ });
303
+ }, [sorting, updateUrlQuery]);
304
+ const handleFilterChange = React.useCallback((updater) => {
305
+ const next = typeof updater === 'function' ? updater(columnFilters) : updater;
306
+ const filters = {};
307
+ for (const f of next) {
308
+ if (f.value != null && f.value !== '')
309
+ filters[f.id] = String(f.value);
310
+ }
311
+ updateUrlQuery({
312
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
313
+ page: 1,
314
+ });
315
+ }, [columnFilters, updateUrlQuery]);
316
+ // Ref so column header popovers can read latest filter state without being
317
+ // in the `columns` useMemo dependency array (which would recreate all columns
318
+ // on every filter change).
319
+ const columnFiltersRef = React.useRef(columnFilters);
320
+ React.useEffect(() => {
321
+ columnFiltersRef.current = columnFilters;
322
+ }, [columnFilters]);
323
+ const handleColumnFilterApply = React.useCallback((updates) => {
324
+ const next = columnFiltersRef.current.filter((f) => !(f.id in updates));
325
+ for (const [id, value] of Object.entries(updates)) {
326
+ if (value)
327
+ next.push({ id, value });
328
+ }
329
+ handleFilterChange(next);
330
+ }, [handleFilterChange]);
331
+ const handlePaginationChange = React.useCallback((updater) => {
332
+ const next = typeof updater === 'function' ? updater(pagination) : updater;
333
+ updateUrlQuery({
334
+ page: next.pageIndex + 1,
335
+ perPage: next.pageSize,
336
+ });
337
+ }, [pagination, updateUrlQuery]);
338
+ const query = React.useMemo(() => {
339
+ // Locked filters are merged in but never written to URL or column state.
340
+ const mergedFilters = { ...(lockedFilters ?? {}), ...(urlQuery.filters ?? {}) };
341
+ return {
342
+ page: urlQuery.page ?? 1,
343
+ perPage: urlQuery.perPage ?? 20,
344
+ ...(urlQuery.sortBy
345
+ ? {
346
+ sortBy: urlQuery.sortBy,
347
+ ...(urlQuery.direction ? { direction: urlQuery.direction } : {}),
348
+ }
349
+ : {}),
350
+ ...(Object.keys(mergedFilters).length > 0 ? { filters: mergedFilters } : {}),
351
+ };
352
+ }, [
353
+ urlQuery.page,
354
+ urlQuery.perPage,
355
+ urlQuery.sortBy,
356
+ urlQuery.direction,
357
+ urlQuery.filters,
358
+ lockedFilters,
359
+ ]);
360
+ const records = useRecords(resourceId, query);
361
+ const visible = React.useMemo(() => {
362
+ const all = resource ? visibleRecordProperties(resource.properties, 'list') : [];
363
+ // Drop columns pinned by lockedFilters — they're identical for every row.
364
+ if (lockedFilters && Object.keys(lockedFilters).length > 0) {
365
+ return all.filter((p) => !(p.path in lockedFilters));
366
+ }
367
+ return all;
368
+ }, [resource, lockedFilters]);
369
+ const builtInActionNames = new Set([
370
+ 'list',
371
+ 'show',
372
+ 'new',
373
+ 'edit',
374
+ 'delete',
375
+ 'bulkDelete',
376
+ 'search',
377
+ 'values',
378
+ ]);
379
+ const customResourceActions = (resource?.actions ?? []).filter((a) => a.actionType === 'resource' && !builtInActionNames.has(a.name));
380
+ const customRecordActions = (resource?.actions ?? []).filter((a) => a.actionType === 'record' && !builtInActionNames.has(a.name));
381
+ const customBulkActions = (resource?.actions ?? []).filter((a) => a.actionType === 'bulk' && !builtInActionNames.has(a.name));
382
+ const showSelectColumn = f.bulk || isSelectionControlled;
383
+ const columns = React.useMemo(() => {
384
+ const cols = [];
385
+ if (showSelectColumn) {
386
+ cols.push({
387
+ id: '_select',
388
+ enableSorting: false,
389
+ enableHiding: false,
390
+ enableResizing: false,
391
+ size: 40,
392
+ minSize: 0,
393
+ header: ({ table }) => {
394
+ const all = table.getIsAllPageRowsSelected();
395
+ const some = table.getIsSomePageRowsSelected();
396
+ return (<Checkbox checked={all ? true : some ? 'indeterminate' : false} onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} aria-label={t('common:selectAll')}/>);
397
+ },
398
+ cell: ({ row }) => (<Checkbox checked={row.getIsSelected()} onCheckedChange={(v) => row.toggleSelected(!!v)} onClick={(e) => e.stopPropagation()} aria-label={t('common:selectRow')}/>),
399
+ });
400
+ }
401
+ cols.push(...visible.map((property) => ({
402
+ id: property.path,
403
+ accessorFn: (row) => row.params[property.path],
404
+ size: defaultColumnSize(property),
405
+ minSize: 80,
406
+ header: ({ column }) => (<div className="flex items-center gap-0.5">
407
+ <SortHeader property={property} state={column.getIsSorted() === 'asc'
408
+ ? 'asc'
409
+ : column.getIsSorted() === 'desc'
410
+ ? 'desc'
411
+ : 'none'} onSort={() => {
412
+ if (!property.isSortable)
413
+ return;
414
+ const cur = column.getIsSorted();
415
+ if (cur === false)
416
+ column.toggleSorting(false);
417
+ else if (cur === 'asc')
418
+ column.toggleSorting(true);
419
+ else
420
+ column.clearSorting();
421
+ }}/>
422
+ {f.headerFilters && (<ColumnFilterPopover property={property} getFilters={() => columnFiltersRef.current} onApply={handleColumnFilterApply} resourceId={resourceId} t={t}/>)}
423
+ </div>),
424
+ enableSorting: property.isSortable,
425
+ cell: ({ row }) => (<CellContent resourceId={resourceId} recordId={row.original.id} property={property} value={row.original.params[property.path]} populated={row.original.populated}/>),
426
+ })));
427
+ if (!disableRowNavigation)
428
+ cols.push({
429
+ id: '_actions',
430
+ header: () => null,
431
+ enableSorting: false,
432
+ enableHiding: false,
433
+ enableResizing: false,
434
+ size: 44,
435
+ minSize: 0,
436
+ cell: ({ row }) => (<RowActions t={t} customActions={customRecordActions} onView={() => navigate({ name: 'show', resourceId, recordId: row.original.id })} onEdit={() => navigate({ name: 'edit', resourceId, recordId: row.original.id })} onDelete={async () => {
437
+ const ok = await dialogs.confirm({
438
+ title: t('common:confirmDelete'),
439
+ description: row.original.title || row.original.id,
440
+ confirmLabel: t('common:delete'),
441
+ destructive: true,
442
+ });
443
+ if (!ok)
444
+ return;
445
+ remove.mutate(row.original.id, {
446
+ onSuccess: () => notify.success({ key: 'toast:deleted' }),
447
+ onError: (err) => notify.error({ key: 'toast:deleteFailed' }, { description: err instanceof Error ? err.message : String(err) }),
448
+ });
449
+ }} onInvokeAction={async (action) => {
450
+ if (!await confirmGuard(action, dialogs))
451
+ return;
452
+ invokeRecord.mutate({ recordId: row.original.id, actionName: action.name }, {
453
+ onSuccess: (res) => {
454
+ if (res.notice) {
455
+ const type = res.notice.type === 'error' ? 'error'
456
+ : res.notice.type === 'warning' ? 'warning'
457
+ : res.notice.type === 'info' ? 'info'
458
+ : 'success';
459
+ notify[type]({ message: res.notice.message });
460
+ }
461
+ },
462
+ onError: (err) => notify.error({ message: err.message }),
463
+ });
464
+ }}/>),
465
+ });
466
+ return cols;
467
+ }, [
468
+ visible,
469
+ resourceId,
470
+ navigate,
471
+ remove,
472
+ t,
473
+ notify,
474
+ dialogs,
475
+ handleColumnFilterApply,
476
+ f.headerFilters,
477
+ showSelectColumn,
478
+ disableRowNavigation,
479
+ customRecordActions,
480
+ invokeRecord,
481
+ ]);
482
+ const total = records.data?.meta.total ?? 0;
483
+ const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
484
+ const table = useReactTable({
485
+ data: records.data?.records ?? [],
486
+ columns,
487
+ pageCount,
488
+ state: { sorting, columnFilters, columnVisibility, pagination, rowSelection, columnSizing },
489
+ onSortingChange: handleSortingChange,
490
+ onColumnFiltersChange: handleFilterChange,
491
+ onColumnVisibilityChange: setColumnVisibility,
492
+ onColumnSizingChange: setColumnSizing,
493
+ onPaginationChange: handlePaginationChange,
494
+ onRowSelectionChange: setRowSelection,
495
+ enableRowSelection: true,
496
+ manualSorting: true,
497
+ manualFiltering: true,
498
+ manualPagination: true,
499
+ enableColumnResizing: true,
500
+ // 'onEnd' commits the new size only when the user releases the handle,
501
+ // avoiding a re-render storm that 'onChange' triggers on every mousemove.
502
+ columnResizeMode: 'onEnd',
503
+ defaultColumn: { minSize: 80, size: 200, maxSize: 800 },
504
+ getCoreRowModel: getCoreRowModel(),
505
+ getRowId: (row) => row.id,
506
+ });
507
+ // Selection lives at the page level (we always know the IDs from rowSelection
508
+ // keys because getRowId returns row.id). The bulk-delete button shows
509
+ // whenever the user has at least one row selected.
510
+ const selectedIds = React.useMemo(() => Object.keys(rowSelection), [rowSelection]);
511
+ const selectedCount = selectedIds.length;
512
+ const showStandaloneEmptyState = !records.isFetching && !records.isError && total === 0;
513
+ // ── Keyboard shortcuts ──
514
+ // Plain `n` creates, `r` refreshes, `f` opens the filters drawer.
515
+ // Bare-key bindings are skipped while typing in inputs. Ctrl+N would
516
+ // be the conventional choice for "new" but every major browser
517
+ // reserves it for "new window" and won't surrender the keydown, so we
518
+ // settle for a single-letter binding consistent with `r` / `f`.
519
+ useHotkey('n', () => {
520
+ navigate({ name: 'new', resourceId });
521
+ }, { enabled: f.create, description: t('common:new') });
522
+ useHotkey('r', () => {
523
+ if (!records.isFetching)
524
+ records.refetch();
525
+ }, { enabled: f.refresh, description: t('common:refresh') });
526
+ useHotkey('f', () => {
527
+ setFilterOpen((v) => !v);
528
+ }, { enabled: f.filters, description: t('common:filters') });
529
+ const handleBulkDelete = React.useCallback(async () => {
530
+ const ok = await dialogs.confirm({
531
+ title: t('common:bulkDeleteConfirm', { count: selectedCount }),
532
+ confirmLabel: t('common:delete'),
533
+ destructive: true,
534
+ });
535
+ if (!ok)
536
+ return;
537
+ bulkRemove.mutate(selectedIds, {
538
+ onSuccess: () => {
539
+ setRowSelection({});
540
+ notify.success({ key: 'toast:bulkDeleted', params: { count: selectedCount } });
541
+ },
542
+ onError: (err) => notify.error({ key: 'toast:bulkDeleteFailed' }, { description: err instanceof Error ? err.message : String(err) }),
543
+ });
544
+ }, [bulkRemove, dialogs, notify, selectedCount, selectedIds, setRowSelection, t]);
545
+ if (!resource) {
546
+ return (<Card>
547
+ <CardContent className="p-6">
548
+ <Skeleton className="h-6 w-1/3"/>
549
+ </CardContent>
550
+ </Card>);
551
+ }
552
+ const showCustomResourceActions = f.actions && customResourceActions.length > 0;
553
+ const hasToolbarActions = !showStandaloneEmptyState && (f.refresh || f.filters || f.columns || f.export || f.create || showCustomResourceActions);
554
+ const hasHeader = f.title || hasToolbarActions || (!showStandaloneEmptyState && visible.some((p) => p.isSortable));
555
+ // CardHeader/Content add their own padding. When `card: false` we're embedded
556
+ // inside another container that already provides spacing, so use a bare div
557
+ // wrapper to avoid compounding paddings.
558
+ const HeaderEl = f.card ? CardHeader : 'div';
559
+ const ContentEl = f.card ? CardContent : 'div';
560
+ const headerCls = cn('flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4', !f.card && 'mb-3');
561
+ const inner = (<>
562
+ {f.filters && (<FilterPanel open={filterOpen} onOpenChange={setFilterOpen} properties={visible} filters={columnFilters} onChange={handleFilterChange} resourceId={resourceId} t={t}/>)}
563
+ {hasHeader && (<HeaderEl className={headerCls}>
564
+ {f.title ? <CardTitle>{resource.name}</CardTitle> : <span />}
565
+ {hasToolbarActions && (<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
566
+ {f.refresh && (<Tooltip>
567
+ <TooltipTrigger asChild>
568
+ <Button variant="outline" size="sm" onClick={() => records.refetch()} disabled={records.isFetching} aria-label={t('common:refresh')}>
569
+ <RefreshCw className={records.isFetching ? 'size-4 animate-spin' : 'size-4'}/>
570
+ </Button>
571
+ </TooltipTrigger>
572
+ <TooltipContent className="flex items-center gap-1.5">
573
+ <span>{t('common:refresh')}</span>
574
+ <Kbd>R</Kbd>
575
+ </TooltipContent>
576
+ </Tooltip>)}
577
+ {f.filters && (<Tooltip>
578
+ <TooltipTrigger asChild>
579
+ <Button variant="outline" size="sm" onClick={() => setFilterOpen(true)}>
580
+ <ListFilter className="size-4"/>
581
+ <span className="hidden sm:inline">{t('common:filters')}</span>
582
+ {columnFilters.length > 0 && (<Badge className="ml-1 h-5 rounded-full px-1.5 text-xs">
583
+ {columnFilters.length}
584
+ </Badge>)}
585
+ </Button>
586
+ </TooltipTrigger>
587
+ <TooltipContent className="flex items-center gap-1.5">
588
+ <span>{t('common:filters')}</span>
589
+ <Kbd>F</Kbd>
590
+ </TooltipContent>
591
+ </Tooltip>)}
592
+ {f.columns && (<ColumnVisibilityMenu table={table} properties={visible} t={t}/>)}
593
+ {f.export && (<Button variant="outline" size="sm" onClick={() => dialogs.open({
594
+ render: ({ close }) => (<ExportDialog resourceId={resourceId} resourceLabel={resource.name} properties={visible} query={query} onClose={() => close()}/>),
595
+ })}>
596
+ <Download className="size-4"/>
597
+ <span className="hidden sm:inline">{t('common:export')}</span>
598
+ </Button>)}
599
+ {showCustomResourceActions && (<ActionMenu actions={customResourceActions} onAction={async (action) => {
600
+ if (!await confirmGuard(action, dialogs))
601
+ return;
602
+ invokeResource.mutate({ actionName: action.name }, {
603
+ onSuccess: (res) => {
604
+ if (res.notice) {
605
+ const type = res.notice.type === 'error' ? 'error'
606
+ : res.notice.type === 'warning' ? 'warning'
607
+ : res.notice.type === 'info' ? 'info'
608
+ : 'success';
609
+ notify[type]({ message: res.notice.message });
610
+ }
611
+ },
612
+ onError: (err) => notify.error({ message: err.message }),
613
+ });
614
+ }} t={t} trigger={(<Button variant="outline" size="sm" disabled={invokeResource.isPending}>
615
+ <Zap className="size-4"/>
616
+ <span className="hidden sm:inline">{t('common:actions')}</span>
617
+ </Button>)}/>)}
618
+ {f.create && (<Tooltip>
619
+ <TooltipTrigger asChild>
620
+ <Button size="sm" onClick={() => navigate({ name: 'new', resourceId })}>
621
+ <Plus className="size-4"/>
622
+ <span className="hidden sm:inline">{t('common:new')}</span>
623
+ </Button>
624
+ </TooltipTrigger>
625
+ <TooltipContent className="flex items-center gap-1.5">
626
+ <span>{t('common:new')}</span>
627
+ <Kbd>N</Kbd>
628
+ </TooltipContent>
629
+ </Tooltip>)}
630
+ </div>)}
631
+ {/* Mobile-only sort selector — desktop uses column header clicks */}
632
+ {visible.some((p) => p.isSortable) && (<div className="flex w-full items-center gap-2 sm:hidden">
633
+ <ArrowUpDown className="size-4 shrink-0 text-muted-foreground"/>
634
+ <Select value={sorting[0]
635
+ ? `${sorting[0].id}:${sorting[0].desc ? 'desc' : 'asc'}`
636
+ : '_none_'} onValueChange={(v) => {
637
+ if (v === '_none_') {
638
+ handleSortingChange([]);
639
+ return;
640
+ }
641
+ const sep = v.lastIndexOf(':');
642
+ handleSortingChange([{ id: v.slice(0, sep), desc: v.slice(sep + 1) === 'desc' }]);
643
+ }}>
644
+ <SelectTrigger className="h-8 flex-1">
645
+ <SelectValue placeholder={t('common:sortBy')}/>
646
+ </SelectTrigger>
647
+ <SelectContent>
648
+ <SelectItem value="_none_">{t('common:sortBy')}: —</SelectItem>
649
+ {visible
650
+ .filter((p) => p.isSortable)
651
+ .flatMap((p) => [
652
+ <SelectItem key={`${p.path}:asc`} value={`${p.path}:asc`}>
653
+ {p.label} ↑
654
+ </SelectItem>,
655
+ <SelectItem key={`${p.path}:desc`} value={`${p.path}:desc`}>
656
+ {p.label} ↓
657
+ </SelectItem>,
658
+ ])}
659
+ </SelectContent>
660
+ </Select>
661
+ </div>)}
662
+ </HeaderEl>)}
663
+ <ContentEl className="flex flex-1 flex-col gap-2 sm:gap-3">
664
+ {showStandaloneEmptyState ? (<Empty>
665
+ <EmptyHeader>
666
+ <EmptyMedia>
667
+ <Inbox />
668
+ </EmptyMedia>
669
+ <EmptyTitle>{t('common:noRecords')}</EmptyTitle>
670
+ {f.create && (<EmptyDescription>
671
+ {t('common:noRecordsHint', { resource: resource.name })}
672
+ </EmptyDescription>)}
673
+ </EmptyHeader>
674
+ {f.create && (<EmptyContent>
675
+ <Button size="sm" onClick={() => navigate({ name: 'new', resourceId })}>
676
+ <Plus className="size-4"/>
677
+ {t('common:new')}
678
+ </Button>
679
+ </EmptyContent>)}
680
+ </Empty>) : (<>
681
+ {/* Bulk action bar — only visible when at least one row is selected.
682
+ Sits above the list so the user can act on the selection without
683
+ having to scroll. Mirrors a typical email-client multi-select. */}
684
+ {f.bulk && selectedCount > 0 && (<div className="flex flex-row items-center justify-between gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
685
+ <div className="min-w-0 truncate text-sm font-medium">
686
+ {t('common:selectedCount', { count: selectedCount })}
687
+ </div>
688
+ <div className="flex shrink-0 items-center gap-2">
689
+ <Button variant="ghost" size="sm" onClick={() => setRowSelection({})} disabled={bulkRemove.isPending}>
690
+ <X className="size-4"/>
691
+ <span className="hidden sm:inline">{t('common:clearSelection')}</span>
692
+ </Button>
693
+ {customBulkActions.length > 0 && (<ActionMenu actions={customBulkActions} onAction={async (action) => {
694
+ if (!await confirmGuard(action, dialogs))
695
+ return;
696
+ invokeBulk.mutate({ actionName: action.name, ids: selectedIds }, {
697
+ onSuccess: (res) => {
698
+ setRowSelection({});
699
+ if (res.notice) {
700
+ const type = res.notice.type === 'error' ? 'error'
701
+ : res.notice.type === 'warning' ? 'warning'
702
+ : res.notice.type === 'info' ? 'info'
703
+ : 'success';
704
+ notify[type]({ message: res.notice.message });
705
+ }
706
+ },
707
+ onError: (err) => notify.error({ message: err.message }),
708
+ });
709
+ }} t={t} trigger={(<Button variant="outline" size="sm" disabled={invokeBulk.isPending}>
710
+ <Zap className="size-4"/>
711
+ <span className="hidden sm:inline">{t('common:actions')}</span>
712
+ </Button>)}/>)}
713
+ <Button variant="destructive" size="sm" onClick={handleBulkDelete} disabled={bulkRemove.isPending}>
714
+ <Trash2 className="size-4"/>
715
+ <span className="hidden sm:inline">{t('common:deleteSelected')}</span>
716
+ </Button>
717
+ </div>
718
+ </div>)}
719
+ {/* Mobile: card-per-record stack. Hidden ≥ sm. */}
720
+ <div className="space-y-2 sm:hidden">
721
+ {records.isFetching && Array.from({ length: pagination.pageSize }, (_, i) => (<div key={`skel-card-${i}`} className="rounded-lg border border-border bg-card p-3">
722
+ <div className="flex items-start gap-3">
723
+ <Skeleton className="mt-1 h-4 w-4 flex-none rounded"/>
724
+ <div className="flex-1 space-y-2">
725
+ <div className="flex items-start justify-between gap-2">
726
+ <div className="flex-1 space-y-1.5">
727
+ <Skeleton className="h-4 w-32"/>
728
+ <Skeleton className="h-3 w-16"/>
729
+ </div>
730
+ <Skeleton className="h-7 w-7 shrink-0 rounded"/>
731
+ </div>
732
+ <div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2">
733
+ {Array.from({ length: 4 }, (_, j) => (<div key={j} className="space-y-1">
734
+ <Skeleton className="h-2.5 w-14"/>
735
+ <Skeleton className={`h-4 ${SKEL_WIDTHS[(i * 3 + j) % SKEL_WIDTHS.length]}`}/>
736
+ </div>))}
737
+ </div>
738
+ </div>
739
+ </div>
740
+ </div>))}
741
+ {!records.isFetching && records.isError && (<div className="rounded-md border border-border px-4 py-8 text-left text-destructive">
742
+ {t('common:loadFailed', { error: String(records.error) })}
743
+ </div>)}
744
+ {!records.isFetching && table.getRowModel().rows.map((row) => (<RecordCard key={row.id} record={row.original} properties={visible.filter((p) => table.getColumn(p.path)?.getIsVisible() ?? true)} resourceId={resourceId} showSelect={showSelectColumn} selected={row.getIsSelected()} onToggleSelect={(v) => row.toggleSelected(v)} onView={() => navigate({ name: 'show', resourceId, recordId: row.original.id })} onEdit={() => navigate({ name: 'edit', resourceId, recordId: row.original.id })} onDelete={async () => {
745
+ const ok = await dialogs.confirm({
746
+ title: t('common:confirmDelete'),
747
+ description: row.original.title || row.original.id,
748
+ confirmLabel: t('common:delete'),
749
+ destructive: true,
750
+ });
751
+ if (!ok)
752
+ return;
753
+ remove.mutate(row.original.id, {
754
+ onSuccess: () => notify.success({ key: 'toast:deleted' }),
755
+ onError: (err) => notify.error({ key: 'toast:deleteFailed' }, { description: err instanceof Error ? err.message : String(err) }),
756
+ });
757
+ }} customActions={customRecordActions} onInvokeAction={async (action) => {
758
+ if (!await confirmGuard(action, dialogs))
759
+ return;
760
+ invokeRecord.mutate({ recordId: row.original.id, actionName: action.name }, {
761
+ onSuccess: (res) => {
762
+ if (res.notice) {
763
+ const type = res.notice.type === 'error' ? 'error'
764
+ : res.notice.type === 'warning' ? 'warning'
765
+ : res.notice.type === 'info' ? 'info'
766
+ : 'success';
767
+ notify[type]({ message: res.notice.message });
768
+ }
769
+ },
770
+ onError: (err) => notify.error({ message: err.message }),
771
+ });
772
+ }} t={t}/>))}
773
+ </div>
774
+
775
+ {/* Desktop: tabular layout. Hidden < sm.
776
+ `cursor-grab` is a hint that the table can be dragged
777
+ horizontally; the actual drag handlers live in
778
+ `tableWrapperRef`. The grip on column resize handles takes
779
+ precedence (cursor-col-resize is set on a child) so users
780
+ still get the right cursor while resizing. */}
781
+ <div ref={tableWrapperRef} className="relative hidden cursor-grab overflow-x-auto rounded-md border border-border sm:block">
782
+ {(() => {
783
+ // Distribute leftover wrapper space proportionally across data columns
784
+ // so the table always fills the full wrapper width.
785
+ // _select and _actions keep their fixed sizes; only data columns stretch.
786
+ const leafCols = table.getVisibleLeafColumns();
787
+ const totalSize = table.getCenterTotalSize();
788
+ const wrapperW = wrapperWidth > 0 ? wrapperWidth : totalSize;
789
+ const extra = Math.max(0, wrapperW - totalSize);
790
+ // _select (checkbox) and _actions stay fixed-width; only data
791
+ // columns participate in proportional stretch.
792
+ const fixedIds = new Set(['_select', '_actions']);
793
+ const stretchCols = leafCols.filter((c) => !fixedIds.has(c.id));
794
+ const stretchBaseTotal = stretchCols.reduce((s, c) => s + c.getSize(), 0);
795
+ // Pre-compute each column's rendered pixel width (base + proportional boost).
796
+ const renderedWidth = new Map(leafCols.map((c) => [c.id, c.getSize()]));
797
+ if (extra > 0 && stretchBaseTotal > 0) {
798
+ let assigned = 0;
799
+ stretchCols.forEach((c, i) => {
800
+ const share = i === stretchCols.length - 1
801
+ ? extra - assigned
802
+ : Math.floor((extra * c.getSize()) / stretchBaseTotal);
803
+ renderedWidth.set(c.id, c.getSize() + share);
804
+ assigned += share;
805
+ });
806
+ }
807
+ const renderedTotal = totalSize + extra;
808
+ const sizeOf = (colId, base) => renderedWidth.get(colId) ?? base;
809
+ // Resize guide: compute left offset using rendered (boosted) widths.
810
+ const resizingHeader = table.getHeaderGroups()
811
+ .flatMap((hg) => hg.headers)
812
+ .find((h) => h.column.getIsResizing());
813
+ const deltaOffset = table.getState().columnSizingInfo.deltaOffset ?? 0;
814
+ const resizeLeft = resizingHeader
815
+ ? leafCols
816
+ .slice(0, leafCols.findIndex((c) => c.id === resizingHeader.column.id) + 1)
817
+ .reduce((s, c) => s + (renderedWidth.get(c.id) ?? c.getSize()), 0) +
818
+ deltaOffset
819
+ : 0;
820
+ return (<div className="relative" style={{ width: renderedTotal }}>
821
+ {/* Use a raw <table> instead of the shadcn <Table> wrapper:
822
+ <Table> renders an internal <div className="overflow-auto">
823
+ which becomes the containing block for sticky cells, pinning
824
+ them to the off-screen right edge of the full table width
825
+ rather than the visible scroll area. */}
826
+ <table className="w-full caption-bottom text-sm" style={{ tableLayout: 'fixed', width: renderedTotal }}>
827
+ <TableHeader>
828
+ {table.getHeaderGroups().map((hg) => (<TableRow key={hg.id}>
829
+ {hg.headers.map((header) => (<TableHead key={header.id} style={{ width: sizeOf(header.column.id, header.getSize()) }} className={cn('relative select-none', header.column.id === '_actions' &&
830
+ 'sticky right-0 z-20 bg-muted px-1 shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.15)]')}>
831
+ {header.isPlaceholder
832
+ ? null
833
+ : flexRender(header.column.columnDef.header, header.getContext())}
834
+ {header.column.getCanResize() && (<div data-resize-handle="" onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} onClick={(e) => e.stopPropagation()} onDoubleClick={() => header.column.resetSize()} className={cn('absolute right-0 top-0 z-10 h-full w-1.5 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/40', header.column.getIsResizing() && 'bg-primary')} aria-hidden="true"/>)}
835
+ </TableHead>))}
836
+ </TableRow>))}
837
+ </TableHeader>
838
+ <TableBody>
839
+ {records.isFetching ? (Array.from({ length: pagination.pageSize }, (_, i) => (<TableRow key={`skel-${i}`} className="pointer-events-none">
840
+ {table.getVisibleLeafColumns().map((col, j) => (<TableCell key={col.id} style={{ width: sizeOf(col.id, col.getSize()) }} className={cn(col.id === '_actions' &&
841
+ 'sticky right-0 z-10 bg-card px-1 shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.15)]')}>
842
+ {col.id === '_select' ? (<Skeleton className="h-4 w-4 rounded"/>) : col.id === '_actions' ? (<Skeleton className="h-8 w-8 rounded"/>) : (<Skeleton className={`h-4 ${SKEL_WIDTHS[(i * 3 + j) % SKEL_WIDTHS.length]}`}/>)}
843
+ </TableCell>))}
844
+ </TableRow>))) : records.isError ? (<TableRow>
845
+ <TableCell colSpan={columns.length} className="py-8">
846
+ <div className="sticky left-4 w-fit text-destructive">
847
+ {t('common:loadFailed', { error: String(records.error) })}
848
+ </div>
849
+ </TableCell>
850
+ </TableRow>) : (table.getRowModel().rows.map((row) => (<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'} className="group cursor-pointer" onClick={(e) => {
851
+ const target = e.target;
852
+ if (target.closest('a, button, [role="menuitem"], [role="checkbox"]'))
853
+ return;
854
+ if (disableRowNavigation) {
855
+ row.toggleSelected(!row.getIsSelected());
856
+ return;
857
+ }
858
+ navigate({ name: 'edit', resourceId, recordId: row.original.id });
859
+ }} onAuxClick={(e) => {
860
+ if (e.button !== 1)
861
+ return;
862
+ const target = e.target;
863
+ if (target.closest('a, button, [role="menuitem"]'))
864
+ return;
865
+ if (disableRowNavigation)
866
+ return;
867
+ e.preventDefault();
868
+ openInNewTab({ name: 'edit', resourceId, recordId: row.original.id });
869
+ }} onMouseDown={(e) => {
870
+ if (e.button === 1) {
871
+ const target = e.target;
872
+ if (target.closest('a, button, [role="menuitem"]'))
873
+ return;
874
+ e.preventDefault();
875
+ }
876
+ }}>
877
+ {row.getVisibleCells().map((cell) => (<TableCell key={cell.id} style={{ width: sizeOf(cell.column.id, cell.column.getSize()) }} className={cn('overflow-hidden', cell.column.id === '_actions' &&
878
+ 'sticky right-0 z-10 bg-card px-1 shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.15)] group-hover:bg-muted group-data-[state=selected]:bg-muted')}>
879
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
880
+ </TableCell>))}
881
+ </TableRow>)))}
882
+ </TableBody>
883
+ </table>
884
+ {/* Vertical guide line follows the cursor while a column is being
885
+ resized. Position is computed from rendered (boosted) widths. */}
886
+ {resizingHeader && (<div aria-hidden="true" className="pointer-events-none absolute top-0 z-20 h-full w-px bg-primary" style={{ left: resizeLeft }}/>)}
887
+ </div>);
888
+ })()}
889
+ </div>
890
+ </>)}
891
+ </ContentEl>
892
+ </>);
893
+ return (<div className={cn('flex flex-col', f.card ? 'min-h-full' : 'h-full')}>
894
+ {f.breadcrumbs && (<PageBreadcrumbs className="mb-2 sm:mb-4" items={[homeCrumb(t('common:home')), { label: resource.name }]}/>)}
895
+ {f.card ? (<Card className="flex flex-1 flex-col">{inner}</Card>) : (
896
+ // Embedded mode (e.g. picker dialog): the table area scrolls
897
+ // internally so the paginator below can sit flush at the host's
898
+ // bottom edge, full-width, without a scrollbar gutter eating its
899
+ // right side. `min-h-0` is required for `flex-1 overflow-y-auto`
900
+ // inside a flex column to actually constrain its height. Horizontal
901
+ // + top padding lives on the scroll container — the paginator is
902
+ // a sibling so it stays edge-to-edge.
903
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 pt-4">{inner}</div>)}
904
+ {!showStandaloneEmptyState && (
905
+ // Standalone page mode: sticky at the page-wrapper level so the
906
+ // paginator pins to the viewport bottom while the user scrolls
907
+ // through the list. The bar extends edge-to-edge via negative
908
+ // margins that exactly cancel the main scroll-container padding
909
+ // (`px-4 sm:px-6`) so it sits flush against the screen edges with
910
+ // no visible gutter. Right padding (`pr-14 sm:pr-16`) reserves
911
+ // space for the floating AI assistant widget (`fixed bottom-4
912
+ // right-4`, ~40px wide) so pagination buttons never slide under
913
+ // it. A top shadow lifts the bar visually off the table when the
914
+ // user is mid-scroll.
915
+ //
916
+ // Embedded mode (`card: false`, e.g. picker dialog): plain flex
917
+ // child below the scrollable table area. No sticky, no scrollbar
918
+ // gutter interference — the bar spans the host's full width and
919
+ // sits directly above whatever the host renders next (e.g.
920
+ // DialogFooter).
921
+ <div className={cn('border-t border-border bg-card py-3', f.card
922
+ ? 'sticky bottom-0 -mb-px z-20 -mx-2 mt-0 px-2 pr-14 shadow-[0_-4px_8px_-6px_rgba(0,0,0,0.08)] sm:-mx-6 sm:px-6 sm:pr-16'
923
+ : 'shrink-0 px-6')}>
924
+ <Paginator table={table} total={total} t={t}/>
925
+ </div>)}
926
+ </div>);
927
+ }
928
+ function SortHeader({ property, state, onSort, }) {
929
+ if (!property.isSortable) {
930
+ return <span className="font-semibold">{property.label}</span>;
931
+ }
932
+ const Icon = state === 'asc' ? ArrowUp : state === 'desc' ? ArrowDown : ArrowUpDown;
933
+ return (<button type="button" onClick={onSort} className="-ml-2 inline-flex h-8 items-center gap-1 rounded-md px-2 font-semibold hover:bg-accent hover:text-accent-foreground">
934
+ {property.label}
935
+ <Icon className="size-3.5 opacity-60"/>
936
+ </button>);
937
+ }
938
+ function CellContent({ resourceId, recordId, property, value, populated, }) {
939
+ if (property.isId) {
940
+ return (<Link to={{ name: 'show', resourceId, recordId }} className="font-mono text-sm font-medium text-foreground hover:underline" onClick={(e) => e.stopPropagation()}>
941
+ {String(value ?? '')}
942
+ </Link>);
943
+ }
944
+ // m2m properties also set `reference` + `isArray`, but their value is an
945
+ // array of `{ id, ...extras }` objects — not scalar FKs. Hand them off to
946
+ // PropertyDisplay so its dedicated `case 'm2m'` branch can extract ids.
947
+ if (property.reference && property.type !== 'm2m' && value != null && value !== '') {
948
+ if (property.isArray) {
949
+ const ids = Array.isArray(value) ? value : [];
950
+ return (<ReferenceLinkList resourceId={property.reference} recordIds={ids} populated={populated} populatedKeyPrefix={property.path}/>);
951
+ }
952
+ // The list endpoint pre-populates scalar references in batch
953
+ // (`record.populated[property.path]`), so we hand the inline record to
954
+ // <ReferenceLink> and avoid the per-row `show` request.
955
+ const populatedRecord = populated?.[property.path];
956
+ return (<ReferenceLink resourceId={property.reference} recordId={value} populated={populatedRecord}/>);
957
+ }
958
+ return <PropertyDisplay property={property} value={value} view="list" populated={populated}/>;
959
+ }
960
+ function RowActions({ onView, onEdit, onDelete, onInvokeAction, customActions = [], t, }) {
961
+ return (<div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
962
+ <DropdownMenu>
963
+ <DropdownMenuTrigger asChild>
964
+ <Button variant="ghost" size="icon" className="size-8">
965
+ <MoreHorizontal className="size-4"/>
966
+ <span className="sr-only">{t('common:openMenu')}</span>
967
+ </Button>
968
+ </DropdownMenuTrigger>
969
+ <DropdownMenuContent align="end">
970
+ <DropdownMenuLabel>{t('common:actions')}</DropdownMenuLabel>
971
+ <DropdownMenuItem onSelect={onView}>
972
+ <Eye className="size-4"/> {t('common:show')}
973
+ </DropdownMenuItem>
974
+ <DropdownMenuItem onSelect={onEdit}>
975
+ <Pencil className="size-4"/> {t('common:edit')}
976
+ </DropdownMenuItem>
977
+ {customActions.length > 0 && (<>
978
+ <DropdownMenuSeparator />
979
+ <ActionMenuItems actions={customActions} onAction={(action) => onInvokeAction?.(action)}/>
980
+ </>)}
981
+ <DropdownMenuSeparator />
982
+ <DropdownMenuItem onSelect={onDelete} className="text-destructive focus:text-destructive">
983
+ <Trash2 className="size-4"/> {t('common:delete')}
984
+ </DropdownMenuItem>
985
+ </DropdownMenuContent>
986
+ </DropdownMenu>
987
+ </div>);
988
+ }
989
+ function ColumnVisibilityMenu({ table, properties, t, }) {
990
+ const labelMap = new Map(properties.map((p) => [p.path, p.label]));
991
+ return (<DropdownMenu>
992
+ <DropdownMenuTrigger asChild>
993
+ <Button variant="outline" size="sm">
994
+ <SlidersHorizontal className="size-4"/>
995
+ <span className="hidden sm:inline">{t('common:columns')}</span>
996
+ </Button>
997
+ </DropdownMenuTrigger>
998
+ <DropdownMenuContent align="end" className="w-48">
999
+ <DropdownMenuLabel>{t('common:toggleColumns')}</DropdownMenuLabel>
1000
+ <DropdownMenuSeparator />
1001
+ {table
1002
+ .getAllColumns()
1003
+ .filter((c) => c.getCanHide())
1004
+ .map((column) => (<DropdownMenuCheckboxItem key={column.id} checked={column.getIsVisible()} onCheckedChange={(v) => column.toggleVisibility(!!v)} onSelect={(e) => e.preventDefault()}>
1005
+ {labelMap.get(column.id) ?? column.id}
1006
+ </DropdownMenuCheckboxItem>))}
1007
+ </DropdownMenuContent>
1008
+ </DropdownMenu>);
1009
+ }
1010
+ /** Sliding window of up to `windowSize` page indices centred on `pageIndex`. */
1011
+ function getPageRange(pageIndex, pageCount, windowSize = 10) {
1012
+ if (pageCount <= 0)
1013
+ return [];
1014
+ const half = Math.floor(windowSize / 2);
1015
+ let start = pageIndex - half;
1016
+ let end = start + windowSize;
1017
+ if (start < 0) {
1018
+ start = 0;
1019
+ end = windowSize;
1020
+ }
1021
+ if (end > pageCount) {
1022
+ end = pageCount;
1023
+ start = Math.max(0, end - windowSize);
1024
+ }
1025
+ return Array.from({ length: end - start }, (_, i) => start + i);
1026
+ }
1027
+ function Paginator({ table, total, t, }) {
1028
+ const { pageIndex, pageSize } = table.getState().pagination;
1029
+ const pageCount = table.getPageCount();
1030
+ const pages = getPageRange(pageIndex, pageCount);
1031
+ // Click-and-drag horizontal scroll on the page-buttons row, mirroring the
1032
+ // table wrapper. Callback ref returns a cleanup so React 19 detaches the
1033
+ // pointer listeners automatically when the row unmounts.
1034
+ const paginationScrollRef = React.useCallback((el) => {
1035
+ if (!el)
1036
+ return;
1037
+ return attachDragScroll(el);
1038
+ }, []);
1039
+ const perPageSelect = (<Select value={String(pageSize)} onValueChange={(v) => table.setPageSize(Number(v))}>
1040
+ <SelectTrigger className="h-8 w-[72px]">
1041
+ <SelectValue />
1042
+ </SelectTrigger>
1043
+ <SelectContent>
1044
+ {PAGE_SIZES.map((s) => (<SelectItem key={s} value={String(s)}>
1045
+ {s}
1046
+ </SelectItem>))}
1047
+ </SelectContent>
1048
+ </Select>);
1049
+ return (<div className="flex w-full min-w-0 flex-col gap-3 px-1 sm:flex-row sm:items-center sm:justify-between">
1050
+ {/* Top row on mobile (records count + per-page select side-by-side);
1051
+ on desktop just the records-count label on the left. */}
1052
+ <div className="flex items-center justify-between gap-3 sm:justify-start">
1053
+ <div className="text-sm text-muted-foreground">
1054
+ {t('common:recordsCount', { count: total })}
1055
+ </div>
1056
+ {/* Mobile-only per-page select inline with records count. */}
1057
+ <div className="sm:hidden">{perPageSelect}</div>
1058
+ </div>
1059
+ {/* `min-w-0` on the right block is critical: without it, the flex item
1060
+ takes its content's intrinsic width on mobile (the buttons row is
1061
+ ~460px) and overflows the panel to the left. */}
1062
+ <div className="flex min-w-0 flex-col items-center gap-2 sm:flex-row">
1063
+ {/* Desktop-only per-page label + select next to the pagination buttons. */}
1064
+ <div className="hidden items-center gap-2 sm:flex">
1065
+ <span className="text-sm text-muted-foreground">
1066
+ {t('common:rowsPerPage')}
1067
+ </span>
1068
+ {perPageSelect}
1069
+ </div>
1070
+ {/* Page navigation — scrollable + drag-scrollable on narrow screens.
1071
+ `max-w-full` clamps to parent width so overflow-x-auto actually
1072
+ activates; without it the inner buttons row would expand the
1073
+ container instead of scrolling internally. `cursor-grab` hints the
1074
+ drag affordance; buttons keep their own `cursor-pointer`. */}
1075
+ <div ref={paginationScrollRef} className="max-w-full cursor-grab overflow-x-auto">
1076
+ <div className="flex items-center gap-1">
1077
+ <Button variant="outline" size="sm" onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>
1078
+ <ChevronsLeft className="size-4"/>
1079
+ </Button>
1080
+ <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
1081
+ <ChevronLeft className="size-4"/>
1082
+ </Button>
1083
+ {pages.map((p) => (<Button key={p} variant={p === pageIndex ? 'default' : 'outline'} size="sm" className="text-xs" onClick={() => table.setPageIndex(p)} aria-current={p === pageIndex ? 'page' : undefined}>
1084
+ {p + 1}
1085
+ </Button>))}
1086
+ <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
1087
+ <ChevronRight className="size-4"/>
1088
+ </Button>
1089
+ <Button variant="outline" size="sm" onClick={() => table.setPageIndex(pageCount - 1)} disabled={!table.getCanNextPage()}>
1090
+ <ChevronsRight className="size-4"/>
1091
+ </Button>
1092
+ </div>
1093
+ </div>
1094
+ </div>
1095
+ </div>);
1096
+ }
1097
+ const STRING_OPS = new Set(['co', 'nco', 'sw', 'ew', 'eq', 'neq', 'empty', 'nempty', 'in']);
1098
+ const ALL_STRING_OPS = ['co', 'nco', 'sw', 'ew', 'in', 'empty', 'nempty'];
1099
+ const NULLARY_OPS = new Set(['empty', 'nempty']);
1100
+ function parseFilterString(raw) {
1101
+ if (!raw)
1102
+ return { op: 'co', val: '' };
1103
+ const colonIdx = raw.indexOf(':');
1104
+ if (colonIdx === -1)
1105
+ return { op: 'co', val: raw };
1106
+ const prefix = raw.slice(0, colonIdx);
1107
+ if (STRING_OPS.has(prefix))
1108
+ return { op: prefix, val: raw.slice(colonIdx + 1) };
1109
+ return { op: 'co', val: raw };
1110
+ }
1111
+ function encodeFilter(op, val) {
1112
+ if (op === 'empty' || op === 'nempty')
1113
+ return `${op}:`;
1114
+ // Unchecking the last item in the "Is one of" picker ⇒ no filter.
1115
+ // We deliberately do NOT emit `in:` here: it would survive
1116
+ // `setDraftFilter`'s empty-string guard and ship a phantom
1117
+ // `filters[col]=in:` URL param (and a "1 active filter" badge) while
1118
+ // the adapter layer drops the clause anyway. The operator resets to
1119
+ // `co` on close, but `StringFilterField`'s auto-switch re-promotes
1120
+ // low-cardinality fields back to `in` the next time the panel opens.
1121
+ if (op === 'in')
1122
+ return val ? `in:${val}` : '';
1123
+ if (!val)
1124
+ return '';
1125
+ return `${op}:${val}`;
1126
+ }
1127
+ const NUMERIC_OP_SET = new Set(['eq', 'neq', 'gt', 'lt', 'between', 'empty', 'nempty']);
1128
+ const ALL_NUMERIC_OPS = ['eq', 'neq', 'gt', 'lt', 'between', 'empty', 'nempty'];
1129
+ const NUMERIC_NULLARY = new Set(['empty', 'nempty']);
1130
+ function parseNumericFilter(raw) {
1131
+ if (!raw)
1132
+ return { op: 'eq', from: '', to: '' };
1133
+ const colonIdx = raw.indexOf(':');
1134
+ if (colonIdx === -1)
1135
+ return { op: 'eq', from: raw, to: '' };
1136
+ const prefix = raw.slice(0, colonIdx);
1137
+ if (!NUMERIC_OP_SET.has(prefix))
1138
+ return { op: 'eq', from: raw, to: '' };
1139
+ const rest = raw.slice(colonIdx + 1);
1140
+ if (prefix === 'between') {
1141
+ const commaIdx = rest.indexOf(',');
1142
+ return commaIdx !== -1
1143
+ ? { op: 'between', from: rest.slice(0, commaIdx), to: rest.slice(commaIdx + 1) }
1144
+ : { op: 'between', from: rest, to: '' };
1145
+ }
1146
+ return { op: prefix, from: rest, to: '' };
1147
+ }
1148
+ function encodeNumericFilter(op, from, to) {
1149
+ if (op === 'empty' || op === 'nempty')
1150
+ return `${op}:`;
1151
+ if (op === 'between')
1152
+ return (from || to) ? `between:${from},${to}` : '';
1153
+ return from ? `${op}:${from}` : '';
1154
+ }
1155
+ function NumericFilterField({ value, onChange, t, }) {
1156
+ const parsed = parseNumericFilter(value);
1157
+ const [op, setOp] = React.useState(parsed.op);
1158
+ const [from, setFrom] = React.useState(parsed.from);
1159
+ const [to, setTo] = React.useState(parsed.to);
1160
+ React.useEffect(() => {
1161
+ const next = parseNumericFilter(value);
1162
+ setOp(next.op);
1163
+ setFrom(next.from);
1164
+ setTo(next.to);
1165
+ }, [value]);
1166
+ const emit = (nextOp, nextFrom, nextTo) => {
1167
+ setOp(nextOp);
1168
+ setFrom(nextFrom);
1169
+ setTo(nextTo);
1170
+ onChange(encodeNumericFilter(nextOp, nextFrom, nextTo));
1171
+ };
1172
+ const handleOpChange = (nextOp) => {
1173
+ if (NUMERIC_NULLARY.has(nextOp)) {
1174
+ emit(nextOp, '', '');
1175
+ }
1176
+ else {
1177
+ emit(nextOp, from, nextOp === 'between' ? to : '');
1178
+ }
1179
+ };
1180
+ return (<div className="space-y-2">
1181
+ <Select value={op} onValueChange={(v) => handleOpChange(v)}>
1182
+ <SelectTrigger className="h-7 text-xs">
1183
+ <SelectValue />
1184
+ </SelectTrigger>
1185
+ <SelectContent>
1186
+ {ALL_NUMERIC_OPS.map((o) => (<SelectItem key={o} value={o} className="text-xs">
1187
+ {t(`filter:op.${o}`)}
1188
+ </SelectItem>))}
1189
+ </SelectContent>
1190
+ </Select>
1191
+ {op === 'between' ? (<div className="flex gap-2">
1192
+ <Input type="number" className="h-8" value={from} placeholder={t('common:from')} onChange={(e) => emit('between', e.target.value, to)}/>
1193
+ <Input type="number" className="h-8" value={to} placeholder={t('common:to')} onChange={(e) => emit('between', from, e.target.value)}/>
1194
+ </div>) : !NUMERIC_NULLARY.has(op) ? (<Input type="number" className="h-8" value={from} placeholder={t('common:any')} onChange={(e) => emit(op, e.target.value, '')}/>) : null}
1195
+ </div>);
1196
+ }
1197
+ // ─── Filter panel (side sheet) ───────────────────────────────────────────────
1198
+ function FilterPanel({ open, onOpenChange, properties, filters, onChange, resourceId, t, }) {
1199
+ const [draft, setDraft] = React.useState(filters);
1200
+ React.useEffect(() => {
1201
+ if (open)
1202
+ setDraft(filters);
1203
+ }, [open, filters]);
1204
+ const draftMap = new Map(draft.map((f) => [f.id, f.value]));
1205
+ const setDraftFilter = (id, value) => {
1206
+ const without = draft.filter((f) => f.id !== id);
1207
+ setDraft(value != null && value !== '' ? [...without, { id, value }] : without);
1208
+ };
1209
+ const handleApply = () => {
1210
+ onChange(draft);
1211
+ onOpenChange(false);
1212
+ };
1213
+ const handleClearAll = () => {
1214
+ setDraft([]);
1215
+ onChange([]);
1216
+ };
1217
+ return (<Sheet open={open} onOpenChange={onOpenChange}>
1218
+ <SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md" aria-describedby={undefined}>
1219
+ <SheetHeader className="flex-none flex-row items-center justify-between space-y-0 border-b border-border px-4 py-3 pr-12">
1220
+ <div className="flex items-center gap-2">
1221
+ <SheetTitle>{t('common:filters')}</SheetTitle>
1222
+ {draft.length > 0 && (<Badge className="h-5 rounded-full px-1.5 text-xs">{draft.length}</Badge>)}
1223
+ </div>
1224
+ {draft.length > 0 && (<Button variant="ghost" size="sm" onClick={handleClearAll}>
1225
+ {t('common:clearAll')}
1226
+ </Button>)}
1227
+ </SheetHeader>
1228
+
1229
+ <ScrollArea className="min-h-0 flex-1">
1230
+ <div className="space-y-5 px-4 py-4">
1231
+ {properties.map((p) => (<FilterField key={p.path} property={p} value={draftMap.get(p.path)} onChange={(v) => setDraftFilter(p.path, v)} valueFrom={draftMap.get(p.path + '~~from')} valueTo={draftMap.get(p.path + '~~to')} onChangeFrom={(v) => setDraftFilter(p.path + '~~from', v)} onChangeTo={(v) => setDraftFilter(p.path + '~~to', v)} resourceId={resourceId} t={t}/>))}
1232
+ </div>
1233
+ </ScrollArea>
1234
+
1235
+ <div className="flex flex-none border-t border-border p-4">
1236
+ <Button className="w-full" onClick={handleApply}>
1237
+ {t('common:applyFilters')}
1238
+ </Button>
1239
+ </div>
1240
+ </SheetContent>
1241
+ </Sheet>);
1242
+ }
1243
+ // ─── Filter field (generic wrapper per property) ─────────────────────────────
1244
+ function FilterField({ property, value, onChange, valueFrom, valueTo, onChangeFrom, onChangeTo, resourceId, t, }) {
1245
+ const isDateType = property.type === 'date' || property.type === 'datetime';
1246
+ return (<div className="space-y-1.5">
1247
+ <Label className="text-sm font-medium">{property.label}</Label>
1248
+ {isDateType ? (<DateRangeFilter mode={property.type} from={valueFrom} to={valueTo} onFromChange={onChangeFrom ?? onChange} onToChange={onChangeTo ?? onChange} t={t}/>) : (<FilterInput property={property} value={value ?? ''} onChange={onChange} resourceId={resourceId} t={t}/>)}
1249
+ </div>);
1250
+ }
1251
+ function DateRangeFilter({ mode, from, to, onFromChange, onToChange, t, }) {
1252
+ return (<div className="space-y-2">
1253
+ <div className="space-y-1">
1254
+ <span className="text-xs text-muted-foreground">{t('common:from')}</span>
1255
+ <DatePicker mode={mode} value={from ?? ''} onChange={(v) => onFromChange(v)} ariaLabel={t('common:from')}/>
1256
+ </div>
1257
+ <div className="space-y-1">
1258
+ <span className="text-xs text-muted-foreground">{t('common:to')}</span>
1259
+ <DatePicker mode={mode} value={to ?? ''} onChange={(v) => onToChange(v)} ariaLabel={t('common:to')}/>
1260
+ </div>
1261
+ </div>);
1262
+ }
1263
+ // ─── Filter input (dispatches to type-specific UIs) ──────────────────────────
1264
+ function FilterInput({ property, value, onChange, resourceId, t, }) {
1265
+ // Reference field → combobox backed by the referenced resource's search action
1266
+ if (property.reference && !property.isArray) {
1267
+ return (<ReferenceCombobox referenceResourceId={property.reference} value={value || null} onChange={(v) => onChange(v ?? '')} placeholder={t('common:any')}/>);
1268
+ }
1269
+ // Enum / available values → Select
1270
+ if (property.availableValues?.length) {
1271
+ return (<Select value={value || '_any_'} onValueChange={(v) => onChange(v === '_any_' ? '' : v)}>
1272
+ <SelectTrigger className="h-8">
1273
+ <SelectValue placeholder={t('common:any')}/>
1274
+ </SelectTrigger>
1275
+ <SelectContent>
1276
+ <SelectItem value="_any_">{t('common:any')}</SelectItem>
1277
+ {property.availableValues.map((opt) => (<SelectItem key={opt.value} value={opt.value}>
1278
+ {opt.label}
1279
+ </SelectItem>))}
1280
+ </SelectContent>
1281
+ </Select>);
1282
+ }
1283
+ switch (property.type) {
1284
+ case 'boolean':
1285
+ return (<Select value={value || '_any_'} onValueChange={(v) => onChange(v === '_any_' ? '' : v)}>
1286
+ <SelectTrigger className="h-8">
1287
+ <SelectValue placeholder={t('common:any')}/>
1288
+ </SelectTrigger>
1289
+ <SelectContent>
1290
+ <SelectItem value="_any_">{t('common:any')}</SelectItem>
1291
+ <SelectItem value="true">{t('common:yes')}</SelectItem>
1292
+ <SelectItem value="false">{t('common:no')}</SelectItem>
1293
+ </SelectContent>
1294
+ </Select>);
1295
+ case 'number':
1296
+ case 'float':
1297
+ case 'money':
1298
+ case 'currency':
1299
+ return (<NumericFilterField value={value} onChange={onChange} t={t}/>);
1300
+ default:
1301
+ return (<StringFilterField property={property} value={value} onChange={onChange} resourceId={resourceId} t={t}/>);
1302
+ }
1303
+ }
1304
+ // ─── String filter with operator selector + value picker ─────────────────────
1305
+ function StringFilterField({ property, value, onChange, resourceId, t, }) {
1306
+ const parsed = parseFilterString(value);
1307
+ const [op, setOp] = React.useState(parsed.op);
1308
+ const [val, setVal] = React.useState(parsed.val);
1309
+ // Sync local state when external value changes (e.g. from URL update).
1310
+ React.useEffect(() => {
1311
+ const next = parseFilterString(value);
1312
+ setOp(next.op);
1313
+ setVal(next.val);
1314
+ }, [value]);
1315
+ // Auto-detect: fetch distinct values to see if field is low-cardinality.
1316
+ const { data: distinctData } = useDistinctValues(resourceId, property.path, {
1317
+ limit: 101,
1318
+ });
1319
+ const isLowCardinality = distinctData != null && !distinctData.hasMore;
1320
+ const distinctValues = distinctData?.values ?? [];
1321
+ // If low cardinality, no existing filter, and default op (co with empty val):
1322
+ // auto-switch to "is one of" mode to match Metabase behavior.
1323
+ const autoSwitchedRef = React.useRef(false);
1324
+ React.useEffect(() => {
1325
+ if (autoSwitchedRef.current)
1326
+ return;
1327
+ if (isLowCardinality && !value && op === 'co' && val === '') {
1328
+ autoSwitchedRef.current = true;
1329
+ setOp('in');
1330
+ }
1331
+ }, [isLowCardinality, value, op, val]);
1332
+ const emit = (nextOp, nextVal) => {
1333
+ setOp(nextOp);
1334
+ setVal(nextVal);
1335
+ onChange(encodeFilter(nextOp, nextVal));
1336
+ };
1337
+ const handleOpChange = (nextOp) => {
1338
+ if (NULLARY_OPS.has(nextOp)) {
1339
+ emit(nextOp, '');
1340
+ }
1341
+ else if (nextOp === 'in') {
1342
+ // Switching to multi-select: clear text value
1343
+ emit(nextOp, '');
1344
+ }
1345
+ else {
1346
+ // Switching from multi-select to text: clear value
1347
+ emit(nextOp, op === 'in' ? '' : val);
1348
+ }
1349
+ };
1350
+ return (<div className="space-y-2">
1351
+ {/* Operator selector */}
1352
+ <Select value={op} onValueChange={(v) => handleOpChange(v)}>
1353
+ <SelectTrigger className="h-7 text-xs">
1354
+ <SelectValue />
1355
+ </SelectTrigger>
1356
+ <SelectContent>
1357
+ {ALL_STRING_OPS.map((o) => (<SelectItem key={o} value={o} className="text-xs">
1358
+ {t(`filter:op.${o}`)}
1359
+ </SelectItem>))}
1360
+ </SelectContent>
1361
+ </Select>
1362
+
1363
+ {/* Value input based on operator */}
1364
+ {op === 'in' ? (<FilterValuePicker resourceId={resourceId} field={property.path} selected={val ? val.split(',') : []} onChange={(selected) => emit('in', selected.join(','))} preloadedValues={isLowCardinality ? distinctValues : undefined} t={t}/>) : !NULLARY_OPS.has(op) ? (<Input className="h-8" value={val} placeholder={t('common:filterPlaceholder')} onChange={(e) => emit(op, e.target.value)}/>) : null}
1365
+ </div>);
1366
+ }
1367
+ // ─── Value picker (checkbox list with search, Metabase-style) ────────────────
1368
+ function FilterValuePicker({ resourceId, field, selected, onChange, preloadedValues, t, }) {
1369
+ const [search, setSearch] = React.useState('');
1370
+ const selectedSet = React.useMemo(() => new Set(selected), [selected]);
1371
+ // Fetch values from server (skipped when preloaded values are available).
1372
+ const needsServerSearch = preloadedValues == null;
1373
+ const { data: serverData, isLoading } = useDistinctValues(resourceId, field, { search: needsServerSearch ? search : undefined, limit: 100, enabled: needsServerSearch });
1374
+ // Client-side filter when using preloaded values, falling back to the
1375
+ // server-fetched distinct values otherwise.
1376
+ const displayValues = React.useMemo(() => {
1377
+ const allValues = preloadedValues ?? serverData?.values ?? [];
1378
+ if (!preloadedValues || !search)
1379
+ return allValues;
1380
+ const lower = search.toLowerCase();
1381
+ return allValues.filter((v) => v.toLowerCase().includes(lower));
1382
+ }, [preloadedValues, serverData?.values, search]);
1383
+ const toggle = (val) => {
1384
+ if (selectedSet.has(val)) {
1385
+ onChange(selected.filter((v) => v !== val));
1386
+ }
1387
+ else {
1388
+ onChange([...selected, val]);
1389
+ }
1390
+ };
1391
+ const handleSelectAll = () => {
1392
+ const allSelected = displayValues.length > 0 && displayValues.every((v) => selectedSet.has(v));
1393
+ if (allSelected) {
1394
+ // Deselect all currently visible values
1395
+ const visibleSet = new Set(displayValues);
1396
+ onChange(selected.filter((v) => !visibleSet.has(v)));
1397
+ }
1398
+ else {
1399
+ const allSet = new Set([...selected, ...displayValues]);
1400
+ onChange(Array.from(allSet));
1401
+ }
1402
+ };
1403
+ return (<div className="space-y-2">
1404
+ {/* Search input */}
1405
+ <div className="relative">
1406
+ <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"/>
1407
+ <Input className="h-7 pl-7 text-xs" value={search} placeholder={t('filter:searchValues')} onChange={(e) => setSearch(e.target.value)}/>
1408
+ </div>
1409
+
1410
+ {/* Select all */}
1411
+ {displayValues.length > 0 && (<button type="button" className="flex w-full items-center gap-2 rounded px-1 py-0.5 text-xs text-muted-foreground hover:text-foreground" onClick={handleSelectAll}>
1412
+ <Checkbox className="size-3.5" checked={displayValues.length > 0 && displayValues.every((v) => selectedSet.has(v))
1413
+ ? true
1414
+ : displayValues.some((v) => selectedSet.has(v))
1415
+ ? 'indeterminate'
1416
+ : false}/>
1417
+ {t('filter:selectAll')}
1418
+ </button>)}
1419
+
1420
+ {/* Value list */}
1421
+ <div className="max-h-48 overflow-y-auto">
1422
+ <div className="space-y-0.5">
1423
+ {isLoading && !preloadedValues ? (<div className="py-2 text-center text-xs text-muted-foreground">
1424
+ {t('common:loading')}
1425
+ </div>) : displayValues.length === 0 ? (<div className="py-2 text-center text-xs text-muted-foreground">
1426
+ {t('filter:noValues')}
1427
+ </div>) : (displayValues.map((v) => (<button key={v} type="button" className="flex w-full items-center gap-2 rounded px-1 py-0.5 text-left text-sm hover:bg-accent" onClick={() => toggle(v)}>
1428
+ <Checkbox className="size-3.5" checked={selectedSet.has(v)}/>
1429
+ <span className="truncate">{v}</span>
1430
+ </button>)))}
1431
+ </div>
1432
+ </div>
1433
+
1434
+ {/* Selected count */}
1435
+ {selected.length > 0 && (<div className="text-xs text-muted-foreground">
1436
+ {t('common:selectedCount', { count: selected.length })}
1437
+ </div>)}
1438
+ </div>);
1439
+ }
1440
+ // ─── Per-column filter popover in table header ───────────────────────────────
1441
+ // A magnifying-glass icon sits next to the sort label. Clicking it opens a
1442
+ // Popover with the same full filter controls as the side panel (FilterField).
1443
+ // The icon is highlighted when a filter for this column is active.
1444
+ function ColumnFilterPopover({ property, getFilters, onApply, resourceId, t, }) {
1445
+ const [open, setOpen] = React.useState(false);
1446
+ const isDateType = property.type === 'date' || property.type === 'datetime';
1447
+ const [value, setValue] = React.useState('');
1448
+ const [valueFrom, setValueFrom] = React.useState('');
1449
+ const [valueTo, setValueTo] = React.useState('');
1450
+ // Initialise draft from current URL filters each time the popover opens.
1451
+ React.useEffect(() => {
1452
+ if (!open)
1453
+ return;
1454
+ const map = new Map(getFilters().map((f) => [f.id, String(f.value ?? '')]));
1455
+ if (isDateType) {
1456
+ setValueFrom(map.get(property.path + '~~from') ?? '');
1457
+ setValueTo(map.get(property.path + '~~to') ?? '');
1458
+ }
1459
+ else {
1460
+ setValue(map.get(property.path) ?? '');
1461
+ }
1462
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1463
+ }, [open]);
1464
+ // Icon is highlighted when any filter for this property is set.
1465
+ const isActive = (() => {
1466
+ const map = new Map(getFilters().map((f) => [f.id, String(f.value ?? '')]));
1467
+ return isDateType
1468
+ ? !!(map.get(property.path + '~~from') || map.get(property.path + '~~to'))
1469
+ : !!map.get(property.path);
1470
+ })();
1471
+ const handleApply = () => {
1472
+ const updates = {};
1473
+ if (isDateType) {
1474
+ updates[property.path + '~~from'] = valueFrom;
1475
+ updates[property.path + '~~to'] = valueTo;
1476
+ }
1477
+ else {
1478
+ updates[property.path] = value;
1479
+ }
1480
+ onApply(updates);
1481
+ setOpen(false);
1482
+ };
1483
+ const handleClear = () => {
1484
+ const updates = {};
1485
+ if (isDateType) {
1486
+ updates[property.path + '~~from'] = '';
1487
+ updates[property.path + '~~to'] = '';
1488
+ }
1489
+ else {
1490
+ updates[property.path] = '';
1491
+ }
1492
+ onApply(updates);
1493
+ setOpen(false);
1494
+ };
1495
+ return (<Popover open={open} onOpenChange={setOpen}>
1496
+ <PopoverTrigger asChild>
1497
+ <button type="button" className={cn('inline-flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-accent', isActive ? 'text-primary' : 'text-muted-foreground opacity-50 hover:opacity-100')} aria-label={t('common:filter', { label: property.label })}>
1498
+ <ListFilter className="size-3.5"/>
1499
+ </button>
1500
+ </PopoverTrigger>
1501
+ <PopoverContent className="w-72 border-border p-3" align="start">
1502
+ <div className="space-y-3">
1503
+ <FilterField property={property} value={value} onChange={(v) => setValue(String(v ?? ''))} valueFrom={valueFrom} valueTo={valueTo} onChangeFrom={(v) => setValueFrom(String(v ?? ''))} onChangeTo={(v) => setValueTo(String(v ?? ''))} resourceId={resourceId} t={t}/>
1504
+ <div className="flex gap-2">
1505
+ <Button size="sm" className="flex-1" onClick={handleApply}>
1506
+ {t('common:apply')}
1507
+ </Button>
1508
+ <Button size="sm" variant="outline" onClick={handleClear}>
1509
+ {t('common:clear')}
1510
+ </Button>
1511
+ </div>
1512
+ </div>
1513
+ </PopoverContent>
1514
+ </Popover>);
1515
+ }
1516
+ // ─── Mobile record card ──────────────────────────────────────────────────────
1517
+ // Renders a single record as a tap-to-edit card with a header (avatar + title +
1518
+ // id), a 2-column grid of property values, and a contextual menu.
1519
+ function RecordCard({ record, properties, resourceId, showSelect, selected, onToggleSelect, onView, onEdit, onDelete, customActions = [], onInvokeAction, t, }) {
1520
+ const openInNewTab = useOpenInNewTab();
1521
+ const idProperty = properties.find((p) => p.isId);
1522
+ const titleProperty = properties.find((p) => !p.isId && p.type === 'string');
1523
+ const titleText = record.title ||
1524
+ (titleProperty ? String(record.params[titleProperty.path] ?? '') : '') ||
1525
+ `#${record.id}`;
1526
+ // Body shows non-id, non-title properties. On mobile we want maximum
1527
+ // information density, so render up to 8 — enough to surface most fields
1528
+ // without scrolling each card.
1529
+ const bodyProps = properties
1530
+ .filter((p) => !p.isId && p.path !== titleProperty?.path)
1531
+ .slice(0, 8);
1532
+ // Card uses a clickable div (not <button>) because it nests interactive
1533
+ // children (the RowActions DropdownMenuTrigger and reference links). HTML
1534
+ // forbids button-in-button. Behavior is mirrored from the desktop TableRow.
1535
+ const handleKeyDown = (e) => {
1536
+ if (e.key === 'Enter' || e.key === ' ') {
1537
+ const target = e.target;
1538
+ if (target.closest('a, button, [role="menuitem"]'))
1539
+ return;
1540
+ e.preventDefault();
1541
+ onEdit();
1542
+ }
1543
+ };
1544
+ const handleClick = (e) => {
1545
+ const target = e.target;
1546
+ if (target.closest('a, button, [role="menuitem"]'))
1547
+ return;
1548
+ onEdit();
1549
+ };
1550
+ const handleAuxClick = (e) => {
1551
+ if (e.button !== 1)
1552
+ return;
1553
+ const target = e.target;
1554
+ if (target.closest('a, button, [role="menuitem"]'))
1555
+ return;
1556
+ e.preventDefault();
1557
+ openInNewTab({ name: 'edit', resourceId, recordId: record.id });
1558
+ };
1559
+ const handleMouseDown = (e) => {
1560
+ if (e.button !== 1)
1561
+ return;
1562
+ const target = e.target;
1563
+ if (target.closest('a, button, [role="menuitem"]'))
1564
+ return;
1565
+ e.preventDefault();
1566
+ };
1567
+ return (<div role="button" tabIndex={0} onClick={handleClick} onAuxClick={handleAuxClick} onMouseDown={handleMouseDown} onKeyDown={handleKeyDown} data-state={selected ? 'selected' : undefined} className="block w-full cursor-pointer rounded-lg border border-border bg-card p-2.5 text-left transition-colors hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=selected]:border-primary/50 data-[state=selected]:bg-primary/5">
1568
+ <div className="flex items-start gap-2">
1569
+ {showSelect && (<div className="flex flex-none items-center pt-0.5" onClick={(e) => e.stopPropagation()}>
1570
+ <Checkbox checked={selected} onCheckedChange={(v) => onToggleSelect(!!v)} aria-label={t('common:selectRow')}/>
1571
+ </div>)}
1572
+ <div className="min-w-0 flex-1">
1573
+ <div className="flex items-start justify-between gap-2">
1574
+ <div className="min-w-0 flex-1">
1575
+ <div className="truncate text-sm font-semibold leading-tight">{titleText}</div>
1576
+ {idProperty && (<div className="truncate text-[11px] leading-tight text-muted-foreground">
1577
+ #{String(record.params[idProperty.path] ?? record.id)}
1578
+ </div>)}
1579
+ </div>
1580
+ <RowActions onView={onView} onEdit={onEdit} onDelete={onDelete} customActions={customActions} onInvokeAction={onInvokeAction} t={t}/>
1581
+ </div>
1582
+ {bodyProps.length > 0 && (<div className="mt-2 grid grid-cols-2 gap-x-2 gap-y-1.5">
1583
+ {bodyProps.map((p) => (<div key={p.path} className="min-w-0">
1584
+ <div className="truncate text-[10px] uppercase tracking-wide leading-tight text-muted-foreground">
1585
+ {p.label}
1586
+ </div>
1587
+ <div className="truncate text-sm leading-tight">
1588
+ <CellContent resourceId={resourceId} recordId={record.id} property={p} value={record.params[p.path]} populated={record.populated}/>
1589
+ </div>
1590
+ </div>))}
1591
+ </div>)}
1592
+ </div>
1593
+ </div>
1594
+ </div>);
1595
+ }
1596
+ //# sourceMappingURL=list-page.jsx.map