torch-glare 2.1.2 → 2.1.3

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 (27) hide show
  1. package/apps/lib/components/Avatar.tsx +1 -1
  2. package/apps/lib/components/Card.tsx +68 -54
  3. package/apps/lib/components/DataViews/DataViewRadio.tsx +47 -0
  4. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +56 -45
  5. package/apps/lib/components/DataViews/DataViewsHeader.tsx +130 -28
  6. package/apps/lib/components/DataViews/DataViewsLayout.tsx +32 -2
  7. package/apps/lib/components/DataViews/FilterPanel.tsx +148 -3
  8. package/apps/lib/components/DataViews/HeaderSearch.tsx +97 -0
  9. package/apps/lib/components/DataViews/InboxView.tsx +263 -282
  10. package/apps/lib/components/DataViews/InboxViewCard.tsx +136 -0
  11. package/apps/lib/components/DataViews/KanbanView.tsx +264 -153
  12. package/apps/lib/components/DataViews/PanelControls.tsx +10 -41
  13. package/apps/lib/components/DataViews/TreeView.tsx +220 -191
  14. package/apps/lib/components/DataViews/index.ts +6 -0
  15. package/apps/lib/components/DataViews/types.ts +30 -1
  16. package/apps/lib/components/Radio.tsx +18 -21
  17. package/apps/lib/components/Switch.tsx +3 -1
  18. package/apps/lib/components/Table.tsx +1 -1
  19. package/apps/lib/components/TreeFolder/TreeFolder.tsx +160 -137
  20. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +221 -93
  21. package/apps/lib/components/TreeFolder/types.ts +9 -0
  22. package/apps/lib/layouts/DataViewCard.tsx +76 -0
  23. package/dist/src/shared/copyComponentsRecursively.js +9 -1
  24. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  25. package/docs/components/data-views-config-panel.md +204 -0
  26. package/docs/components/data-views-layout.md +270 -0
  27. package/package.json +1 -1
@@ -1,57 +1,26 @@
1
1
  "use client";
2
2
 
3
- import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
4
3
  import { Switch } from "../Switch";
5
- import { cn } from "../../utils/cn";
4
+ import { DataViewRadio } from "./DataViewRadio";
6
5
 
7
6
  /**
8
7
  * DataViews config-panel form controls.
9
8
  *
10
- * These are intentionally NOT shared library components. The panel chrome is
11
- * always-dark (Figma `Cun` = #000000) and hardcodes Figma hex values, which
12
- * conflicts with the design-system token / `data-theme` convention used by the
13
- * public components. They live colocated with the panel so the always-dark
14
- * Figma styling stays internal to DataViews. Not re-exported from index.ts.
9
+ * The config panel is wrapped in `data-theme="dark"`, so theme-aware
10
+ * components (DataViewRadio, Switch) render in dark mode automatically. The
11
+ * green-checked Switch hex is the only thing this panel still hardcodes
12
+ * because it sits outside the theme system.
15
13
  */
16
14
 
17
15
  /**
18
- * Saved View / Default Sort radio row, built from the raw Radix primitive to
19
- * match Figma node 1612:30021. The shared <Radio>/<Label> components impose
20
- * their own circle size, color tokens, line-height reset and wrapper flex
21
- * layout that fight this panel's always-dark Figma spec, so the row is
22
- * hand-built here instead.
23
- *
24
- * The whole row IS the Radix Item, so the entire area (circle + label +
25
- * padding) is one click target. The circle/label are non-interactive visuals.
16
+ * Saved View / Default Sort radio row.
26
17
  *
18
+ * Thin wrapper around the reusable {@link DataViewRadio} so the config panel
19
+ * keeps a stable name. The whole row IS the Radix Item, so the entire area
20
+ * (circle + label + padding) is one click target.
27
21
  */
28
22
  export function RadioRow({ value, label }: { value: string; label: string }) {
29
- return (
30
- <RadioGroupPrimitive.Item
31
- value={value}
32
- className={cn(
33
- "group flex w-full items-center gap-1.5 py-1 pl-2",
34
- "cursor-pointer rounded-[8px] text-left outline-none transition-colors",
35
- "hover:bg-white/[0.04] focus-visible:bg-white/[0.04]",
36
- )}
37
- >
38
- <span
39
- className={cn(
40
- "flex h-[14px] w-[14px] shrink-0 items-center justify-center rounded-full",
41
- "border border-[#626467] bg-[rgba(255,255,255,0.05)] transition-colors",
42
- "group-data-[state=checked]:border-transparent",
43
- "group-data-[state=checked]:bg-[#005ECC]",
44
- )}
45
- >
46
- <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
47
- <span className="h-[6px] w-[6px] rounded-full bg-white" />
48
- </RadioGroupPrimitive.Indicator>
49
- </span>
50
- <span className="text-[14px] font-normal leading-[1.475] text-white">
51
- {label}
52
- </span>
53
- </RadioGroupPrimitive.Item>
54
- );
23
+ return <DataViewRadio value={value} label={label} />;
55
24
  }
56
25
 
57
26
  // Shared <Switch> with the bright-green checked track (#0AC713) from the Figma
@@ -1,6 +1,6 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import { useEffect, useMemo, useState } from "react"
3
+ import { useEffect, useMemo, useState } from "react";
4
4
  import type {
5
5
  DynamicRecord,
6
6
  ViewConfig,
@@ -10,7 +10,7 @@ import type {
10
10
  FilterState,
11
11
  FilterValue,
12
12
  TreeConfig,
13
- } from "./types"
13
+ } from "./types";
14
14
  import {
15
15
  applyMove,
16
16
  autoDetectTreeShape,
@@ -20,31 +20,34 @@ import {
20
20
  initialExpansion,
21
21
  pruneTree,
22
22
  type TreeNode,
23
- } from "../../utils/dataViews/treeUtils"
24
- import { getByPath, matchesFilterValues } from "../../utils/dataViews/pathUtils"
25
- import { visibleFields } from "../../utils/dataViews/fieldUtils"
26
- import { renderField } from "./fieldRenderers"
27
- import { renderDetailView } from "../../utils/dataViews/nestedDataUtils"
28
- import { useIsMobile } from "../../hooks/useIsMobile"
29
- import { TableView } from "./TableView"
30
- import { FilterPanel } from "./FilterPanel"
31
- import { TreeSidebar } from "./tree/TreeSidebar"
32
- import { TreeDrawer, TreeDrawerTrigger } from "./tree/TreeDrawer"
33
- import { Table2, FileText } from "lucide-react"
34
- import { cn } from "../../utils/cn"
23
+ } from "../../utils/dataViews/treeUtils";
24
+ import {
25
+ getByPath,
26
+ matchesFilterValues,
27
+ } from "../../utils/dataViews/pathUtils";
28
+ import { visibleFields } from "../../utils/dataViews/fieldUtils";
29
+ import { renderField } from "./fieldRenderers";
30
+ import { useIsMobile } from "../../hooks/useIsMobile";
31
+ import { TableView } from "./TableView";
32
+ import { FilterPanel } from "./FilterPanel";
33
+ import { TreeSidebar } from "./tree/TreeSidebar";
34
+ import { TreeDrawer, TreeDrawerTrigger } from "./tree/TreeDrawer";
35
+ import { Card, CardContent, CardHeader } from "../Card";
36
+ import { Table2, LayoutGrid } from "lucide-react";
37
+ import { cn } from "../../utils/cn";
35
38
 
36
39
  export type TreeViewProps = {
37
- data: DynamicRecord[]
38
- columns?: DynamicColumnConfig[]
39
- fields: FieldConfig[]
40
- config: ViewConfig
41
- treeConfig?: TreeConfig
42
- onDataUpdate?: (data: DynamicRecord[]) => void
43
- filters?: DynamicFilterConfig[]
44
- filterState?: FilterState
45
- onFilterChange?: (filters: FilterState) => void
46
- showFilters?: boolean
47
- }
40
+ data: DynamicRecord[];
41
+ columns?: DynamicColumnConfig[];
42
+ fields: FieldConfig[];
43
+ config: ViewConfig;
44
+ treeConfig?: TreeConfig;
45
+ onDataUpdate?: (data: DynamicRecord[]) => void;
46
+ filters?: DynamicFilterConfig[];
47
+ filterState?: FilterState;
48
+ onFilterChange?: (filters: FilterState) => void;
49
+ showFilters?: boolean;
50
+ };
48
51
 
49
52
  export function TreeView({
50
53
  data,
@@ -58,108 +61,118 @@ export function TreeView({
58
61
  onFilterChange,
59
62
  showFilters = true,
60
63
  }: TreeViewProps) {
61
- const isMobile = useIsMobile()
62
- const [internalFilters, setInternalFilters] = useState<FilterState>({})
63
- const activeFilters = externalFilterState ?? internalFilters
64
+ const isMobile = useIsMobile();
65
+ const [internalFilters, setInternalFilters] = useState<FilterState>({});
66
+ const activeFilters = externalFilterState ?? internalFilters;
64
67
 
65
68
  const resolvedTree = useMemo(
66
69
  () => autoDetectTreeShape(data, treeConfig ?? {}),
67
70
  [data, treeConfig],
68
- )
71
+ );
69
72
 
70
73
  const display = useMemo(
71
74
  () => visibleFields(fields).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
72
75
  [fields],
73
- )
76
+ );
74
77
 
75
78
  const labelField: FieldConfig = useMemo(() => {
76
- const path = resolvedTree.nodeLabel
79
+ const path = resolvedTree.nodeLabel;
77
80
  if (path) {
78
- const f = fields.find((x) => x.path === path)
79
- if (f) return f
80
- return { path, label: path, type: "text" }
81
+ const f = fields.find((x) => x.path === path);
82
+ if (f) return f;
83
+ return { path, label: path, type: "text" };
81
84
  }
82
- return display[0] ?? { path: resolvedTree.idField, type: "text" }
83
- }, [resolvedTree, fields, display])
85
+ return display[0] ?? { path: resolvedTree.idField, type: "text" };
86
+ }, [resolvedTree, fields, display]);
84
87
 
85
88
  const fullForest = useMemo(
86
89
  () => buildTree(data, resolvedTree),
87
90
  [data, resolvedTree],
88
- )
91
+ );
89
92
 
90
- const filterEntries = useMemo(() => Object.entries(activeFilters), [activeFilters])
93
+ const filterEntries = useMemo(
94
+ () => Object.entries(activeFilters),
95
+ [activeFilters],
96
+ );
91
97
 
92
98
  const visibleForest: TreeNode[] = useMemo(() => {
93
- if (filterEntries.length === 0) return fullForest
99
+ if (filterEntries.length === 0) return fullForest;
94
100
  return pruneTree(fullForest, (record) =>
95
- filterEntries.every(([path, value]) => matchesFilterValues(record, path, value)),
96
- )
97
- }, [fullForest, filterEntries])
101
+ filterEntries.every(([path, value]) =>
102
+ matchesFilterValues(record, path, value),
103
+ ),
104
+ );
105
+ }, [fullForest, filterEntries]);
98
106
 
99
107
  const [expanded, setExpanded] = useState<Set<string>>(() =>
100
108
  initialExpansion(fullForest, resolvedTree.defaultExpanded),
101
- )
109
+ );
102
110
 
103
111
  useEffect(() => {
104
- setExpanded(initialExpansion(fullForest, resolvedTree.defaultExpanded))
105
- }, [fullForest, resolvedTree.defaultExpanded])
112
+ setExpanded(initialExpansion(fullForest, resolvedTree.defaultExpanded));
113
+ }, [fullForest, resolvedTree.defaultExpanded]);
106
114
 
107
- const [selectedId, setSelectedId] = useState<string | null>(() =>
108
- fullForest[0]?.id ?? null,
109
- )
115
+ const [selectedId, setSelectedId] = useState<string | null>(
116
+ () => fullForest[0]?.id ?? null,
117
+ );
110
118
 
111
119
  useEffect(() => {
112
120
  if (selectedId && !findNodeById(visibleForest, selectedId)) {
113
- setSelectedId(visibleForest[0]?.id ?? null)
121
+ setSelectedId(visibleForest[0]?.id ?? null);
114
122
  }
115
- }, [visibleForest, selectedId])
123
+ }, [visibleForest, selectedId]);
116
124
 
117
- const selectedNode = selectedId ? findNodeById(visibleForest, selectedId) : null
125
+ const selectedNode = selectedId
126
+ ? findNodeById(visibleForest, selectedId)
127
+ : null;
118
128
  const recordsForRightPane = useMemo(
119
129
  () => (selectedNode ? flatten(selectedNode) : []),
120
130
  [selectedNode],
121
- )
131
+ );
122
132
 
123
133
  const toggle = (id: string) =>
124
134
  setExpanded((prev) => {
125
- const next = new Set(prev)
126
- if (next.has(id)) next.delete(id)
127
- else next.add(id)
128
- return next
129
- })
135
+ const next = new Set(prev);
136
+ if (next.has(id)) next.delete(id);
137
+ else next.add(id);
138
+ return next;
139
+ });
130
140
 
131
141
  const handleFilterChange = (path: string, value: FilterValue) => {
132
- const next: FilterState = { ...activeFilters, [path]: value }
133
- if (onFilterChange) onFilterChange(next)
134
- else setInternalFilters(next)
135
- }
142
+ const next: FilterState = { ...activeFilters, [path]: value };
143
+ if (onFilterChange) onFilterChange(next);
144
+ else setInternalFilters(next);
145
+ };
136
146
  const clearAllFilters = () => {
137
- if (onFilterChange) onFilterChange({})
138
- else setInternalFilters({})
139
- }
147
+ if (onFilterChange) onFilterChange({});
148
+ else setInternalFilters({});
149
+ };
140
150
 
141
- const [drawerOpen, setDrawerOpen] = useState(false)
151
+ const [drawerOpen, setDrawerOpen] = useState(false);
142
152
 
143
- type RightPaneMode = "table" | "details"
153
+ type RightPaneMode = "table" | "card";
144
154
  const [rightPaneMode, setRightPaneMode] = useState<RightPaneMode>(
145
- treeConfig?.defaultRightPane ?? "table",
146
- )
155
+ // "details" is a deprecated alias of "card".
156
+ treeConfig?.defaultRightPane === "details"
157
+ ? "card"
158
+ : (treeConfig?.defaultRightPane ?? "table"),
159
+ );
147
160
 
148
- const dndEnabled = treeConfig?.dndEnabled !== false && !!onDataUpdate
161
+ const dndEnabled = treeConfig?.dndEnabled !== false && !!onDataUpdate;
149
162
 
150
163
  const handleMove = ({
151
164
  dragIds,
152
165
  parentId,
153
166
  index,
154
167
  }: {
155
- dragIds: string[]
156
- parentId: string | null
157
- index: number
168
+ dragIds: string[];
169
+ parentId: string | null;
170
+ index: number;
158
171
  }) => {
159
- if (!onDataUpdate) return
160
- const next = applyMove(data, resolvedTree, { dragIds, parentId, index })
161
- onDataUpdate(next)
162
- }
172
+ if (!onDataUpdate) return;
173
+ const next = applyMove(data, resolvedTree, { dragIds, parentId, index });
174
+ onDataUpdate(next);
175
+ };
163
176
 
164
177
  const treeContent = (
165
178
  <TreeSidebar
@@ -170,29 +183,25 @@ export function TreeView({
170
183
  dndEnabled={dndEnabled}
171
184
  onToggle={toggle}
172
185
  onSelect={(id) => {
173
- setSelectedId(id)
174
- if (isMobile) setDrawerOpen(false)
186
+ setSelectedId(id);
187
+ if (isMobile) setDrawerOpen(false);
175
188
  }}
176
189
  onMove={handleMove}
177
190
  />
178
- )
179
-
180
- const filtersEnabled = showFilters && config.showFilters !== false
191
+ );
181
192
 
182
- const fallbackColumns: DynamicColumnConfig[] = columns ?? fields.map((f, i) => ({
183
- id: f.path,
184
- label: f.label ?? f.path,
185
- visible: f.visible !== false && f.type !== "hidden",
186
- order: f.order ?? i,
187
- }))
193
+ const filtersEnabled = showFilters && config.showFilters !== false;
188
194
 
189
195
  return (
190
- <div className="flex h-full bg-background-presentation-body-primary">
196
+ <div className="flex h-full gap-2">
191
197
  {!isMobile && (
192
- <div className="w-64 border-r border-border-presentation-global-primary bg-background-presentation-body-overlay-primary flex flex-col">
198
+ <div className="w-64 rounded-[16px] border border-border-presentation-global-primary bg-background-presentation-form-base overflow-hidden flex flex-col">
193
199
  <div className="px-3 py-2 border-b border-border-presentation-global-primary">
194
- <span className="text-xs font-semibold text-content-presentation-global-secondary uppercase tracking-wide">
195
- Tree
200
+ <span
201
+ style={{ fontFeatureSettings: "'cv05' on" }}
202
+ className="typography-display-medium-medium uppercase text-content-presentation-global-primary"
203
+ >
204
+ categories
196
205
  </span>
197
206
  </div>
198
207
  <div className="flex-1 overflow-hidden">{treeContent}</div>
@@ -210,50 +219,67 @@ export function TreeView({
210
219
  />
211
220
  )}
212
221
 
213
- <div className="flex-1 flex flex-col overflow-hidden">
214
- <div className="flex items-center gap-2 px-3 py-2 border-b border-border-presentation-global-primary bg-background-presentation-body-primary">
215
- {isMobile && <TreeDrawerTrigger onClick={() => setDrawerOpen(true)} />}
216
-
217
- <div className="flex items-center gap-1 rounded-md border border-border-presentation-global-primary bg-background-presentation-body-overlay-primary p-0.5">
218
- <button
219
- type="button"
220
- onClick={() => setRightPaneMode("table")}
221
- aria-label="Table mode"
222
- aria-pressed={rightPaneMode === "table"}
223
- className={cn(
224
- "inline-flex items-center gap-1 px-2 py-1 rounded text-xs",
225
- rightPaneMode === "table"
226
- ? "bg-content-presentation-action-primary text-white"
227
- : "text-content-presentation-global-secondary hover:text-content-presentation-global-primary",
228
- )}
222
+ <div className="flex-1 flex flex-col overflow-hidden rounded-[16px] border border-border-presentation-global-primary bg-background-presentation-form-base">
223
+ <div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border-presentation-global-primary bg-background-presentation-form-base">
224
+ <div className="flex items-center gap-2 min-w-0">
225
+ {isMobile && (
226
+ <TreeDrawerTrigger onClick={() => setDrawerOpen(true)} />
227
+ )}
228
+ <span
229
+ style={{ fontFeatureSettings: "'cv05' on" }}
230
+ className="typography-display-medium-medium uppercase text-content-presentation-global-primary truncate"
229
231
  >
230
- <Table2 className="h-3.5 w-3.5" />
231
- Table
232
- </button>
233
- <button
234
- type="button"
235
- onClick={() => setRightPaneMode("details")}
236
- aria-label="Details mode"
237
- aria-pressed={rightPaneMode === "details"}
238
- className={cn(
239
- "inline-flex items-center gap-1 px-2 py-1 rounded text-xs",
240
- rightPaneMode === "details"
241
- ? "bg-content-presentation-action-primary text-white"
242
- : "text-content-presentation-global-secondary hover:text-content-presentation-global-primary",
243
- )}
244
- >
245
- <FileText className="h-3.5 w-3.5" />
246
- Details
247
- </button>
232
+ {selectedNode
233
+ ? String(getByPath(selectedNode.record, labelField.path) ?? "Items")
234
+ : "Items"}
235
+ </span>
236
+ <div className="h-6 w-px bg-border-presentation-global-primary shrink-0" />
237
+ <span className="text-sm text-content-presentation-global-secondary truncate">
238
+ {selectedNode
239
+ ? `${recordsForRightPane.length} record${recordsForRightPane.length === 1 ? "" : "s"}`
240
+ : "Select an item"}
241
+ </span>
248
242
  </div>
249
243
 
250
- <span className="ml-auto text-sm text-content-presentation-global-secondary truncate">
251
- {selectedNode
252
- ? rightPaneMode === "table"
253
- ? `${recordsForRightPane.length} record${recordsForRightPane.length === 1 ? "" : "s"}`
254
- : `1 record`
255
- : "Select an item"}
256
- </span>
244
+ {/* Segmented switcher same style as the main view switcher
245
+ (DataViewsHeader): #252729 track, white active pill, divider
246
+ between two inactive tabs only. */}
247
+ <div className="flex items-center gap-[2px] rounded-[10px] bg-background-presentation-body-primary p-[2px] shadow-[inset_0_0_4px_0_rgba(0,0,0,0.08)] shrink-0">
248
+ {(
249
+ [
250
+ { id: "table", label: "List", icon: <Table2 /> },
251
+ { id: "card", label: "Cards", icon: <LayoutGrid /> },
252
+ ] as const
253
+ ).map((tab, idx) => {
254
+ const active = rightPaneMode === tab.id;
255
+ const prevActive = idx > 0 && rightPaneMode === "table";
256
+ const showDivider = idx > 0 && !active && !prevActive;
257
+ return (
258
+ <div key={tab.id} className="flex items-center">
259
+ {showDivider && (
260
+ <div className="mx-[3px] h-3 w-px bg-[#434446]" />
261
+ )}
262
+ <button
263
+ type="button"
264
+ aria-label={`${tab.label} mode`}
265
+ aria-pressed={active}
266
+ onClick={() => setRightPaneMode(tab.id)}
267
+ className={cn(
268
+ "flex h-6 items-center gap-[6px] rounded-[8px] px-3 text-[14px] font-[510] leading-none transition-all duration-200 ease-in-out",
269
+ active
270
+ ? "bg-white text-black shadow-[0_0_10px_2px_rgba(0,0,0,0.25)]"
271
+ : "bg-transparent text-content-presentation-global-primary hover:bg-white/5",
272
+ )}
273
+ >
274
+ <span className="flex h-[14px] w-[14px] items-center justify-center [&_svg]:h-[14px] [&_svg]:w-[14px]">
275
+ {tab.icon}
276
+ </span>
277
+ <span className="max-w-[80px] truncate">{tab.label}</span>
278
+ </button>
279
+ </div>
280
+ );
281
+ })}
282
+ </div>
257
283
  </div>
258
284
 
259
285
  <div className="flex-1 overflow-hidden">
@@ -268,16 +294,15 @@ export function TreeView({
268
294
  filters={filterConfig}
269
295
  filterState={activeFilters}
270
296
  onFilterChange={(next) => {
271
- if (onFilterChange) onFilterChange(next)
272
- else setInternalFilters(next)
297
+ if (onFilterChange) onFilterChange(next);
298
+ else setInternalFilters(next);
273
299
  }}
274
300
  showFilters={false}
275
301
  />
276
302
  ) : (
277
- <DetailsBody
278
- node={selectedNode}
303
+ <CardGrid
304
+ records={recordsForRightPane}
279
305
  fields={fields}
280
- columns={fallbackColumns}
281
306
  labelField={labelField}
282
307
  />
283
308
  )
@@ -295,69 +320,73 @@ export function TreeView({
295
320
  </TreeDrawer>
296
321
  )}
297
322
  </div>
298
- )
323
+ );
299
324
  }
300
325
 
301
- function DetailsBody({
302
- node,
326
+ /**
327
+ * Card mode for the Tree right pane: renders the same record set as Table
328
+ * mode, one library <Card> per record. The label field is the card header;
329
+ * the remaining visible fields are key/value rows in the card body.
330
+ */
331
+ function CardGrid({
332
+ records,
303
333
  fields,
304
- columns,
305
334
  labelField,
306
335
  }: {
307
- node: TreeNode
308
- fields: FieldConfig[]
309
- columns: DynamicColumnConfig[]
310
- labelField: FieldConfig
336
+ records: DynamicRecord[];
337
+ fields: FieldConfig[];
338
+ labelField: FieldConfig;
311
339
  }) {
312
- const record = node.record
313
- const labelValue = getByPath(record, labelField.path)
314
- const displayFields = visibleFields(fields)
340
+ const bodyFields = visibleFields(fields)
315
341
  .filter((f) => f.path !== labelField.path)
316
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
342
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
317
343
 
318
- return (
319
- <div className="h-full overflow-y-auto p-6 bg-background-presentation-body-primary">
320
- <div className="max-w-3xl mx-auto space-y-6">
321
- <div className="space-y-1">
322
- <div className="text-xs uppercase tracking-wide text-content-presentation-global-tertiary">
323
- {labelField.label ?? labelField.path}
324
- </div>
325
- <h2 className="text-2xl font-semibold text-content-presentation-global-primary">
326
- {renderField(labelValue, labelField, record)}
327
- </h2>
328
- </div>
344
+ if (records.length === 0) {
345
+ return (
346
+ <div className="h-full flex items-center justify-center text-sm text-content-presentation-global-tertiary">
347
+ No records.
348
+ </div>
349
+ );
350
+ }
329
351
 
330
- {displayFields.length > 0 && (
331
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-2 border-b border-border-presentation-global-primary">
332
- {displayFields.map((f) => {
333
- const value = getByPath(record, f.path)
334
- if (value == null) return null
335
- return (
336
- <div key={f.path} className="space-y-1">
337
- <dt className="text-xs font-medium uppercase tracking-wide text-content-presentation-global-tertiary">
338
- {f.label ?? f.path}
339
- </dt>
340
- <dd className="text-sm text-content-presentation-global-primary">
341
- {renderField(value, f, record)}
342
- </dd>
352
+ return (
353
+ <div className="h-full overflow-y-auto p-4 bg-background-presentation-body-primary">
354
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
355
+ {records.map((record, idx) => {
356
+ const labelValue = getByPath(record, labelField.path);
357
+ return (
358
+ <Card key={record.id ?? idx} className="overflow-hidden">
359
+ <CardHeader className="pb-2">
360
+ <div className="text-xs uppercase tracking-wide text-content-presentation-global-tertiary">
361
+ {labelField.label ?? labelField.path}
343
362
  </div>
344
- )
345
- })}
346
- </div>
347
- )}
348
-
349
- <div>
350
- {renderDetailView(
351
- record,
352
- columns.filter((c) => c.visible),
353
- (value, column, row) => {
354
- const f = fields.find((x) => x.path === column.id)
355
- if (f) return renderField(value, f, row)
356
- return <span>{String(value ?? "")}</span>
357
- },
358
- )}
359
- </div>
363
+ <div className="text-base font-semibold text-content-presentation-global-primary">
364
+ {renderField(labelValue, labelField, record)}
365
+ </div>
366
+ </CardHeader>
367
+ <CardContent className="space-y-2 pt-0">
368
+ {bodyFields.map((f) => {
369
+ const value = getByPath(record, f.path);
370
+ if (value == null) return null;
371
+ return (
372
+ <div
373
+ key={f.path}
374
+ className="flex items-center justify-between gap-3 text-sm"
375
+ >
376
+ <span className="text-content-presentation-global-tertiary">
377
+ {f.label ?? f.path}
378
+ </span>
379
+ <span className="text-content-presentation-global-primary text-right">
380
+ {renderField(value, f, record)}
381
+ </span>
382
+ </div>
383
+ );
384
+ })}
385
+ </CardContent>
386
+ </Card>
387
+ );
388
+ })}
360
389
  </div>
361
390
  </div>
362
- )
391
+ );
363
392
  }
@@ -10,9 +10,15 @@ export type { KanbanViewProps } from "./KanbanView"
10
10
  export { InboxView } from "./InboxView"
11
11
  export type { InboxViewProps } from "./InboxView"
12
12
 
13
+ export { InboxViewCard } from "./InboxViewCard"
14
+ export type { InboxViewCardProps } from "./InboxViewCard"
15
+
13
16
  export { TreeView } from "./TreeView"
14
17
  export type { TreeViewProps } from "./TreeView"
15
18
 
19
+ export { DataViewRadio } from "./DataViewRadio"
20
+ export type { DataViewRadioProps } from "./DataViewRadio"
21
+
16
22
  export { FilterPanel } from "./FilterPanel"
17
23
  export { SettingsPanel } from "./SettingsPanel"
18
24
  export { DataViewsHeader } from "./DataViewsHeader"