create-davepi-ui 0.2.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-davepi-ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffolder for new davepi-ui projects. Run: npx create-davepi-ui <name> [--api-url <url>]",
5
5
  "license": "MIT",
6
6
  "author": "David Baxter <dave@unlockedequity.com>",
@@ -1,10 +1,26 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en" class="dark">
2
+ <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>davepi-ui</title>
8
+ <script>
9
+ // FOUC guard: read theme preference + apply class before paint
10
+ // so the very first frame already matches the user's choice.
11
+ // Lookup order: explicit localStorage choice → system preference.
12
+ // Values: 'light' | 'dark' | 'system' (default).
13
+ (function () {
14
+ try {
15
+ var pref = localStorage.getItem('davepi-theme') || 'system';
16
+ var prefersDark =
17
+ pref === 'dark' ||
18
+ (pref === 'system' &&
19
+ window.matchMedia('(prefers-color-scheme: dark)').matches);
20
+ document.documentElement.classList.toggle('dark', prefersDark);
21
+ } catch (e) {}
22
+ })();
23
+ </script>
8
24
  </head>
9
25
  <body class="bg-background text-foreground">
10
26
  <div id="root"></div>
@@ -1,6 +1,7 @@
1
1
  import { Outlet } from 'react-router-dom';
2
2
  import { UserMenu } from '@davepi/ui-react';
3
3
  import { Sidebar } from './Sidebar';
4
+ import { ThemeToggle } from './ThemeToggle';
4
5
 
5
6
  /**
6
7
  * Two-column shell: sidebar nav (resource list from describe) + main outlet.
@@ -12,6 +13,7 @@ export function AppShell() {
12
13
  <Sidebar />
13
14
  <div className="flex flex-1 flex-col overflow-hidden">
14
15
  <header className="flex h-14 items-center justify-end gap-3 border-b border-border px-6">
16
+ <ThemeToggle />
15
17
  <UserMenu className="flex items-center gap-3 text-sm" />
16
18
  </header>
17
19
  <main className="flex-1 overflow-auto p-6">
@@ -0,0 +1,78 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Moon, Sun, Laptop } from 'lucide-react';
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
+ * Light / dark / system-preference toggle. Persisted in localStorage
13
+ * under `davepi-theme`. The index.html FOUC-guard script reads the
14
+ * same key on first paint, so the user's choice survives reload
15
+ * without a flash of the wrong theme.
16
+ */
17
+ type Theme = 'light' | 'dark' | 'system';
18
+
19
+ const STORAGE_KEY = 'davepi-theme';
20
+
21
+ function applyTheme(theme: Theme) {
22
+ const prefersDark =
23
+ theme === 'dark' ||
24
+ (theme === 'system' &&
25
+ typeof window !== 'undefined' &&
26
+ window.matchMedia('(prefers-color-scheme: dark)').matches);
27
+ document.documentElement.classList.toggle('dark', prefersDark);
28
+ }
29
+
30
+ function readTheme(): Theme {
31
+ if (typeof window === 'undefined') return 'system';
32
+ const v = window.localStorage.getItem(STORAGE_KEY);
33
+ if (v === 'light' || v === 'dark' || v === 'system') return v;
34
+ return 'system';
35
+ }
36
+
37
+ export function ThemeToggle() {
38
+ const [theme, setTheme] = useState<Theme>(() => readTheme());
39
+
40
+ useEffect(() => {
41
+ applyTheme(theme);
42
+ if (typeof window !== 'undefined') {
43
+ window.localStorage.setItem(STORAGE_KEY, theme);
44
+ }
45
+ }, [theme]);
46
+
47
+ // Listen for OS-level preference changes when in system mode so the
48
+ // UI tracks the user's day-night transition.
49
+ useEffect(() => {
50
+ if (theme !== 'system' || typeof window === 'undefined') return;
51
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
52
+ const handler = () => applyTheme('system');
53
+ mq.addEventListener('change', handler);
54
+ return () => mq.removeEventListener('change', handler);
55
+ }, [theme]);
56
+
57
+ return (
58
+ <DropdownMenu>
59
+ <DropdownMenuTrigger asChild>
60
+ <Button variant="ghost" size="icon" aria-label="Toggle theme">
61
+ <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
62
+ <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
63
+ </Button>
64
+ </DropdownMenuTrigger>
65
+ <DropdownMenuContent align="end">
66
+ <DropdownMenuItem onClick={() => setTheme('light')}>
67
+ <Sun className="mr-2 h-4 w-4" /> Light
68
+ </DropdownMenuItem>
69
+ <DropdownMenuItem onClick={() => setTheme('dark')}>
70
+ <Moon className="mr-2 h-4 w-4" /> Dark
71
+ </DropdownMenuItem>
72
+ <DropdownMenuItem onClick={() => setTheme('system')}>
73
+ <Laptop className="mr-2 h-4 w-4" /> System
74
+ </DropdownMenuItem>
75
+ </DropdownMenuContent>
76
+ </DropdownMenu>
77
+ );
78
+ }
@@ -4,44 +4,52 @@
4
4
 
5
5
  @layer base {
6
6
  :root {
7
+ /* Light theme — slate neutrals + indigo accent.
8
+ Friendlier than pure black-on-white. */
7
9
  --background: 0 0% 100%;
8
- --foreground: 240 10% 3.9%;
10
+ --foreground: 222 47% 11%;
9
11
  --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%;
12
+ --card-foreground: 222 47% 11%;
13
+ --popover: 0 0% 100%;
14
+ --popover-foreground: 222 47% 11%;
15
+ --primary: 224 76% 48%;
16
+ --primary-foreground: 0 0% 100%;
17
+ --secondary: 210 40% 96%;
18
+ --secondary-foreground: 222 47% 11%;
19
+ --muted: 210 40% 96%;
20
+ --muted-foreground: 215 16% 47%;
21
+ --accent: 210 40% 96%;
22
+ --accent-foreground: 222 47% 11%;
23
+ --destructive: 0 72% 51%;
20
24
  --destructive-foreground: 0 0% 98%;
21
- --border: 240 5.9% 90%;
22
- --input: 240 5.9% 90%;
23
- --ring: 240 5.9% 10%;
25
+ --border: 214 32% 91%;
26
+ --input: 214 32% 91%;
27
+ --ring: 224 76% 48%;
24
28
  --radius: 0.5rem;
25
29
  }
26
30
 
27
31
  .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%;
32
+ /* Dark theme — slate base instead of near-black, indigo accent
33
+ carried over. Less screen-bleach than pure-monochrome. */
34
+ --background: 222 47% 11%;
35
+ --foreground: 210 40% 98%;
36
+ --card: 222 47% 13%;
37
+ --card-foreground: 210 40% 98%;
38
+ --popover: 222 47% 13%;
39
+ --popover-foreground: 210 40% 98%;
40
+ --primary: 224 76% 60%;
41
+ --primary-foreground: 222 47% 11%;
42
+ --secondary: 217 33% 18%;
43
+ --secondary-foreground: 210 40% 98%;
44
+ --muted: 217 33% 18%;
45
+ --muted-foreground: 215 20% 65%;
46
+ --accent: 217 33% 18%;
47
+ --accent-foreground: 210 40% 98%;
48
+ --destructive: 0 63% 51%;
49
+ --destructive-foreground: 210 40% 98%;
50
+ --border: 217 33% 22%;
51
+ --input: 217 33% 22%;
52
+ --ring: 224 76% 60%;
45
53
  }
46
54
 
47
55
  * {
@@ -51,9 +51,24 @@ export function ResourceDetailPage() {
51
51
 
52
52
  const preview = describe.registry.preview(path, record.data);
53
53
  const visibleFields = entry.fields.filter((f) => !SERVER_STAMPED.has(f.name));
54
- const childRelations = describe.registry
54
+ // Suppress redundant `hasOne` tabs: when a parent declares both
55
+ // `hasMany: contact` and `hasOne: contact` against the same FK
56
+ // (e.g. `primaryContact` for "the one flagged as primary"), the
57
+ // hasMany tab already lists every contact including the primary —
58
+ // a separate Primary Contact tab is UX clutter, not information.
59
+ // Skip the hasOne when an equivalent hasMany exists; the user can
60
+ // sort / filter the main list to find the primary.
61
+ const allChildRelations = describe.registry
55
62
  .relations(path)
56
63
  .filter((r) => r.kind === 'hasMany' || r.kind === 'hasOne');
64
+ const hasManyKeys = new Set(
65
+ allChildRelations
66
+ .filter((r) => r.kind === 'hasMany')
67
+ .map((r) => `${r.target}:${r.foreignKey}`)
68
+ );
69
+ const childRelations = allChildRelations.filter(
70
+ (r) => r.kind !== 'hasOne' || !hasManyKeys.has(`${r.target}:${r.foreignKey}`)
71
+ );
57
72
 
58
73
  const detailsBlock = (
59
74
  <section className="rounded-md border border-border bg-card">
@@ -104,10 +119,20 @@ export function ResourceDetailPage() {
104
119
  <TabsTrigger value="details">Details</TabsTrigger>
105
120
  {childRelations.map((rel) => {
106
121
  const target = describe.registry.display(rel.target);
107
- const key = `${rel.target}:${rel.foreignKey}`;
122
+ // Include `rel.name` in the key without it, a parent
123
+ // that declares both `contacts: hasMany` and
124
+ // `primaryContact: hasOne` on the same target/FK pair
125
+ // would collide on a `target:foreignKey`-only key.
126
+ // The label uses the relation name (humanised) instead
127
+ // of the bare target plural so the tabs read distinctly.
128
+ const key = `${rel.name}:${rel.target}:${rel.foreignKey}`;
129
+ const tabLabel =
130
+ rel.kind === 'hasOne'
131
+ ? labelize(rel.name)
132
+ : target.pluralLabel;
108
133
  return (
109
134
  <TabsTrigger key={key} value={key}>
110
- {target.pluralLabel}
135
+ {tabLabel}
111
136
  </TabsTrigger>
112
137
  );
113
138
  })}
@@ -116,7 +141,7 @@ export function ResourceDetailPage() {
116
141
  {detailsBlock}
117
142
  </TabsContent>
118
143
  {childRelations.map((rel) => {
119
- const key = `${rel.target}:${rel.foreignKey}`;
144
+ const key = `${rel.name}:${rel.target}:${rel.foreignKey}`;
120
145
  return (
121
146
  <TabsContent key={key} value={key} className="mt-4">
122
147
  <RelatedList
File without changes
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "@davepi/ui-core": "^0.1.1",
3
3
  "@davepi/ui-mcp": "^0.1.1",
4
- "@davepi/ui-react": "^0.1.1"
4
+ "@davepi/ui-react": "^0.2.0"
5
5
  }
@@ -1,25 +0,0 @@
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;
@@ -1,7 +0,0 @@
1
- import type { ResourceConfig } from '@davepi/ui-core';
2
-
3
- const config: ResourceConfig = {
4
- category: 'Catalogue',
5
- };
6
-
7
- export default config;
@@ -1,40 +0,0 @@
1
- import type { ResourceConfig } from '@davepi/ui-core';
2
-
3
- /**
4
- * Contact override. Form sections and explicit list columns are the
5
- * UI-only bits — labels / pluralLabel / displayField come straight
6
- * from the backend manifest.
7
- */
8
- const config: ResourceConfig = {
9
- category: 'CRM',
10
- listColumns: [
11
- { field: 'first_name' },
12
- { field: 'last_name' },
13
- { field: 'email' },
14
- { field: 'phone' },
15
- ],
16
- formSections: [
17
- {
18
- title: 'Identity',
19
- fields: [{ field: 'first_name' }, { field: 'last_name' }, { field: 'email' }],
20
- },
21
- {
22
- title: 'Contact',
23
- fields: [{ field: 'phone' }, { field: 'mobile' }, { field: 'company' }],
24
- },
25
- {
26
- title: 'Address',
27
- description: 'Postal address used for invoices and correspondence.',
28
- fields: [
29
- { field: 'address1' },
30
- { field: 'address2' },
31
- { field: 'suburb' },
32
- { field: 'state' },
33
- { field: 'postcode' },
34
- { field: 'country' },
35
- ],
36
- },
37
- ],
38
- };
39
-
40
- export default config;
@@ -1,7 +0,0 @@
1
- import type { ResourceConfig } from '@davepi/ui-core';
2
-
3
- const config: ResourceConfig = {
4
- category: 'Catalogue',
5
- };
6
-
7
- export default config;
@@ -1,7 +0,0 @@
1
- import type { ResourceConfig } from '@davepi/ui-core';
2
-
3
- const config: ResourceConfig = {
4
- category: 'Delivery',
5
- };
6
-
7
- export default config;
@@ -1,12 +0,0 @@
1
- import type { ResourceConfig } from '@davepi/ui-core';
2
-
3
- /**
4
- * Quote override. Only the sidebar category is consumer-supplied —
5
- * everything else (labels, displayField, contactId relation, etc.)
6
- * comes from the backend manifest.
7
- */
8
- const config: ResourceConfig = {
9
- category: 'CRM',
10
- };
11
-
12
- export default config;