@compilr-dev/factory 0.1.1
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 +131 -0
- package/dist/factory/file-writer.d.ts +11 -0
- package/dist/factory/file-writer.js +21 -0
- package/dist/factory/list-toolkits-tool.d.ts +12 -0
- package/dist/factory/list-toolkits-tool.js +35 -0
- package/dist/factory/registry.d.ts +15 -0
- package/dist/factory/registry.js +28 -0
- package/dist/factory/scaffold-tool.d.ts +21 -0
- package/dist/factory/scaffold-tool.js +95 -0
- package/dist/factory/skill.d.ts +14 -0
- package/dist/factory/skill.js +146 -0
- package/dist/factory/tools.d.ts +14 -0
- package/dist/factory/tools.js +17 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +25 -0
- package/dist/model/defaults.d.ts +10 -0
- package/dist/model/defaults.js +68 -0
- package/dist/model/naming.d.ts +12 -0
- package/dist/model/naming.js +80 -0
- package/dist/model/operations-entity.d.ts +35 -0
- package/dist/model/operations-entity.js +110 -0
- package/dist/model/operations-field.d.ts +33 -0
- package/dist/model/operations-field.js +104 -0
- package/dist/model/operations-relationship.d.ts +19 -0
- package/dist/model/operations-relationship.js +90 -0
- package/dist/model/operations-section.d.ts +32 -0
- package/dist/model/operations-section.js +35 -0
- package/dist/model/operations.d.ts +12 -0
- package/dist/model/operations.js +63 -0
- package/dist/model/persistence.d.ts +19 -0
- package/dist/model/persistence.js +40 -0
- package/dist/model/schema.d.ts +15 -0
- package/dist/model/schema.js +269 -0
- package/dist/model/tools.d.ts +14 -0
- package/dist/model/tools.js +380 -0
- package/dist/model/types.d.ts +70 -0
- package/dist/model/types.js +8 -0
- package/dist/toolkits/react-node/api.d.ts +8 -0
- package/dist/toolkits/react-node/api.js +198 -0
- package/dist/toolkits/react-node/config.d.ts +9 -0
- package/dist/toolkits/react-node/config.js +120 -0
- package/dist/toolkits/react-node/dashboard.d.ts +8 -0
- package/dist/toolkits/react-node/dashboard.js +60 -0
- package/dist/toolkits/react-node/entity.d.ts +8 -0
- package/dist/toolkits/react-node/entity.js +469 -0
- package/dist/toolkits/react-node/helpers.d.ts +27 -0
- package/dist/toolkits/react-node/helpers.js +71 -0
- package/dist/toolkits/react-node/index.d.ts +8 -0
- package/dist/toolkits/react-node/index.js +52 -0
- package/dist/toolkits/react-node/router.d.ts +8 -0
- package/dist/toolkits/react-node/router.js +49 -0
- package/dist/toolkits/react-node/seed.d.ts +11 -0
- package/dist/toolkits/react-node/seed.js +144 -0
- package/dist/toolkits/react-node/shared.d.ts +7 -0
- package/dist/toolkits/react-node/shared.js +119 -0
- package/dist/toolkits/react-node/shell.d.ts +8 -0
- package/dist/toolkits/react-node/shell.js +152 -0
- package/dist/toolkits/react-node/static.d.ts +8 -0
- package/dist/toolkits/react-node/static.js +105 -0
- package/dist/toolkits/react-node/types-gen.d.ts +8 -0
- package/dist/toolkits/react-node/types-gen.js +31 -0
- package/dist/toolkits/types.d.ts +22 -0
- package/dist/toolkits/types.js +6 -0
- package/package.json +51 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React+Node Toolkit — Configuration Files Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates: package.json, vite.config.ts, tailwind.config.js,
|
|
5
|
+
* tsconfig.json, postcss.config.js
|
|
6
|
+
*/
|
|
7
|
+
import { toKebabCase } from '../../model/naming.js';
|
|
8
|
+
export function generateConfigFiles(model) {
|
|
9
|
+
const appSlug = toKebabCase(model.identity.name);
|
|
10
|
+
return [
|
|
11
|
+
generatePackageJson(appSlug),
|
|
12
|
+
generateViteConfig(),
|
|
13
|
+
generateTailwindConfig(model),
|
|
14
|
+
generateTsConfig(),
|
|
15
|
+
generatePostCssConfig(),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
function generatePackageJson(appSlug) {
|
|
19
|
+
const pkg = {
|
|
20
|
+
name: appSlug,
|
|
21
|
+
version: '0.1.0',
|
|
22
|
+
private: true,
|
|
23
|
+
type: 'module',
|
|
24
|
+
scripts: {
|
|
25
|
+
dev: 'concurrently "vite" "tsx watch server/index.ts"',
|
|
26
|
+
build: 'vite build',
|
|
27
|
+
preview: 'vite preview',
|
|
28
|
+
server: 'tsx watch server/index.ts',
|
|
29
|
+
},
|
|
30
|
+
dependencies: {
|
|
31
|
+
react: '^18.3.1',
|
|
32
|
+
'react-dom': '^18.3.1',
|
|
33
|
+
'react-router-dom': '^6.28.0',
|
|
34
|
+
},
|
|
35
|
+
devDependencies: {
|
|
36
|
+
'@types/cors': '^2.8.17',
|
|
37
|
+
'@types/express': '^5.0.0',
|
|
38
|
+
'@types/react': '^18.3.12',
|
|
39
|
+
'@types/react-dom': '^18.3.1',
|
|
40
|
+
'@vitejs/plugin-react': '^4.3.4',
|
|
41
|
+
autoprefixer: '^10.4.20',
|
|
42
|
+
concurrently: '^9.1.0',
|
|
43
|
+
cors: '^2.8.5',
|
|
44
|
+
express: '^4.21.1',
|
|
45
|
+
postcss: '^8.4.49',
|
|
46
|
+
tailwindcss: '^3.4.15',
|
|
47
|
+
tsx: '^4.19.2',
|
|
48
|
+
typescript: '^5.6.3',
|
|
49
|
+
vite: '^6.0.0',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
return { path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' };
|
|
53
|
+
}
|
|
54
|
+
function generateViteConfig() {
|
|
55
|
+
return {
|
|
56
|
+
path: 'vite.config.ts',
|
|
57
|
+
content: `import { defineConfig } from 'vite';
|
|
58
|
+
import react from '@vitejs/plugin-react';
|
|
59
|
+
|
|
60
|
+
export default defineConfig({
|
|
61
|
+
plugins: [react()],
|
|
62
|
+
server: {
|
|
63
|
+
proxy: {
|
|
64
|
+
'/api': 'http://localhost:3001',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function generateTailwindConfig(model) {
|
|
72
|
+
return {
|
|
73
|
+
path: 'tailwind.config.js',
|
|
74
|
+
content: `/** @type {import('tailwindcss').Config} */
|
|
75
|
+
export default {
|
|
76
|
+
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
77
|
+
darkMode: 'class',
|
|
78
|
+
theme: {
|
|
79
|
+
extend: {
|
|
80
|
+
colors: {
|
|
81
|
+
primary: '${model.theme.primaryColor}',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
plugins: [],
|
|
86
|
+
};
|
|
87
|
+
`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function generateTsConfig() {
|
|
91
|
+
const config = {
|
|
92
|
+
compilerOptions: {
|
|
93
|
+
target: 'ES2022',
|
|
94
|
+
module: 'ESNext',
|
|
95
|
+
moduleResolution: 'bundler',
|
|
96
|
+
jsx: 'react-jsx',
|
|
97
|
+
strict: true,
|
|
98
|
+
esModuleInterop: true,
|
|
99
|
+
skipLibCheck: true,
|
|
100
|
+
forceConsistentCasingInFileNames: true,
|
|
101
|
+
resolveJsonModule: true,
|
|
102
|
+
isolatedModules: true,
|
|
103
|
+
noEmit: true,
|
|
104
|
+
},
|
|
105
|
+
include: ['src', 'server'],
|
|
106
|
+
};
|
|
107
|
+
return { path: 'tsconfig.json', content: JSON.stringify(config, null, 2) + '\n' };
|
|
108
|
+
}
|
|
109
|
+
function generatePostCssConfig() {
|
|
110
|
+
return {
|
|
111
|
+
path: 'postcss.config.js',
|
|
112
|
+
content: `export default {
|
|
113
|
+
plugins: {
|
|
114
|
+
tailwindcss: {},
|
|
115
|
+
autoprefixer: {},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React+Node Toolkit — Dashboard Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates Dashboard.tsx (only when features.dashboard = true).
|
|
5
|
+
*/
|
|
6
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
7
|
+
import type { FactoryFile } from '../types.js';
|
|
8
|
+
export declare function generateDashboard(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React+Node Toolkit — Dashboard Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates Dashboard.tsx (only when features.dashboard = true).
|
|
5
|
+
*/
|
|
6
|
+
import { toKebabCase, toCamelCase } from '../../model/naming.js';
|
|
7
|
+
export function generateDashboard(model) {
|
|
8
|
+
if (!model.features.dashboard)
|
|
9
|
+
return [];
|
|
10
|
+
const fetchCalls = model.entities
|
|
11
|
+
.map((e) => {
|
|
12
|
+
const apiUrl = '/api/' + toKebabCase(e.pluralName).toLowerCase();
|
|
13
|
+
return ` fetch('${apiUrl}').then(r => r.json()).then((d: ${e.name}[]) => set${e.name}Count(d.length)),`;
|
|
14
|
+
})
|
|
15
|
+
.join('\n');
|
|
16
|
+
const stateLines = model.entities
|
|
17
|
+
.map((e) => ` const [${toCamelCase(e.name)}Count, set${e.name}Count] = useState(0);`)
|
|
18
|
+
.join('\n');
|
|
19
|
+
const typeImports = model.entities.map((e) => e.name).join(', ');
|
|
20
|
+
const cards = model.entities
|
|
21
|
+
.map((e) => {
|
|
22
|
+
const path = '/' + toKebabCase(e.pluralName).toLowerCase();
|
|
23
|
+
return ` <Link to="${path}" className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
|
|
24
|
+
<div className="text-3xl">${e.icon}</div>
|
|
25
|
+
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{${toCamelCase(e.name)}Count}</div>
|
|
26
|
+
<div className="text-sm text-gray-500 dark:text-gray-400">${e.pluralName}</div>
|
|
27
|
+
</Link>`;
|
|
28
|
+
})
|
|
29
|
+
.join('\n');
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
path: 'src/pages/Dashboard.tsx',
|
|
33
|
+
content: `import { useState, useEffect } from 'react';
|
|
34
|
+
import { Link } from 'react-router-dom';
|
|
35
|
+
import type { ${typeImports} } from '../types';
|
|
36
|
+
|
|
37
|
+
export default function Dashboard() {
|
|
38
|
+
${stateLines}
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
Promise.all([
|
|
42
|
+
${fetchCalls}
|
|
43
|
+
]);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div>
|
|
48
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
|
49
|
+
Welcome to ${model.identity.name}
|
|
50
|
+
</h1>
|
|
51
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${String(Math.min(model.entities.length, 4))}">
|
|
52
|
+
${cards}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
`,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React+Node Toolkit — Entity Pages Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates per entity: List page, Detail page, Card component, Form component.
|
|
5
|
+
*/
|
|
6
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
7
|
+
import type { FactoryFile } from '../types.js';
|
|
8
|
+
export declare function generateEntityFiles(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React+Node Toolkit — Entity Pages Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates per entity: List page, Detail page, Card component, Form component.
|
|
5
|
+
*/
|
|
6
|
+
import { toCamelCase, toKebabCase } from '../../model/naming.js';
|
|
7
|
+
import { fkFieldName, belongsToRels, hasManyRels, inputType, apiPath } from './helpers.js';
|
|
8
|
+
export function generateEntityFiles(model) {
|
|
9
|
+
return model.entities.flatMap((entity) => [
|
|
10
|
+
generateListPage(model, entity),
|
|
11
|
+
generateDetailPage(model, entity),
|
|
12
|
+
generateCard(entity),
|
|
13
|
+
generateForm(model, entity),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// List Page
|
|
18
|
+
// =============================================================================
|
|
19
|
+
function generateListPage(model, entity) {
|
|
20
|
+
const api = apiPath(entity);
|
|
21
|
+
const routeBase = toKebabCase(entity.pluralName).toLowerCase();
|
|
22
|
+
const enumFields = entity.fields.filter((f) => f.type === 'enum' && f.enumValues?.length);
|
|
23
|
+
const filterDefs = enumFields
|
|
24
|
+
.map((f) => ` { label: '${f.label}', field: '${f.name}', values: [${(f.enumValues ?? []).map((v) => `'${v}'`).join(', ')}] },`)
|
|
25
|
+
.join('\n');
|
|
26
|
+
const hasFilters = enumFields.length > 0;
|
|
27
|
+
const tableHeaders = entity.fields
|
|
28
|
+
.slice(0, 5)
|
|
29
|
+
.map((f) => ` <th className="px-4 py-2 text-left text-sm font-medium text-gray-500 dark:text-gray-400">${f.label}</th>`)
|
|
30
|
+
.join('\n');
|
|
31
|
+
const tableCells = entity.fields
|
|
32
|
+
.slice(0, 5)
|
|
33
|
+
.map((f) => ` <td className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">{String(item.${f.name})}</td>`)
|
|
34
|
+
.join('\n');
|
|
35
|
+
return {
|
|
36
|
+
path: `src/pages/${entity.name}List.tsx`,
|
|
37
|
+
content: `import { useState, useEffect, useCallback } from 'react';
|
|
38
|
+
import { Link } from 'react-router-dom';
|
|
39
|
+
import type { ${entity.name} } from '../types';
|
|
40
|
+
import ViewToggle from '../components/shared/ViewToggle';
|
|
41
|
+
import SearchBar from '../components/shared/SearchBar';${hasFilters ? "\nimport FilterBar from '../components/shared/FilterBar';" : ''}
|
|
42
|
+
import ${entity.name}Card from '../components/${routeBase}/${entity.name}Card';
|
|
43
|
+
|
|
44
|
+
${hasFilters ? `const filterOptions = [\n${filterDefs}\n];\n` : ''}
|
|
45
|
+
export default function ${entity.name}List() {
|
|
46
|
+
const [items, setItems] = useState<${entity.name}[]>([]);
|
|
47
|
+
const [view, setView] = useState<'card' | 'list'>('card');
|
|
48
|
+
const [search, setSearch] = useState('');${hasFilters ? '\n const [filters, setFilters] = useState<Record<string, string>>({});' : ''}
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
fetch('${api}')
|
|
52
|
+
.then((r) => r.json())
|
|
53
|
+
.then((data: ${entity.name}[]) => setItems(data));
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const onSearch = useCallback((q: string) => setSearch(q), []);
|
|
57
|
+
|
|
58
|
+
const filtered = items.filter((item) => {
|
|
59
|
+
if (search) {
|
|
60
|
+
const q = search.toLowerCase();
|
|
61
|
+
const match = Object.values(item).some((v) =>
|
|
62
|
+
String(v).toLowerCase().includes(q),
|
|
63
|
+
);
|
|
64
|
+
if (!match) return false;
|
|
65
|
+
}${hasFilters
|
|
66
|
+
? `
|
|
67
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
68
|
+
if (value && String((item as Record<string, unknown>)[field]) !== value) return false;
|
|
69
|
+
}`
|
|
70
|
+
: ''}
|
|
71
|
+
return true;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div>
|
|
76
|
+
<div className="mb-6 flex items-center justify-between">
|
|
77
|
+
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">${entity.icon} ${entity.pluralName}</h1>
|
|
78
|
+
<div className="flex items-center gap-3">
|
|
79
|
+
<ViewToggle view={view} onChange={setView} />
|
|
80
|
+
<Link
|
|
81
|
+
to="/${routeBase}/new"
|
|
82
|
+
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
|
83
|
+
>
|
|
84
|
+
+ New ${entity.name}
|
|
85
|
+
</Link>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div className="mb-4 flex flex-wrap items-center gap-3">
|
|
90
|
+
<div className="w-64">
|
|
91
|
+
<SearchBar onSearch={onSearch} placeholder="Search ${entity.pluralName.toLowerCase()}..." />${hasFilters ? '\n </div>\n <FilterBar filters={filterOptions} active={filters} onChange={(f, v) => setFilters({ ...filters, [f]: v })} />' : '\n </div>'}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{view === 'card' ? (
|
|
95
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
96
|
+
{filtered.map((item) => (
|
|
97
|
+
<${entity.name}Card key={item.id} item={item} />
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
) : (
|
|
101
|
+
<table className="w-full border-collapse">
|
|
102
|
+
<thead>
|
|
103
|
+
<tr className="border-b border-gray-200 dark:border-gray-700">
|
|
104
|
+
${tableHeaders}
|
|
105
|
+
</tr>
|
|
106
|
+
</thead>
|
|
107
|
+
<tbody>
|
|
108
|
+
{filtered.map((item) => (
|
|
109
|
+
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800/50">
|
|
110
|
+
${tableCells}
|
|
111
|
+
<td className="px-4 py-2">
|
|
112
|
+
<Link to={\`/${routeBase}/\${String(item.id)}\`} className="text-sm text-primary hover:underline">View</Link>
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
))}
|
|
116
|
+
</tbody>
|
|
117
|
+
</table>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// =============================================================================
|
|
126
|
+
// Detail Page
|
|
127
|
+
// =============================================================================
|
|
128
|
+
function generateDetailPage(model, entity) {
|
|
129
|
+
const api = apiPath(entity);
|
|
130
|
+
const routeBase = toKebabCase(entity.pluralName).toLowerCase();
|
|
131
|
+
const fieldRows = entity.fields
|
|
132
|
+
.map((f) => ` <div>
|
|
133
|
+
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${f.label}</dt>
|
|
134
|
+
<dd className="mt-1 text-gray-900 dark:text-white">{String(item.${f.name}${f.required ? '' : " ?? '—'"})}</dd>
|
|
135
|
+
</div>`)
|
|
136
|
+
.join('\n');
|
|
137
|
+
const btoRels = belongsToRels(entity);
|
|
138
|
+
const hmRels = hasManyRels(entity);
|
|
139
|
+
const relLinks = btoRels
|
|
140
|
+
.map((rel) => {
|
|
141
|
+
const fk = fkFieldName(rel);
|
|
142
|
+
const targetRoute = toKebabCase(model.entities.find((e) => e.name === rel.target)?.pluralName ?? rel.target).toLowerCase();
|
|
143
|
+
return ` <div>
|
|
144
|
+
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${rel.target}</dt>
|
|
145
|
+
<dd className="mt-1">
|
|
146
|
+
<Link to={\`/${targetRoute}/\${String(item.${fk})}\`} className="text-primary hover:underline">
|
|
147
|
+
View ${rel.target}
|
|
148
|
+
</Link>
|
|
149
|
+
</dd>
|
|
150
|
+
</div>`;
|
|
151
|
+
})
|
|
152
|
+
.join('\n');
|
|
153
|
+
const hasManySection = hmRels
|
|
154
|
+
.map((rel) => {
|
|
155
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
156
|
+
if (!targetEntity)
|
|
157
|
+
return '';
|
|
158
|
+
const stateVar = toCamelCase(targetEntity.pluralName);
|
|
159
|
+
return `
|
|
160
|
+
const [${stateVar}, set${targetEntity.pluralName}] = useState<${rel.target}[]>([]);`;
|
|
161
|
+
})
|
|
162
|
+
.join('');
|
|
163
|
+
const hasManyFetches = hmRels
|
|
164
|
+
.map((rel) => {
|
|
165
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
166
|
+
if (!targetEntity)
|
|
167
|
+
return '';
|
|
168
|
+
const fetchApi = apiPath(targetEntity);
|
|
169
|
+
const fk = fkFieldName(targetEntity.relationships.find((r) => r.type === 'belongsTo' && r.target === entity.name) ?? {
|
|
170
|
+
type: 'belongsTo',
|
|
171
|
+
target: entity.name,
|
|
172
|
+
});
|
|
173
|
+
return `
|
|
174
|
+
fetch('${fetchApi}')
|
|
175
|
+
.then((r) => r.json())
|
|
176
|
+
.then((data: ${rel.target}[]) => set${targetEntity.pluralName}(data.filter((d) => d.${fk} === Number(id))));`;
|
|
177
|
+
})
|
|
178
|
+
.join('');
|
|
179
|
+
const hasManyImports = hmRels.map((rel) => rel.target).join(', ');
|
|
180
|
+
const hasManyTypeImport = hmRels.length > 0 ? `, ${hasManyImports}` : '';
|
|
181
|
+
const hasManyRender = hmRels
|
|
182
|
+
.map((rel) => {
|
|
183
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
184
|
+
if (!targetEntity)
|
|
185
|
+
return '';
|
|
186
|
+
const targetRoute = toKebabCase(targetEntity.pluralName).toLowerCase();
|
|
187
|
+
const varName = toCamelCase(targetEntity.pluralName);
|
|
188
|
+
const firstField = targetEntity.fields[0]?.name ?? 'id';
|
|
189
|
+
return `
|
|
190
|
+
<div className="mt-8">
|
|
191
|
+
<h2 className="mb-4 text-lg font-bold text-gray-900 dark:text-white">${targetEntity.icon} ${targetEntity.pluralName}</h2>
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
{${varName}.map((rel) => (
|
|
194
|
+
<Link
|
|
195
|
+
key={rel.id}
|
|
196
|
+
to={\`/${targetRoute}/\${String(rel.id)}\`}
|
|
197
|
+
className="block rounded-lg border border-gray-200 p-3 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
|
198
|
+
>
|
|
199
|
+
{String(rel.${firstField})}
|
|
200
|
+
</Link>
|
|
201
|
+
))}
|
|
202
|
+
{${varName}.length === 0 && (
|
|
203
|
+
<p className="text-sm text-gray-500">No ${targetEntity.pluralName.toLowerCase()} found.</p>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</div>`;
|
|
207
|
+
})
|
|
208
|
+
.join('');
|
|
209
|
+
return {
|
|
210
|
+
path: `src/pages/${entity.name}Detail.tsx`,
|
|
211
|
+
content: `import { useState, useEffect } from 'react';
|
|
212
|
+
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
213
|
+
import type { ${entity.name}${hasManyTypeImport} } from '../types';
|
|
214
|
+
|
|
215
|
+
export default function ${entity.name}Detail() {
|
|
216
|
+
const { id } = useParams();
|
|
217
|
+
const navigate = useNavigate();
|
|
218
|
+
const [item, setItem] = useState<${entity.name} | null>(null);${hasManySection}
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
fetch(\`${api}/\${id as string}\`)
|
|
222
|
+
.then((r) => r.json())
|
|
223
|
+
.then((data: ${entity.name}) => setItem(data));${hasManyFetches}
|
|
224
|
+
}, [id]);
|
|
225
|
+
|
|
226
|
+
if (!item) return <div className="p-6">Loading...</div>;
|
|
227
|
+
|
|
228
|
+
const handleDelete = () => {
|
|
229
|
+
if (!confirm('Are you sure you want to delete this ${entity.name.toLowerCase()}?')) return;
|
|
230
|
+
fetch(\`${api}/\${id as string}\`, { method: 'DELETE' })
|
|
231
|
+
.then(() => navigate('/${routeBase}'));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<div>
|
|
236
|
+
<div className="mb-6 flex items-center justify-between">
|
|
237
|
+
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">${entity.icon} ${entity.name} Detail</h1>
|
|
238
|
+
<div className="flex gap-2">
|
|
239
|
+
<Link
|
|
240
|
+
to={\`/${routeBase}/\${id as string}/edit\`}
|
|
241
|
+
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
|
242
|
+
>
|
|
243
|
+
Edit
|
|
244
|
+
</Link>
|
|
245
|
+
<button
|
|
246
|
+
onClick={handleDelete}
|
|
247
|
+
className="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
|
248
|
+
>
|
|
249
|
+
Delete
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
255
|
+
${fieldRows}
|
|
256
|
+
${relLinks}
|
|
257
|
+
</dl>${hasManyRender}
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// =============================================================================
|
|
265
|
+
// Card Component
|
|
266
|
+
// =============================================================================
|
|
267
|
+
function generateCard(entity) {
|
|
268
|
+
const routeBase = toKebabCase(entity.pluralName).toLowerCase();
|
|
269
|
+
const previewFields = entity.fields.slice(0, 4);
|
|
270
|
+
const fieldDisplay = previewFields
|
|
271
|
+
.map((f) => ` <p className="text-sm text-gray-600 dark:text-gray-300">
|
|
272
|
+
<span className="font-medium">${f.label}:</span> {String(item.${f.name})}
|
|
273
|
+
</p>`)
|
|
274
|
+
.join('\n');
|
|
275
|
+
return {
|
|
276
|
+
path: `src/components/${routeBase}/${entity.name}Card.tsx`,
|
|
277
|
+
content: `import { Link } from 'react-router-dom';
|
|
278
|
+
import type { ${entity.name} } from '../../types';
|
|
279
|
+
|
|
280
|
+
interface ${entity.name}CardProps {
|
|
281
|
+
item: ${entity.name};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default function ${entity.name}Card({ item }: ${entity.name}CardProps) {
|
|
285
|
+
return (
|
|
286
|
+
<Link
|
|
287
|
+
to={\`/${routeBase}/\${String(item.id)}\`}
|
|
288
|
+
className="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
|
289
|
+
>
|
|
290
|
+
<div className="mb-2 flex items-center gap-2">
|
|
291
|
+
<span className="text-lg">${entity.icon}</span>
|
|
292
|
+
<span className="font-medium text-gray-900 dark:text-white">
|
|
293
|
+
{String(item.${entity.fields[0]?.name ?? 'id'})}
|
|
294
|
+
</span>
|
|
295
|
+
</div>
|
|
296
|
+
<div className="space-y-1">
|
|
297
|
+
${fieldDisplay}
|
|
298
|
+
</div>
|
|
299
|
+
</Link>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// =============================================================================
|
|
306
|
+
// Form Component
|
|
307
|
+
// =============================================================================
|
|
308
|
+
function generateForm(model, entity) {
|
|
309
|
+
const routeBase = toKebabCase(entity.pluralName).toLowerCase();
|
|
310
|
+
const api = apiPath(entity);
|
|
311
|
+
const btoRels = belongsToRels(entity);
|
|
312
|
+
// State for belongsTo dropdowns
|
|
313
|
+
const relStateLines = btoRels
|
|
314
|
+
.map((rel) => {
|
|
315
|
+
const varName = toCamelCase(rel.target) + 'Options';
|
|
316
|
+
return ` const [${varName}, set${rel.target}Options] = useState<${rel.target}[]>([]);`;
|
|
317
|
+
})
|
|
318
|
+
.join('\n');
|
|
319
|
+
const relFetches = btoRels
|
|
320
|
+
.map((rel) => {
|
|
321
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
322
|
+
if (!targetEntity)
|
|
323
|
+
return '';
|
|
324
|
+
const targetApi = apiPath(targetEntity);
|
|
325
|
+
return ` fetch('${targetApi}').then((r) => r.json()).then((d: ${rel.target}[]) => set${rel.target}Options(d));`;
|
|
326
|
+
})
|
|
327
|
+
.join('\n');
|
|
328
|
+
const relTypeImports = btoRels.map((r) => r.target);
|
|
329
|
+
const allImports = [entity.name, ...relTypeImports];
|
|
330
|
+
// Initial form state
|
|
331
|
+
const formFields = [];
|
|
332
|
+
for (const field of entity.fields) {
|
|
333
|
+
const defaultVal = field.type === 'boolean' ? 'false' : field.type === 'number' ? '0' : "''";
|
|
334
|
+
formFields.push(` ${field.name}: ${defaultVal},`);
|
|
335
|
+
}
|
|
336
|
+
for (const rel of btoRels) {
|
|
337
|
+
formFields.push(` ${fkFieldName(rel)}: 0,`);
|
|
338
|
+
}
|
|
339
|
+
// Form inputs
|
|
340
|
+
const formInputs = entity.fields.map((f) => generateFormInput(f)).join('\n');
|
|
341
|
+
const relInputs = btoRels
|
|
342
|
+
.map((rel) => {
|
|
343
|
+
const fk = fkFieldName(rel);
|
|
344
|
+
const optionsVar = toCamelCase(rel.target) + 'Options';
|
|
345
|
+
const firstField = model.entities.find((e) => e.name === rel.target)?.fields[0]?.name ?? 'id';
|
|
346
|
+
return ` <div>
|
|
347
|
+
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${rel.target}</label>
|
|
348
|
+
<select
|
|
349
|
+
value={String(form.${fk})}
|
|
350
|
+
onChange={(e) => setForm({ ...form, ${fk}: Number(e.target.value) })}
|
|
351
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
352
|
+
>
|
|
353
|
+
<option value="0">Select ${rel.target}...</option>
|
|
354
|
+
{${optionsVar}.map((opt) => (
|
|
355
|
+
<option key={opt.id} value={opt.id}>{String(opt.${firstField})}</option>
|
|
356
|
+
))}
|
|
357
|
+
</select>
|
|
358
|
+
</div>`;
|
|
359
|
+
})
|
|
360
|
+
.join('\n');
|
|
361
|
+
return {
|
|
362
|
+
path: `src/components/${routeBase}/${entity.name}Form.tsx`,
|
|
363
|
+
content: `import { useState, useEffect } from 'react';
|
|
364
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
365
|
+
import type { ${allImports.join(', ')} } from '../../types';
|
|
366
|
+
|
|
367
|
+
export default function ${entity.name}Form() {
|
|
368
|
+
const { id } = useParams();
|
|
369
|
+
const navigate = useNavigate();
|
|
370
|
+
const isEdit = Boolean(id);
|
|
371
|
+
|
|
372
|
+
const [form, setForm] = useState({
|
|
373
|
+
${formFields.join('\n')}
|
|
374
|
+
});
|
|
375
|
+
${relStateLines ? '\n' + relStateLines : ''}
|
|
376
|
+
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (isEdit) {
|
|
379
|
+
fetch(\`${api}/\${id as string}\`)
|
|
380
|
+
.then((r) => r.json())
|
|
381
|
+
.then((data: ${entity.name}) => setForm(data as typeof form));
|
|
382
|
+
}
|
|
383
|
+
${relFetches}
|
|
384
|
+
}, [id, isEdit]);
|
|
385
|
+
|
|
386
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
const method = isEdit ? 'PUT' : 'POST';
|
|
389
|
+
const url = isEdit ? \`${api}/\${id as string}\` : '${api}';
|
|
390
|
+
fetch(url, {
|
|
391
|
+
method,
|
|
392
|
+
headers: { 'Content-Type': 'application/json' },
|
|
393
|
+
body: JSON.stringify(form),
|
|
394
|
+
}).then(() => navigate('/${routeBase}'));
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
return (
|
|
398
|
+
<div>
|
|
399
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
|
400
|
+
{isEdit ? 'Edit' : 'New'} ${entity.name}
|
|
401
|
+
</h1>
|
|
402
|
+
<form onSubmit={handleSubmit} className="max-w-lg space-y-4">
|
|
403
|
+
${formInputs}
|
|
404
|
+
${relInputs}
|
|
405
|
+
<div className="flex gap-2 pt-4">
|
|
406
|
+
<button
|
|
407
|
+
type="submit"
|
|
408
|
+
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white hover:opacity-90"
|
|
409
|
+
>
|
|
410
|
+
{isEdit ? 'Update' : 'Create'}
|
|
411
|
+
</button>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
onClick={() => navigate('/${routeBase}')}
|
|
415
|
+
className="rounded-lg border border-gray-300 px-6 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300"
|
|
416
|
+
>
|
|
417
|
+
Cancel
|
|
418
|
+
</button>
|
|
419
|
+
</div>
|
|
420
|
+
</form>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function generateFormInput(field) {
|
|
428
|
+
if (field.type === 'boolean') {
|
|
429
|
+
return ` <div className="flex items-center gap-2">
|
|
430
|
+
<input
|
|
431
|
+
type="checkbox"
|
|
432
|
+
checked={Boolean(form.${field.name})}
|
|
433
|
+
onChange={(e) => setForm({ ...form, ${field.name}: e.target.checked })}
|
|
434
|
+
className="h-4 w-4 rounded border-gray-300"
|
|
435
|
+
/>
|
|
436
|
+
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
|
|
437
|
+
</div>`;
|
|
438
|
+
}
|
|
439
|
+
if (field.type === 'enum') {
|
|
440
|
+
const options = (field.enumValues ?? [])
|
|
441
|
+
.map((v) => ` <option value="${v}">${v}</option>`)
|
|
442
|
+
.join('\n');
|
|
443
|
+
return ` <div>
|
|
444
|
+
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
|
|
445
|
+
<select
|
|
446
|
+
value={String(form.${field.name})}
|
|
447
|
+
onChange={(e) => setForm({ ...form, ${field.name}: e.target.value })}${field.required ? '\n required' : ''}
|
|
448
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
449
|
+
>
|
|
450
|
+
<option value="">Select...</option>
|
|
451
|
+
${options}
|
|
452
|
+
</select>
|
|
453
|
+
</div>`;
|
|
454
|
+
}
|
|
455
|
+
const type = inputType(field);
|
|
456
|
+
const valueExpr = field.type === 'number' ? `Number(form.${field.name})` : `String(form.${field.name})`;
|
|
457
|
+
const changeExpr = field.type === 'number'
|
|
458
|
+
? `setForm({ ...form, ${field.name}: Number(e.target.value) })`
|
|
459
|
+
: `setForm({ ...form, ${field.name}: e.target.value })`;
|
|
460
|
+
return ` <div>
|
|
461
|
+
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
|
|
462
|
+
<input
|
|
463
|
+
type="${type}"
|
|
464
|
+
value={${valueExpr}}
|
|
465
|
+
onChange={(e) => ${changeExpr}}${field.required ? '\n required' : ''}
|
|
466
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
467
|
+
/>
|
|
468
|
+
</div>`;
|
|
469
|
+
}
|