create-davepi-ui 0.1.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/README.md CHANGED
@@ -7,7 +7,7 @@ Scaffolder for new [davepi-ui](https://github.com/projik/davepi-ui) admin projec
7
7
  ```bash
8
8
  npx create-davepi-ui my-admin --api-url http://localhost:4001
9
9
  cd my-admin
10
- pnpm dev
10
+ npm run dev
11
11
  ```
12
12
 
13
13
  ## Flags
@@ -15,14 +15,14 @@ pnpm dev
15
15
  | Flag | Default | Purpose |
16
16
  |---|---|---|
17
17
  | `--api-url <url>` | `http://localhost:4001` | davepi backend base URL written to `.env` |
18
- | `--no-install` | off | Skip post-scaffold `pnpm install` |
18
+ | `--no-install` | off | Skip post-scaffold `npm install` |
19
19
 
20
20
  ## What it does
21
21
 
22
- 1. Copies the bundled template (Vite + React Router + shadcn + `@davepi/ui-react`).
22
+ 1. Copies the bundled template (Vite + React Router + shadcn + `@davepi/ui-react`). Lock files are filtered out so the scaffolded project picks its own package manager — npm by default, matching the davepi backend.
23
23
  2. Rewrites `package.json` — pins `@davepi/ui-*` deps to the published versions matching this scaffolder, sets `private: true`, drops upstream repo metadata.
24
24
  3. Writes `.env` with `VITE_API_URL`.
25
- 4. Runs `pnpm install` (unless `--no-install`).
25
+ 4. Runs `npm install` (unless `--no-install`).
26
26
 
27
27
  ## License
28
28
 
package/bin/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * - rewrites `package.json` with the new project name + pinned
10
10
  * published versions of @davepi/ui-* (no workspace: protocol)
11
11
  * - writes `.env` carrying VITE_API_URL
12
- * - runs `pnpm install` (skip with --no-install)
12
+ * - runs `npm install` (skip with --no-install)
13
13
  * - prints the next three commands
14
14
  *
15
15
  * No `inquirer`, no progress bars — keeps the failure surface small.
@@ -58,7 +58,7 @@ function usage() {
58
58
  out('');
59
59
  out('Flags:');
60
60
  out(' --api-url <url> davepi backend base URL (default: http://localhost:4001)');
61
- out(' --no-install skip the post-scaffold `pnpm install`');
61
+ out(' --no-install skip the post-scaffold `npm install`');
62
62
  }
63
63
 
64
64
  function copyTree(src, dst, skip) {
@@ -198,16 +198,18 @@ function main() {
198
198
  rewritePackageJson(pkgPath, name, resolvePinnedVersions());
199
199
  }
200
200
 
201
- // Write a starter `.env` so the user can run `pnpm dev` immediately.
201
+ // Write a starter `.env` so the user can run `npm run dev` immediately.
202
202
  fs.writeFileSync(path.join(target, '.env'), `VITE_API_URL=${apiUrl}\n`);
203
203
 
204
204
  if (!skipInstall) {
205
205
  out('Installing dependencies…');
206
206
  // spawnSync with an argument array — no shell, no interpolation,
207
- // so the only program ever invoked is the literal `pnpm`.
208
- const r = spawnSync('pnpm', ['install'], { cwd: target, stdio: 'inherit' });
207
+ // so the only program ever invoked is the literal `npm`. npm ships
208
+ // as `.cmd` shim on Windows, hence the platform-specific name.
209
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
210
+ const r = spawnSync(npmCmd, ['install'], { cwd: target, stdio: 'inherit' });
209
211
  if (r.status !== 0) {
210
- err('pnpm install failed. You can re-run it from the project directory.');
212
+ err('npm install failed. You can re-run it from the project directory.');
211
213
  }
212
214
  }
213
215
 
@@ -215,8 +217,8 @@ function main() {
215
217
  out('Done.');
216
218
  out('');
217
219
  out(` cd ${name}`);
218
- if (skipInstall) out(' pnpm install');
219
- out(' pnpm dev');
220
+ if (skipInstall) out(' npm install');
221
+ out(' npm run dev');
220
222
  out('');
221
223
  out(`Backend expected at ${apiUrl}. Edit .env to point elsewhere.`);
222
224
  }
@@ -45,6 +45,14 @@ const SKIP = new Set([
45
45
  '.astro',
46
46
  '.env',
47
47
  '.env.local',
48
+ // Skip lock files — davepi-ui itself uses pnpm because it's a
49
+ // monorepo, but the scaffolded admin is a plain Vite app that uses
50
+ // npm. Keeps the package-manager surface aligned with the davepi
51
+ // backend the user already runs via `npm install`. The scaffolded
52
+ // `npm install` writes a fresh package-lock.json.
53
+ 'pnpm-lock.yaml',
54
+ 'yarn.lock',
55
+ 'package-lock.json',
48
56
  ]);
49
57
 
50
58
  function copyTree(src, dst) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-davepi-ui",
3
- "version": "0.1.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;