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 +1 -1
- package/templates/default/index.html +17 -1
- package/templates/default/src/components/AppShell.tsx +2 -0
- package/templates/default/src/components/ThemeToggle.tsx +78 -0
- package/templates/default/src/index.css +39 -31
- package/templates/default/src/pages/ResourceDetailPage.tsx +29 -4
- package/templates/default/src/resources/.gitkeep +0 -0
- package/templates/pinned-versions.json +1 -1
- package/templates/default/src/resources/account.ts +0 -25
- package/templates/default/src/resources/category.ts +0 -7
- package/templates/default/src/resources/contact.ts +0 -40
- package/templates/default/src/resources/product.ts +0 -7
- package/templates/default/src/resources/project.ts +0 -7
- package/templates/default/src/resources/quote.ts +0 -12
package/package.json
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en"
|
|
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:
|
|
10
|
+
--foreground: 222 47% 11%;
|
|
9
11
|
--card: 0 0% 100%;
|
|
10
|
-
--card-foreground:
|
|
11
|
-
--
|
|
12
|
-
--
|
|
13
|
-
--
|
|
14
|
-
--
|
|
15
|
-
--
|
|
16
|
-
--
|
|
17
|
-
--
|
|
18
|
-
--
|
|
19
|
-
--
|
|
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:
|
|
22
|
-
--input:
|
|
23
|
-
--ring:
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
--
|
|
31
|
-
--
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
--
|
|
41
|
-
--
|
|
42
|
-
--
|
|
43
|
-
--
|
|
44
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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,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,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,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;
|