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,53 @@
1
+ import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'react';
2
+ import * as TabsPrimitive from '@radix-ui/react-tabs';
3
+ import { cn } from '@/lib/utils';
4
+
5
+ export const Tabs = TabsPrimitive.Root;
6
+
7
+ export const TabsList = forwardRef<
8
+ ElementRef<typeof TabsPrimitive.List>,
9
+ ComponentPropsWithoutRef<typeof TabsPrimitive.List>
10
+ >(function TabsList({ className, ...props }, ref) {
11
+ return (
12
+ <TabsPrimitive.List
13
+ ref={ref}
14
+ className={cn(
15
+ 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
16
+ className
17
+ )}
18
+ {...props}
19
+ />
20
+ );
21
+ });
22
+
23
+ export const TabsTrigger = forwardRef<
24
+ ElementRef<typeof TabsPrimitive.Trigger>,
25
+ ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
26
+ >(function TabsTrigger({ className, ...props }, ref) {
27
+ return (
28
+ <TabsPrimitive.Trigger
29
+ ref={ref}
30
+ className={cn(
31
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
32
+ className
33
+ )}
34
+ {...props}
35
+ />
36
+ );
37
+ });
38
+
39
+ export const TabsContent = forwardRef<
40
+ ElementRef<typeof TabsPrimitive.Content>,
41
+ ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
42
+ >(function TabsContent({ className, ...props }, ref) {
43
+ return (
44
+ <TabsPrimitive.Content
45
+ ref={ref}
46
+ className={cn(
47
+ 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
48
+ className
49
+ )}
50
+ {...props}
51
+ />
52
+ );
53
+ });
@@ -0,0 +1,17 @@
1
+ import { forwardRef, type TextareaHTMLAttributes } from 'react';
2
+ import { cn } from '@/lib/utils';
3
+
4
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
5
+ function Textarea({ className, ...props }, ref) {
6
+ return (
7
+ <textarea
8
+ ref={ref}
9
+ className={cn(
10
+ 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
11
+ className
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+ );
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from '@davepi/ui-core';
2
+
3
+ /**
4
+ * App-wide configuration for the davepi-ui example app shell.
5
+ *
6
+ * Per-resource overrides live in `src/resources/{path}.ts(x)` and merge
7
+ * deeply on top of these defaults. Inline JSX props on `<ResourceTable>`
8
+ * / `<ResourceForm>` win over everything.
9
+ */
10
+ export default defineConfig({
11
+ apiBaseUrl: (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:4001',
12
+ branding: { name: 'davepi-ui' },
13
+ categoryOrder: ['CRM', 'Catalogue', 'Delivery'],
14
+ });
@@ -0,0 +1,55 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 240 10% 3.9%;
9
+ --card: 0 0% 100%;
10
+ --card-foreground: 240 10% 3.9%;
11
+ --primary: 240 5.9% 10%;
12
+ --primary-foreground: 0 0% 98%;
13
+ --secondary: 240 4.8% 95.9%;
14
+ --secondary-foreground: 240 5.9% 10%;
15
+ --muted: 240 4.8% 95.9%;
16
+ --muted-foreground: 240 3.8% 46.1%;
17
+ --accent: 240 4.8% 95.9%;
18
+ --accent-foreground: 240 5.9% 10%;
19
+ --destructive: 0 84.2% 60.2%;
20
+ --destructive-foreground: 0 0% 98%;
21
+ --border: 240 5.9% 90%;
22
+ --input: 240 5.9% 90%;
23
+ --ring: 240 5.9% 10%;
24
+ --radius: 0.5rem;
25
+ }
26
+
27
+ .dark {
28
+ --background: 240 10% 3.9%;
29
+ --foreground: 0 0% 98%;
30
+ --card: 240 10% 3.9%;
31
+ --card-foreground: 0 0% 98%;
32
+ --primary: 0 0% 98%;
33
+ --primary-foreground: 240 5.9% 10%;
34
+ --secondary: 240 3.7% 15.9%;
35
+ --secondary-foreground: 0 0% 98%;
36
+ --muted: 240 3.7% 15.9%;
37
+ --muted-foreground: 240 5% 64.9%;
38
+ --accent: 240 3.7% 15.9%;
39
+ --accent-foreground: 0 0% 98%;
40
+ --destructive: 0 62.8% 30.6%;
41
+ --destructive-foreground: 0 0% 98%;
42
+ --border: 240 3.7% 15.9%;
43
+ --input: 240 3.7% 15.9%;
44
+ --ring: 240 4.9% 83.9%;
45
+ }
46
+
47
+ * {
48
+ @apply border-border;
49
+ }
50
+
51
+ body {
52
+ @apply bg-background text-foreground;
53
+ font-feature-settings: 'rlig' 1, 'calt' 1;
54
+ }
55
+ }
@@ -0,0 +1,10 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ /**
5
+ * Combine class names with `clsx` + `tailwind-merge` so conflicting
6
+ * Tailwind utilities resolve to the last-specified one.
7
+ */
8
+ export function cn(...inputs: ClassValue[]): string {
9
+ return twMerge(clsx(inputs));
10
+ }
@@ -0,0 +1,34 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
+ import { AuthProvider, ConfigProvider } from '@davepi/ui-react';
6
+ import { App } from './App';
7
+ import davepiConfig from './davepi-ui.config';
8
+ import { resourceOverrides } from './resourceOverrides';
9
+ import './index.css';
10
+
11
+ const queryClient = new QueryClient({
12
+ defaultOptions: {
13
+ queries: {
14
+ retry: 1,
15
+ },
16
+ },
17
+ });
18
+
19
+ const root = document.getElementById('root');
20
+ if (!root) throw new Error('Root element not found');
21
+
22
+ createRoot(root).render(
23
+ <StrictMode>
24
+ <QueryClientProvider client={queryClient}>
25
+ <ConfigProvider config={davepiConfig} resourceOverrides={resourceOverrides}>
26
+ <AuthProvider baseUrl={davepiConfig.apiBaseUrl}>
27
+ <BrowserRouter>
28
+ <App />
29
+ </BrowserRouter>
30
+ </AuthProvider>
31
+ </ConfigProvider>
32
+ </QueryClientProvider>
33
+ </StrictMode>
34
+ );
@@ -0,0 +1,42 @@
1
+ import { useDescribe } from '@davepi/ui-react';
2
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
3
+
4
+ /**
5
+ * Default home view. Shows a high-level breakdown of registered resources.
6
+ * Consumers swap this out via the per-resource override layer (M3).
7
+ */
8
+ export function DashboardPage() {
9
+ const { data, isPending, error } = useDescribe();
10
+ return (
11
+ <div className="space-y-6">
12
+ <div>
13
+ <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
14
+ <p className="text-sm text-muted-foreground">
15
+ Resources discovered from <code>/_describe</code>.
16
+ </p>
17
+ </div>
18
+ {isPending ? <p>Loading schema…</p> : null}
19
+ {error ? <p className="text-destructive">{error.message}</p> : null}
20
+ {data ? (
21
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
22
+ {data.registry.paths().map((p) => {
23
+ const display = data.registry.display(p);
24
+ const entry = data.registry.get(p)!;
25
+ return (
26
+ <Card key={p}>
27
+ <CardHeader>
28
+ <CardTitle>{display.pluralLabel}</CardTitle>
29
+ </CardHeader>
30
+ <CardContent className="text-sm text-muted-foreground">
31
+ <div>Path: <code>{p}</code></div>
32
+ <div>Fields: {entry.fields.length}</div>
33
+ <div>Display field: <code>{display.displayField}</code></div>
34
+ </CardContent>
35
+ </Card>
36
+ );
37
+ })}
38
+ </div>
39
+ ) : null}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState, type FormEvent } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { useAuth } from '@davepi/ui-react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Label } from '@/components/ui/label';
7
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
8
+
9
+ export function LoginScreen() {
10
+ const { login, status } = useAuth();
11
+ const navigate = useNavigate();
12
+ const [email, setEmail] = useState('');
13
+ const [password, setPassword] = useState('');
14
+ const [error, setError] = useState<string | null>(null);
15
+ const [submitting, setSubmitting] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (status === 'authenticated') navigate('/', { replace: true });
19
+ }, [navigate, status]);
20
+
21
+ async function onSubmit(event: FormEvent<HTMLFormElement>) {
22
+ event.preventDefault();
23
+ setSubmitting(true);
24
+ setError(null);
25
+ try {
26
+ await login(email, password);
27
+ } catch (err) {
28
+ setError(err instanceof Error ? err.message : 'Login failed');
29
+ } finally {
30
+ setSubmitting(false);
31
+ }
32
+ }
33
+
34
+ return (
35
+ <div className="flex min-h-screen items-center justify-center bg-background p-4">
36
+ <Card className="w-full max-w-md">
37
+ <CardHeader>
38
+ <CardTitle>Sign in to davepi</CardTitle>
39
+ </CardHeader>
40
+ <CardContent>
41
+ <form className="flex flex-col gap-4" onSubmit={onSubmit}>
42
+ <div className="flex flex-col gap-2">
43
+ <Label htmlFor="email">Email</Label>
44
+ <Input
45
+ id="email"
46
+ type="email"
47
+ autoComplete="email"
48
+ required
49
+ value={email}
50
+ onChange={(e) => setEmail(e.target.value)}
51
+ />
52
+ </div>
53
+ <div className="flex flex-col gap-2">
54
+ <Label htmlFor="password">Password</Label>
55
+ <Input
56
+ id="password"
57
+ type="password"
58
+ autoComplete="current-password"
59
+ required
60
+ value={password}
61
+ onChange={(e) => setPassword(e.target.value)}
62
+ />
63
+ </div>
64
+ {error ? (
65
+ <p role="alert" className="text-sm text-destructive">
66
+ {error}
67
+ </p>
68
+ ) : null}
69
+ <Button type="submit" disabled={submitting}>
70
+ {submitting ? 'Signing in…' : 'Sign in'}
71
+ </Button>
72
+ </form>
73
+ </CardContent>
74
+ </Card>
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,58 @@
1
+ import { useState } from 'react';
2
+ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
3
+ import { useCreateResource, useDescribe } from '@davepi/ui-react';
4
+ import { ResourceForm } from '@/components/ResourceForm';
5
+
6
+ /**
7
+ * Create page. Accepts `?prefill_{field}={value}` URL params so a parent
8
+ * resource can hand the child a pre-stamped FK (used by M2 RelatedList).
9
+ * For M1 the FK lands as a hidden+readonly field so the create still works.
10
+ */
11
+ export function ResourceCreatePage() {
12
+ const params = useParams<{ path: string }>();
13
+ const [searchParams] = useSearchParams();
14
+ const navigate = useNavigate();
15
+ const { data: describe } = useDescribe();
16
+ const path = params.path ?? '';
17
+ const create = useCreateResource(path);
18
+ const [error, setError] = useState<string | null>(null);
19
+
20
+ const display = describe?.registry.display(path);
21
+ const initial: Record<string, unknown> = {};
22
+ const hidden: string[] = [];
23
+ for (const [k, v] of searchParams.entries()) {
24
+ if (!k.startsWith('prefill_')) continue;
25
+ const field = k.slice('prefill_'.length);
26
+ initial[field] = v;
27
+ hidden.push(field);
28
+ }
29
+
30
+ return (
31
+ <div className="mx-auto max-w-2xl space-y-4">
32
+ <header>
33
+ <Link to={`/r/${path}`} className="text-xs text-muted-foreground hover:underline">
34
+ ← {display?.pluralLabel ?? path}
35
+ </Link>
36
+ <h1 className="text-2xl font-semibold tracking-tight">New {display?.label ?? path}</h1>
37
+ </header>
38
+ <ResourceForm
39
+ resourcePath={path}
40
+ initial={initial}
41
+ hiddenFields={hidden}
42
+ submitLabel={`Create ${display?.label ?? path}`}
43
+ serverError={error}
44
+ onCancel={() => navigate(`/r/${path}`)}
45
+ onSubmit={async (values) => {
46
+ setError(null);
47
+ try {
48
+ const created = await create.mutateAsync(values);
49
+ const id = (created as { _id?: string })._id;
50
+ navigate(id ? `/r/${path}/${id}` : `/r/${path}`);
51
+ } catch (err) {
52
+ setError(err instanceof Error ? err.message : 'Create failed');
53
+ }
54
+ }}
55
+ />
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,171 @@
1
+ import { useState } from 'react';
2
+ import { Link, useNavigate, useParams } from 'react-router-dom';
3
+ import { Pencil, Trash2 } from 'lucide-react';
4
+ import {
5
+ useDeleteResource,
6
+ useDescribe,
7
+ useResource,
8
+ } from '@davepi/ui-react';
9
+ import { Button } from '@/components/ui/button';
10
+ import { labelize } from '@davepi/ui-core';
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ } from '@/components/ui/dialog';
19
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
20
+ import { RelatedList } from '@/components/RelatedList';
21
+
22
+ const SERVER_STAMPED = new Set(['__v', 'userId', 'accountId']);
23
+
24
+ /**
25
+ * Schema-driven detail page.
26
+ *
27
+ * Renders fields in describe order with type-aware formatting and ships
28
+ * Edit + Delete actions. Every child relation (declared `hasMany`/`hasOne`
29
+ * plus inverse edges synthesised by `SchemaRegistry`) becomes either an
30
+ * embedded `<RelatedList>` (single relation) or a tab (multiple
31
+ * relations) so users can browse and inline-create children without
32
+ * leaving the parent page.
33
+ */
34
+ export function ResourceDetailPage() {
35
+ const params = useParams<{ path: string; id: string }>();
36
+ const navigate = useNavigate();
37
+ const path = params.path ?? '';
38
+ const id = params.id ?? '';
39
+ const { data: describe } = useDescribe();
40
+ const record = useResource<Record<string, unknown>>(path, id);
41
+ const remove = useDeleteResource(path);
42
+ const [confirmOpen, setConfirmOpen] = useState(false);
43
+
44
+ if (!describe) return <p>Loading…</p>;
45
+ const entry = describe.registry.get(path);
46
+ if (!entry) return <p className="text-destructive">Unknown resource: {path}</p>;
47
+ const display = describe.registry.display(path);
48
+ if (record.isPending) return <p>Loading…</p>;
49
+ if (record.error) return <p className="text-destructive">{record.error.message}</p>;
50
+ if (!record.data) return <p>Not found.</p>;
51
+
52
+ const preview = describe.registry.preview(path, record.data);
53
+ const visibleFields = entry.fields.filter((f) => !SERVER_STAMPED.has(f.name));
54
+ const childRelations = describe.registry
55
+ .relations(path)
56
+ .filter((r) => r.kind === 'hasMany' || r.kind === 'hasOne');
57
+
58
+ const detailsBlock = (
59
+ <section className="rounded-md border border-border bg-card">
60
+ <dl className="divide-y divide-border">
61
+ {visibleFields.map((field) => (
62
+ <div key={field.name} className="grid grid-cols-3 gap-4 px-4 py-3">
63
+ <dt className="text-sm text-muted-foreground">
64
+ {labelize(field.name, {
65
+ stripIdSuffix: field.name.endsWith('Id') && field.name !== 'Id',
66
+ })}
67
+ </dt>
68
+ <dd className="col-span-2 text-sm">{formatField(record.data[field.name])}</dd>
69
+ </div>
70
+ ))}
71
+ </dl>
72
+ </section>
73
+ );
74
+
75
+ return (
76
+ <div className="space-y-6">
77
+ <header className="flex flex-wrap items-end justify-between gap-3">
78
+ <div>
79
+ <Link to={`/r/${path}`} className="text-xs text-muted-foreground hover:underline">
80
+ ← {display.pluralLabel}
81
+ </Link>
82
+ <h1 className="text-2xl font-semibold tracking-tight">{preview}</h1>
83
+ <p className="font-mono text-xs text-muted-foreground">{id}</p>
84
+ </div>
85
+ <div className="flex items-center gap-2">
86
+ <Button asChild variant="outline">
87
+ <Link to={`/r/${path}/${id}/edit`}>
88
+ <Pencil className="mr-1 h-4 w-4" />
89
+ Edit
90
+ </Link>
91
+ </Button>
92
+ <Button variant="destructive" onClick={() => setConfirmOpen(true)}>
93
+ <Trash2 className="mr-1 h-4 w-4" />
94
+ Delete
95
+ </Button>
96
+ </div>
97
+ </header>
98
+
99
+ {childRelations.length === 0 ? (
100
+ detailsBlock
101
+ ) : (
102
+ <Tabs defaultValue="details">
103
+ <TabsList>
104
+ <TabsTrigger value="details">Details</TabsTrigger>
105
+ {childRelations.map((rel) => {
106
+ const target = describe.registry.display(rel.target);
107
+ const key = `${rel.target}:${rel.foreignKey}`;
108
+ return (
109
+ <TabsTrigger key={key} value={key}>
110
+ {target.pluralLabel}
111
+ </TabsTrigger>
112
+ );
113
+ })}
114
+ </TabsList>
115
+ <TabsContent value="details" className="mt-4">
116
+ {detailsBlock}
117
+ </TabsContent>
118
+ {childRelations.map((rel) => {
119
+ const key = `${rel.target}:${rel.foreignKey}`;
120
+ return (
121
+ <TabsContent key={key} value={key} className="mt-4">
122
+ <RelatedList
123
+ parentPath={path}
124
+ parentId={id}
125
+ target={rel.target}
126
+ foreignKey={rel.foreignKey}
127
+ />
128
+ </TabsContent>
129
+ );
130
+ })}
131
+ </Tabs>
132
+ )}
133
+
134
+ <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
135
+ <DialogContent>
136
+ <DialogHeader>
137
+ <DialogTitle>Delete {display.label}?</DialogTitle>
138
+ <DialogDescription>
139
+ This action cannot be undone via the UI. Soft-deleted records can be restored from
140
+ the API.
141
+ </DialogDescription>
142
+ </DialogHeader>
143
+ <DialogFooter>
144
+ <Button variant="outline" onClick={() => setConfirmOpen(false)}>
145
+ Cancel
146
+ </Button>
147
+ <Button
148
+ variant="destructive"
149
+ onClick={async () => {
150
+ await remove.mutateAsync(id);
151
+ setConfirmOpen(false);
152
+ navigate(`/r/${path}`);
153
+ }}
154
+ >
155
+ Delete
156
+ </Button>
157
+ </DialogFooter>
158
+ </DialogContent>
159
+ </Dialog>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function formatField(value: unknown): string {
165
+ if (value == null) return '—';
166
+ if (Array.isArray(value)) return value.length ? value.join(', ') : '—';
167
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
168
+ if (value instanceof Date) return value.toLocaleString();
169
+ if (typeof value === 'object') return JSON.stringify(value);
170
+ return String(value);
171
+ }
@@ -0,0 +1,52 @@
1
+ import { useState } from 'react';
2
+ import { Link, useNavigate, useParams } from 'react-router-dom';
3
+ import {
4
+ useDescribe,
5
+ useResource,
6
+ useUpdateResource,
7
+ } from '@davepi/ui-react';
8
+ import { ResourceForm } from '@/components/ResourceForm';
9
+
10
+ export function ResourceEditPage() {
11
+ const params = useParams<{ path: string; id: string }>();
12
+ const navigate = useNavigate();
13
+ const path = params.path ?? '';
14
+ const id = params.id ?? '';
15
+ const { data: describe } = useDescribe();
16
+ const record = useResource<Record<string, unknown>>(path, id);
17
+ const update = useUpdateResource(path);
18
+ const [error, setError] = useState<string | null>(null);
19
+
20
+ const display = describe?.registry.display(path);
21
+
22
+ if (record.isPending) return <p>Loading…</p>;
23
+ if (record.error) return <p className="text-destructive">{record.error.message}</p>;
24
+ if (!record.data) return <p className="text-muted-foreground">Not found.</p>;
25
+
26
+ return (
27
+ <div className="mx-auto max-w-2xl space-y-4">
28
+ <header>
29
+ <Link to={`/r/${path}/${id}`} className="text-xs text-muted-foreground hover:underline">
30
+ ← {describe?.registry.preview(path, record.data) ?? id}
31
+ </Link>
32
+ <h1 className="text-2xl font-semibold tracking-tight">Edit {display?.label ?? path}</h1>
33
+ </header>
34
+ <ResourceForm
35
+ resourcePath={path}
36
+ initial={record.data}
37
+ submitLabel="Save changes"
38
+ serverError={error}
39
+ onCancel={() => navigate(`/r/${path}/${id}`)}
40
+ onSubmit={async (values) => {
41
+ setError(null);
42
+ try {
43
+ await update.mutateAsync({ id, body: values });
44
+ navigate(`/r/${path}/${id}`);
45
+ } catch (err) {
46
+ setError(err instanceof Error ? err.message : 'Save failed');
47
+ }
48
+ }}
49
+ />
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,8 @@
1
+ import { useParams } from 'react-router-dom';
2
+ import { ResourceTable } from '@/components/ResourceTable';
3
+
4
+ export function ResourceListPage() {
5
+ const params = useParams<{ path: string }>();
6
+ const path = params.path ?? '';
7
+ return <ResourceTable resourcePath={path} />;
8
+ }
@@ -0,0 +1,34 @@
1
+ import type { ResourceConfig } from '@davepi/ui-core';
2
+
3
+ /**
4
+ * Auto-discover per-resource override files at build time via Vite's
5
+ * `import.meta.glob`. Consumers drop files at `src/resources/{path}.ts(x)`
6
+ * exporting a default `ResourceConfig`.
7
+ *
8
+ * Lives at `src/resourceOverrides.ts` (not inside `src/resources/`) so
9
+ * the loader file itself isn't matched by its own glob — that would
10
+ * pull a copy of every resource module into a self-reference and bloat
11
+ * the bundle.
12
+ *
13
+ * Filename → path mapping: `src/resources/account.ts` → `account`.
14
+ * Hyphenated paths supported (`crm-deal.ts` → `crm-deal`).
15
+ */
16
+
17
+ interface OverrideModule {
18
+ default?: ResourceConfig;
19
+ }
20
+
21
+ const modules = import.meta.glob<OverrideModule>('./resources/*.{ts,tsx}', {
22
+ eager: true,
23
+ });
24
+
25
+ export const resourceOverrides: Record<string, ResourceConfig> = Object.fromEntries(
26
+ Object.entries(modules)
27
+ .map(([path, mod]) => {
28
+ const filename = path.replace(/^\.\/resources\//, '').replace(/\.(ts|tsx)$/, '');
29
+ const cfg = mod.default;
30
+ if (!cfg) return null;
31
+ return [filename, cfg] as const;
32
+ })
33
+ .filter((x): x is readonly [string, ResourceConfig] => x !== null)
34
+ );
@@ -0,0 +1,25 @@
1
+ import type { ResourceConfig } from '@davepi/ui-core';
2
+
3
+ /**
4
+ * Account override. Backend (`/_describe`) supplies label / pluralLabel
5
+ * / displayField, so this file only carries the bits the backend can't
6
+ * know about: sidebar category, table columns, bulk actions.
7
+ */
8
+ const config: ResourceConfig = {
9
+ category: 'CRM',
10
+ listColumns: [
11
+ { field: 'accountName', label: 'Account name' },
12
+ { field: 'description', label: 'Notes' },
13
+ ],
14
+ actions: {
15
+ bulk: [
16
+ {
17
+ id: 'bulk-delete',
18
+ label: 'Delete selected',
19
+ kind: 'bulkDelete',
20
+ },
21
+ ],
22
+ },
23
+ };
24
+
25
+ export default config;
@@ -0,0 +1,7 @@
1
+ import type { ResourceConfig } from '@davepi/ui-core';
2
+
3
+ const config: ResourceConfig = {
4
+ category: 'Catalogue',
5
+ };
6
+
7
+ export default config;