create-davepi-ui 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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/bin/index.js +229 -0
  4. package/bin/sync-templates.js +100 -0
  5. package/package.json +40 -0
  6. package/templates/default/.env.example +1 -0
  7. package/templates/default/index.html +13 -0
  8. package/templates/default/package.json +49 -0
  9. package/templates/default/postcss.config.cjs +6 -0
  10. package/templates/default/src/App.tsx +42 -0
  11. package/templates/default/src/components/AppShell.tsx +23 -0
  12. package/templates/default/src/components/BulkActionBar.tsx +47 -0
  13. package/templates/default/src/components/RelatedCreateModal.tsx +91 -0
  14. package/templates/default/src/components/RelatedList.tsx +70 -0
  15. package/templates/default/src/components/ResourceForm.tsx +311 -0
  16. package/templates/default/src/components/ResourceTable.tsx +475 -0
  17. package/templates/default/src/components/RowActions.tsx +54 -0
  18. package/templates/default/src/components/Sidebar.tsx +171 -0
  19. package/templates/default/src/components/ui/button.tsx +43 -0
  20. package/templates/default/src/components/ui/card.tsx +47 -0
  21. package/templates/default/src/components/ui/checkbox.tsx +24 -0
  22. package/templates/default/src/components/ui/command.tsx +117 -0
  23. package/templates/default/src/components/ui/dialog.tsx +95 -0
  24. package/templates/default/src/components/ui/dropdown-menu.tsx +78 -0
  25. package/templates/default/src/components/ui/input.tsx +18 -0
  26. package/templates/default/src/components/ui/label.tsx +17 -0
  27. package/templates/default/src/components/ui/popover.tsx +27 -0
  28. package/templates/default/src/components/ui/select.tsx +83 -0
  29. package/templates/default/src/components/ui/switch.tsx +21 -0
  30. package/templates/default/src/components/ui/table.tsx +66 -0
  31. package/templates/default/src/components/ui/tabs.tsx +53 -0
  32. package/templates/default/src/components/ui/textarea.tsx +17 -0
  33. package/templates/default/src/davepi-ui.config.ts +14 -0
  34. package/templates/default/src/index.css +55 -0
  35. package/templates/default/src/lib/utils.ts +10 -0
  36. package/templates/default/src/main.tsx +34 -0
  37. package/templates/default/src/pages/DashboardPage.tsx +42 -0
  38. package/templates/default/src/pages/LoginScreen.tsx +77 -0
  39. package/templates/default/src/pages/ResourceCreatePage.tsx +58 -0
  40. package/templates/default/src/pages/ResourceDetailPage.tsx +171 -0
  41. package/templates/default/src/pages/ResourceEditPage.tsx +52 -0
  42. package/templates/default/src/pages/ResourceListPage.tsx +8 -0
  43. package/templates/default/src/resourceOverrides.ts +34 -0
  44. package/templates/default/src/resources/account.ts +25 -0
  45. package/templates/default/src/resources/category.ts +7 -0
  46. package/templates/default/src/resources/contact.ts +40 -0
  47. package/templates/default/src/resources/product.ts +7 -0
  48. package/templates/default/src/resources/project.ts +7 -0
  49. package/templates/default/src/resources/quote.ts +12 -0
  50. package/templates/default/src/vite-env.d.ts +9 -0
  51. package/templates/default/src/widgets/CurrencyInput.tsx +44 -0
  52. package/templates/default/src/widgets/DateInput.tsx +36 -0
  53. package/templates/default/src/widgets/EmailInput.tsx +28 -0
  54. package/templates/default/src/widgets/EnumSelect.tsx +35 -0
  55. package/templates/default/src/widgets/FileUploaderStub.tsx +9 -0
  56. package/templates/default/src/widgets/JsonEditor.tsx +64 -0
  57. package/templates/default/src/widgets/NumberInput.tsx +36 -0
  58. package/templates/default/src/widgets/RelationPicker.tsx +349 -0
  59. package/templates/default/src/widgets/SwitchWidget.tsx +20 -0
  60. package/templates/default/src/widgets/TagInput.tsx +83 -0
  61. package/templates/default/src/widgets/TextAreaWidget.tsx +27 -0
  62. package/templates/default/src/widgets/TextInput.tsx +32 -0
  63. package/templates/default/src/widgets/UrlInput.tsx +27 -0
  64. package/templates/default/src/widgets/registry.ts +51 -0
  65. package/templates/default/src/widgets/types.ts +26 -0
  66. package/templates/default/tailwind.config.ts +54 -0
  67. package/templates/default/tsconfig.json +40 -0
  68. package/templates/default/vite.config.ts +16 -0
  69. package/templates/pinned-versions.json +5 -0
@@ -0,0 +1,475 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
3
+ import { ArrowDown, ArrowUp, ChevronsUpDown, Plus, Search } from 'lucide-react';
4
+ import {
5
+ useDeleteResource,
6
+ useDescribe,
7
+ useResourceList,
8
+ useResourceConfig,
9
+ useResourcePerm,
10
+ } from '@davepi/ui-react';
11
+ import {
12
+ Table,
13
+ TableBody,
14
+ TableCell,
15
+ TableHead,
16
+ TableHeader,
17
+ TableRow,
18
+ } from '@/components/ui/table';
19
+ import { Input } from '@/components/ui/input';
20
+ import { Button } from '@/components/ui/button';
21
+ import { Checkbox } from '@/components/ui/checkbox';
22
+ import { labelize, type descriptor } from '@davepi/ui-core';
23
+ import { cn } from '@/lib/utils';
24
+ import { RowActions } from './RowActions';
25
+ import { BulkActionBar } from './BulkActionBar';
26
+
27
+ /**
28
+ * Schema-driven resource list table.
29
+ *
30
+ * Three layers compose to produce the table:
31
+ * 1. `/_describe` — types, sortable hints, search affordance.
32
+ * 2. Consumer config (`davepi-ui.config.ts` + per-resource override file)
33
+ * — `listColumns`, `actions.row`, `actions.bulk`, `permissions`.
34
+ * 3. Inline JSX props — last-mile overrides on `columns`/`filters`.
35
+ *
36
+ * Selection is opt-in: appears when at least one bulk action is configured.
37
+ * Row actions menu (`...`) appears when at least one row action is configured.
38
+ * Both menus and the "New" button respect `permissions.create/delete` and
39
+ * the live JWT roles.
40
+ *
41
+ * @example
42
+ * <ResourceTable resourcePath="account" />
43
+ */
44
+ export interface ResourceTableProps {
45
+ resourcePath: string;
46
+ /** Override the default first-N columns. Wins over consumer config. */
47
+ columns?: string[] | descriptor.ColumnSpec[];
48
+ /** Hidden filters merged into every list call. */
49
+ filters?: Record<string, unknown>;
50
+ /**
51
+ * Embedded mode: hides the page-level header (title, search bar, "New")
52
+ * so the table can sit inside another container (typically `<RelatedList>`).
53
+ * Standalone mode (default) renders the full chrome.
54
+ */
55
+ embedded?: boolean;
56
+ /**
57
+ * When true (default in standalone mode), filter params from the URL
58
+ * (e.g. `?accountId=xxx`) are merged into the list query. Lets a child
59
+ * list page deep-link back from a parent detail.
60
+ */
61
+ readUrlFilters?: boolean;
62
+ }
63
+
64
+ const STAMPED = new Set(['_id', '__v', 'createdAt', 'updatedAt', 'deletedAt', 'userId', 'accountId']);
65
+
66
+ interface NormalizedColumn {
67
+ field: string;
68
+ label: string;
69
+ format?: string;
70
+ }
71
+
72
+ export function ResourceTable({
73
+ resourcePath,
74
+ columns,
75
+ filters,
76
+ embedded = false,
77
+ readUrlFilters,
78
+ }: ResourceTableProps) {
79
+ const { data: describe } = useDescribe();
80
+ const config = useResourceConfig(resourcePath);
81
+ const createPerm = useResourcePerm(resourcePath, 'create');
82
+ const deletePerm = useResourcePerm(resourcePath, 'delete');
83
+ const remove = useDeleteResource(resourcePath);
84
+ const navigate = useNavigate();
85
+
86
+ const [page, setPage] = useState(1);
87
+ const [search, setSearch] = useState('');
88
+ const [sort, setSort] = useState<{ field: string; dir: 'asc' | 'desc' } | null>(null);
89
+ const [selected, setSelected] = useState<Set<string>>(() => new Set());
90
+ const [searchParams] = useSearchParams();
91
+
92
+ const shouldReadUrl = readUrlFilters ?? !embedded;
93
+ const urlFilters = useMemo(() => {
94
+ if (!shouldReadUrl) return undefined;
95
+ const out: Record<string, unknown> = {};
96
+ for (const [k, v] of searchParams.entries()) {
97
+ if (k.startsWith('__')) continue;
98
+ out[k] = v;
99
+ }
100
+ return Object.keys(out).length ? out : undefined;
101
+ }, [searchParams, shouldReadUrl]);
102
+
103
+ const mergedFilters = useMemo(
104
+ () => ({ ...(urlFilters ?? {}), ...(filters ?? {}) }),
105
+ [filters, urlFilters]
106
+ );
107
+
108
+ const sortParam = sort ? `${sort.field}:${sort.dir}` : undefined;
109
+
110
+ const list = useResourceList<Record<string, unknown>>(resourcePath, {
111
+ params: {
112
+ page,
113
+ q: search || undefined,
114
+ sort: sortParam,
115
+ filter: Object.keys(mergedFilters).length ? mergedFilters : undefined,
116
+ },
117
+ });
118
+
119
+ const entry = describe?.registry.get(resourcePath);
120
+ const display = describe?.registry.display(resourcePath);
121
+
122
+ const effectiveColumns = useMemo<NormalizedColumn[]>(() => {
123
+ const fromProp = normalizeColumns(columns);
124
+ if (fromProp) return fromProp;
125
+ const fromConfig = normalizeColumns(config.listColumns);
126
+ if (fromConfig) return fromConfig;
127
+ if (!entry) return [];
128
+ return entry.fields
129
+ .filter((f) => !STAMPED.has(f.name))
130
+ .slice(0, 5)
131
+ .map((f) => ({
132
+ field: f.name,
133
+ label: labelize(f.name, { stripIdSuffix: f.name.endsWith('Id') }),
134
+ }));
135
+ }, [columns, config.listColumns, entry]);
136
+
137
+ const rowActions = config.actions?.row ?? DEFAULT_ROW_ACTIONS(deletePerm.allowed);
138
+ const bulkActions = config.actions?.bulk ?? [];
139
+ const selectionEnabled = bulkActions.length > 0;
140
+ const searchable = entry?.features.search ?? [];
141
+
142
+ function toggleSort(field: string) {
143
+ setSort((prev) => {
144
+ if (!prev || prev.field !== field) return { field, dir: 'asc' };
145
+ if (prev.dir === 'asc') return { field, dir: 'desc' };
146
+ return null;
147
+ });
148
+ }
149
+
150
+ function clearSelection() {
151
+ setSelected(new Set());
152
+ }
153
+
154
+ function toggleRow(id: string) {
155
+ setSelected((prev) => {
156
+ const next = new Set(prev);
157
+ if (next.has(id)) next.delete(id);
158
+ else next.add(id);
159
+ return next;
160
+ });
161
+ }
162
+
163
+ function toggleAll(rows: readonly Record<string, unknown>[]) {
164
+ const allIds = rows.map((r) => String(r._id ?? ''));
165
+ const allSelected = allIds.every((id) => selected.has(id));
166
+ if (allSelected) {
167
+ setSelected((prev) => {
168
+ const next = new Set(prev);
169
+ for (const id of allIds) next.delete(id);
170
+ return next;
171
+ });
172
+ } else {
173
+ setSelected((prev) => {
174
+ const next = new Set(prev);
175
+ for (const id of allIds) next.add(id);
176
+ return next;
177
+ });
178
+ }
179
+ }
180
+
181
+ async function runRowAction(
182
+ action: descriptor.ActionSpec,
183
+ record: Record<string, unknown>
184
+ ): Promise<void> {
185
+ if (action.kind === 'navigate' && action.to) {
186
+ const id = String(record._id ?? '');
187
+ navigate(action.to.replace('{id}', id).replace('{path}', resourcePath));
188
+ return;
189
+ }
190
+ if (action.id === '__delete__') {
191
+ const id = String(record._id ?? '');
192
+ if (!confirm('Delete this record?')) return;
193
+ await remove.mutateAsync(id);
194
+ return;
195
+ }
196
+ if (action.id === '__edit__') {
197
+ navigate(`/r/${resourcePath}/${record._id}/edit`);
198
+ return;
199
+ }
200
+ }
201
+
202
+ function runBulkAction(action: descriptor.ActionSpec, ids: readonly string[]): void {
203
+ if (action.kind === 'bulkDelete') {
204
+ if (!confirm(`Delete ${ids.length} record(s)?`)) return;
205
+ void Promise.all(ids.map((id) => remove.mutateAsync(id))).then(clearSelection);
206
+ return;
207
+ }
208
+ // Custom actions handled by the consumer through the run callback they
209
+ // pass into their ActionSpec — for M3 we surface the noop and let
210
+ // future work plumb a richer dispatch.
211
+ console.warn('[davepi-ui] bulk action not wired:', action.id);
212
+ }
213
+
214
+ if (!describe || !entry || !display) {
215
+ return <p className="text-muted-foreground">Loading…</p>;
216
+ }
217
+
218
+ const visibleRows = list.data?.results ?? [];
219
+ const allSelected =
220
+ visibleRows.length > 0 && visibleRows.every((r) => selected.has(String(r._id ?? '')));
221
+
222
+ return (
223
+ <div className="space-y-4">
224
+ {!embedded ? (
225
+ <header className="flex items-end justify-between gap-3">
226
+ <div>
227
+ <h1 className="text-2xl font-semibold tracking-tight">
228
+ {config.pluralLabel ?? display.pluralLabel}
229
+ </h1>
230
+ <p className="text-sm text-muted-foreground">
231
+ <code>{entry.path}</code>
232
+ </p>
233
+ </div>
234
+ <div className="flex items-center gap-2">
235
+ {searchable.length ? (
236
+ <div className="relative">
237
+ <Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
238
+ <Input
239
+ value={search}
240
+ onChange={(e) => {
241
+ setPage(1);
242
+ setSearch(e.target.value);
243
+ }}
244
+ placeholder={`Search ${(config.pluralLabel ?? display.pluralLabel).toLowerCase()}…`}
245
+ className="w-64 pl-8"
246
+ />
247
+ </div>
248
+ ) : null}
249
+ {createPerm.allowed ? (
250
+ <Button asChild>
251
+ <Link to={prefillCreateUrl(resourcePath, mergedFilters)}>
252
+ <Plus className="mr-1 h-4 w-4" />
253
+ New {config.label ?? display.label}
254
+ </Link>
255
+ </Button>
256
+ ) : null}
257
+ </div>
258
+ </header>
259
+ ) : null}
260
+ <div className="rounded-md border border-border bg-card">
261
+ <Table>
262
+ <TableHeader>
263
+ <TableRow>
264
+ {selectionEnabled ? (
265
+ <TableHead className="w-10">
266
+ <Checkbox
267
+ checked={allSelected}
268
+ onCheckedChange={() => toggleAll(visibleRows)}
269
+ aria-label="Select all visible rows"
270
+ />
271
+ </TableHead>
272
+ ) : null}
273
+ {effectiveColumns.map((col) => {
274
+ const isSorted = sort?.field === col.field;
275
+ return (
276
+ <TableHead key={col.field}>
277
+ <button
278
+ type="button"
279
+ onClick={() => toggleSort(col.field)}
280
+ className="inline-flex items-center gap-1 hover:text-foreground"
281
+ >
282
+ {col.label}
283
+ {isSorted ? (
284
+ sort?.dir === 'asc' ? (
285
+ <ArrowUp className="h-3.5 w-3.5" />
286
+ ) : (
287
+ <ArrowDown className="h-3.5 w-3.5" />
288
+ )
289
+ ) : (
290
+ <ChevronsUpDown className="h-3.5 w-3.5 opacity-30" />
291
+ )}
292
+ </button>
293
+ </TableHead>
294
+ );
295
+ })}
296
+ {rowActions.length ? <TableHead className="w-10" /> : null}
297
+ </TableRow>
298
+ </TableHeader>
299
+ <TableBody>
300
+ {list.isPending ? (
301
+ <TableRow>
302
+ <TableCell
303
+ colSpan={effectiveColumns.length + (selectionEnabled ? 1 : 0) + (rowActions.length ? 1 : 0)}
304
+ className="text-muted-foreground"
305
+ >
306
+ Loading…
307
+ </TableCell>
308
+ </TableRow>
309
+ ) : null}
310
+ {list.error ? (
311
+ <TableRow>
312
+ <TableCell
313
+ colSpan={effectiveColumns.length + (selectionEnabled ? 1 : 0) + (rowActions.length ? 1 : 0)}
314
+ className="text-destructive"
315
+ >
316
+ {list.error.message}
317
+ </TableCell>
318
+ </TableRow>
319
+ ) : null}
320
+ {!list.isPending && visibleRows.length === 0 ? (
321
+ <TableRow>
322
+ <TableCell
323
+ colSpan={effectiveColumns.length + (selectionEnabled ? 1 : 0) + (rowActions.length ? 1 : 0)}
324
+ className="text-center text-muted-foreground"
325
+ >
326
+ No records.
327
+ </TableCell>
328
+ </TableRow>
329
+ ) : null}
330
+ {visibleRows.map((record) => {
331
+ const id = String(record._id ?? '');
332
+ const isSelected = selected.has(id);
333
+ return (
334
+ <TableRow key={id} className="cursor-pointer" data-state={isSelected ? 'selected' : undefined}>
335
+ {selectionEnabled ? (
336
+ <TableCell onClick={(e) => e.stopPropagation()}>
337
+ <Checkbox
338
+ checked={isSelected}
339
+ onCheckedChange={() => toggleRow(id)}
340
+ aria-label={`Select row ${id}`}
341
+ />
342
+ </TableCell>
343
+ ) : null}
344
+ {effectiveColumns.map((col, idx) => (
345
+ <TableCell key={col.field} className={cn(idx === 0 && 'font-medium')}>
346
+ <Link to={`/r/${resourcePath}/${id}`} className="block w-full">
347
+ {formatCell(getField(record, col.field), col.format)}
348
+ </Link>
349
+ </TableCell>
350
+ ))}
351
+ {rowActions.length ? (
352
+ <TableCell onClick={(e) => e.stopPropagation()}>
353
+ <RowActions
354
+ actions={rowActions}
355
+ record={record}
356
+ onRun={(a, r) => void runRowAction(a, r)}
357
+ />
358
+ </TableCell>
359
+ ) : null}
360
+ </TableRow>
361
+ );
362
+ })}
363
+ </TableBody>
364
+ </Table>
365
+ </div>
366
+ <footer className="flex items-center justify-between text-xs text-muted-foreground">
367
+ <div>
368
+ {list.data
369
+ ? `${list.data.totalResults} total · page ${list.data.page} of ${list.data.totalPages ?? '?'}`
370
+ : null}
371
+ </div>
372
+ <div className="flex items-center gap-2">
373
+ <Button
374
+ variant="outline"
375
+ size="sm"
376
+ disabled={!list.data || (list.data.prevPage ?? 0) < 1}
377
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
378
+ >
379
+ Previous
380
+ </Button>
381
+ <Button
382
+ variant="outline"
383
+ size="sm"
384
+ disabled={!list.data?.nextPage}
385
+ onClick={() => setPage((p) => p + 1)}
386
+ >
387
+ Next
388
+ </Button>
389
+ </div>
390
+ </footer>
391
+ {selectionEnabled ? (
392
+ <BulkActionBar
393
+ selectedIds={Array.from(selected)}
394
+ actions={bulkActions}
395
+ resourceLabel={config.pluralLabel ?? display.pluralLabel}
396
+ onClear={clearSelection}
397
+ onRun={runBulkAction}
398
+ />
399
+ ) : null}
400
+ </div>
401
+ );
402
+ }
403
+
404
+ function normalizeColumns(
405
+ raw: ResourceTableProps['columns'] | descriptor.ColumnSpec[] | undefined
406
+ ): NormalizedColumn[] | null {
407
+ if (!raw || !raw.length) return null;
408
+ return raw.map((col) => {
409
+ if (typeof col === 'string') {
410
+ return {
411
+ field: col,
412
+ label: labelize(col, { stripIdSuffix: col.endsWith('Id') }),
413
+ };
414
+ }
415
+ return {
416
+ field: col.field,
417
+ label:
418
+ col.label ?? labelize(col.field, { stripIdSuffix: col.field.endsWith('Id') }),
419
+ format: col.format,
420
+ };
421
+ });
422
+ }
423
+
424
+ function getField(record: Record<string, unknown>, fieldPath: string): unknown {
425
+ if (!fieldPath.includes('.')) return record[fieldPath];
426
+ return fieldPath.split('.').reduce<unknown>((acc, segment) => {
427
+ if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[segment];
428
+ return undefined;
429
+ }, record);
430
+ }
431
+
432
+ function formatCell(value: unknown, format?: string): string {
433
+ if (value == null) return '—';
434
+ if (format?.startsWith('currency:')) {
435
+ const code = format.slice('currency:'.length);
436
+ const num = Number(value);
437
+ if (Number.isFinite(num)) {
438
+ try {
439
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(num);
440
+ } catch {
441
+ return `${code} ${num}`;
442
+ }
443
+ }
444
+ }
445
+ if (format === 'date') {
446
+ const date = value instanceof Date ? value : new Date(String(value));
447
+ if (!Number.isNaN(date.getTime())) return date.toLocaleDateString();
448
+ }
449
+ if (Array.isArray(value)) return value.length ? value.join(', ') : '—';
450
+ if (typeof value === 'object') return JSON.stringify(value);
451
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
452
+ return String(value);
453
+ }
454
+
455
+ function DEFAULT_ROW_ACTIONS(canDelete: boolean): descriptor.ActionSpec[] {
456
+ const out: descriptor.ActionSpec[] = [
457
+ { id: '__edit__', label: 'Edit', kind: 'custom' },
458
+ ];
459
+ if (canDelete) out.push({ id: '__delete__', label: 'Delete', kind: 'custom' });
460
+ return out;
461
+ }
462
+
463
+ /**
464
+ * Carry active filters into the create URL so a child opened from a
465
+ * filtered list inherits the parent FK pre-stamped.
466
+ */
467
+ function prefillCreateUrl(resourcePath: string, filters: Record<string, unknown>): string {
468
+ const entries = Object.entries(filters).filter(([k, v]) => v != null && k !== '__page');
469
+ if (!entries.length) return `/r/${resourcePath}/new`;
470
+ const qs = new URLSearchParams();
471
+ for (const [k, v] of entries) {
472
+ qs.set(`prefill_${k}`, String(v));
473
+ }
474
+ return `/r/${resourcePath}/new?${qs.toString()}`;
475
+ }
@@ -0,0 +1,54 @@
1
+ import { MoreHorizontal } from 'lucide-react';
2
+ import type { descriptor } from '@davepi/ui-core';
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from '@/components/ui/dropdown-menu';
10
+
11
+ /**
12
+ * Per-row action menu. Renders a "three dots" trigger that opens a
13
+ * Radix dropdown of `ActionSpec` items. Each item dispatches via the
14
+ * `onRun` callback so the parent table can choose how to execute
15
+ * (mutation, navigate, etc.).
16
+ */
17
+ export interface RowActionsProps {
18
+ actions: descriptor.ActionSpec[];
19
+ record: Record<string, unknown>;
20
+ onRun: (action: descriptor.ActionSpec, record: Record<string, unknown>) => void;
21
+ }
22
+
23
+ export function RowActions({ actions, record, onRun }: RowActionsProps) {
24
+ if (!actions.length) return null;
25
+ return (
26
+ <DropdownMenu>
27
+ <DropdownMenuTrigger asChild>
28
+ <Button
29
+ type="button"
30
+ variant="ghost"
31
+ size="icon"
32
+ className="h-8 w-8 data-[state=open]:bg-muted"
33
+ onClick={(e) => e.stopPropagation()}
34
+ >
35
+ <MoreHorizontal className="h-4 w-4" />
36
+ <span className="sr-only">Open menu</span>
37
+ </Button>
38
+ </DropdownMenuTrigger>
39
+ <DropdownMenuContent align="end">
40
+ {actions.map((action) => (
41
+ <DropdownMenuItem
42
+ key={action.id}
43
+ onSelect={(e) => {
44
+ e.preventDefault();
45
+ onRun(action, record);
46
+ }}
47
+ >
48
+ {action.label}
49
+ </DropdownMenuItem>
50
+ ))}
51
+ </DropdownMenuContent>
52
+ </DropdownMenu>
53
+ );
54
+ }
@@ -0,0 +1,171 @@
1
+ import { useMemo } from 'react';
2
+ import { Link, useLocation } from 'react-router-dom';
3
+ import {
4
+ useDavepiConfig,
5
+ useDescribe,
6
+ useResourcePerm,
7
+ } from '@davepi/ui-react';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ /**
11
+ * Sidebar nav derived from the live `/_describe` manifest, with
12
+ * consumer-supplied config applied on top:
13
+ *
14
+ * - Resources grouped by `config.resources[path].category`. Order
15
+ * respects `config.categoryOrder` first, then alphabetical.
16
+ * - Labels prefer the consumer config's `label`/`pluralLabel`, falling
17
+ * back to backend hints, falling back to title-cased path.
18
+ * - Resources excluded by `permissions.list` (consumer config) OR
19
+ * `acl.list` (describe) are hidden — server still enforces.
20
+ */
21
+ export function Sidebar() {
22
+ const { data, isPending, error } = useDescribe();
23
+ const { config } = useDavepiConfig();
24
+ const { pathname } = useLocation();
25
+
26
+ return (
27
+ <aside className="flex w-60 flex-col border-r border-border bg-card">
28
+ <div className="flex h-14 items-center gap-2 border-b border-border px-4 font-semibold">
29
+ {config.branding?.name ?? 'davepi-ui'}
30
+ </div>
31
+ <nav className="flex flex-1 flex-col gap-2 overflow-auto p-3">
32
+ <Link
33
+ to="/"
34
+ className={cn(
35
+ 'rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
36
+ pathname === '/' && 'bg-accent text-accent-foreground'
37
+ )}
38
+ >
39
+ Dashboard
40
+ </Link>
41
+ {isPending ? <div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div> : null}
42
+ {error ? (
43
+ <div className="px-3 py-2 text-xs text-destructive">
44
+ Failed to load schema: {error.message}
45
+ </div>
46
+ ) : null}
47
+ {data ? <SidebarSections currentPath={pathname} /> : null}
48
+ </nav>
49
+ </aside>
50
+ );
51
+ }
52
+
53
+ interface SectionEntry {
54
+ path: string;
55
+ pluralLabel: string;
56
+ }
57
+
58
+ interface Section {
59
+ category: string;
60
+ items: SectionEntry[];
61
+ }
62
+
63
+ function SidebarSections({ currentPath }: { currentPath: string }) {
64
+ const { data } = useDescribe();
65
+ const { config, resolveResource } = useDavepiConfig();
66
+
67
+ const sections = useMemo<Section[]>(() => {
68
+ if (!data) return [];
69
+ const byCategory = new Map<string, SectionEntry[]>();
70
+ for (const path of data.registry.paths()) {
71
+ const display = data.registry.display(path);
72
+ const cfg = resolveResource(path);
73
+ const category = cfg.category ?? '';
74
+ const label = cfg.pluralLabel ?? cfg.label ?? display.pluralLabel;
75
+ const list = byCategory.get(category) ?? [];
76
+ list.push({ path, pluralLabel: label });
77
+ byCategory.set(category, list);
78
+ }
79
+ const orderedCategories = orderCategories(
80
+ Array.from(byCategory.keys()),
81
+ config.categoryOrder
82
+ );
83
+ return orderedCategories.map((category) => ({
84
+ category,
85
+ items: (byCategory.get(category) ?? []).sort((a, b) =>
86
+ a.pluralLabel.localeCompare(b.pluralLabel)
87
+ ),
88
+ }));
89
+ }, [config.categoryOrder, data, resolveResource]);
90
+
91
+ if (!sections.length) return null;
92
+
93
+ return (
94
+ <>
95
+ {sections.map((section) => (
96
+ <SidebarSection
97
+ key={section.category || '__default__'}
98
+ section={section}
99
+ currentPath={currentPath}
100
+ />
101
+ ))}
102
+ </>
103
+ );
104
+ }
105
+
106
+ function SidebarSection({
107
+ section,
108
+ currentPath,
109
+ }: {
110
+ section: Section;
111
+ currentPath: string;
112
+ }) {
113
+ return (
114
+ <div className="flex flex-col gap-0.5">
115
+ {section.category ? (
116
+ <div className="px-3 pt-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
117
+ {section.category}
118
+ </div>
119
+ ) : null}
120
+ {section.items.map((entry) => (
121
+ <SidebarLink
122
+ key={entry.path}
123
+ path={entry.path}
124
+ pluralLabel={entry.pluralLabel}
125
+ currentPath={currentPath}
126
+ />
127
+ ))}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ function SidebarLink({
133
+ path,
134
+ pluralLabel,
135
+ currentPath,
136
+ }: {
137
+ path: string;
138
+ pluralLabel: string;
139
+ currentPath: string;
140
+ }) {
141
+ const perm = useResourcePerm(path, 'list');
142
+ if (!perm.allowed) return null;
143
+ const href = `/r/${path}`;
144
+ const active = currentPath === href || currentPath.startsWith(`${href}/`);
145
+ return (
146
+ <Link
147
+ to={href}
148
+ className={cn(
149
+ 'rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
150
+ active && 'bg-accent text-accent-foreground'
151
+ )}
152
+ >
153
+ {pluralLabel}
154
+ </Link>
155
+ );
156
+ }
157
+
158
+ function orderCategories(found: string[], order: readonly string[] | undefined): string[] {
159
+ const seen = new Set<string>();
160
+ const out: string[] = [];
161
+ if (order) {
162
+ for (const cat of order) {
163
+ if (found.includes(cat) && !seen.has(cat)) {
164
+ out.push(cat);
165
+ seen.add(cat);
166
+ }
167
+ }
168
+ }
169
+ const remaining = found.filter((c) => !seen.has(c)).sort((a, b) => a.localeCompare(b));
170
+ return [...out, ...remaining];
171
+ }