@compilr-dev/factory 0.1.7 → 0.1.8
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/dist/factory/registry.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/toolkits/next-prisma/api-routes.d.ts +10 -0
- package/dist/toolkits/next-prisma/api-routes.js +155 -0
- package/dist/toolkits/next-prisma/config.d.ts +9 -0
- package/dist/toolkits/next-prisma/config.js +139 -0
- package/dist/toolkits/next-prisma/dashboard.d.ts +9 -0
- package/dist/toolkits/next-prisma/dashboard.js +87 -0
- package/dist/toolkits/next-prisma/entity-components.d.ts +10 -0
- package/dist/toolkits/next-prisma/entity-components.js +217 -0
- package/dist/toolkits/next-prisma/entity-pages.d.ts +12 -0
- package/dist/toolkits/next-prisma/entity-pages.js +348 -0
- package/dist/toolkits/next-prisma/helpers.d.ts +13 -0
- package/dist/toolkits/next-prisma/helpers.js +37 -0
- package/dist/toolkits/next-prisma/index.d.ts +9 -0
- package/dist/toolkits/next-prisma/index.js +57 -0
- package/dist/toolkits/next-prisma/layout.d.ts +9 -0
- package/dist/toolkits/next-prisma/layout.js +157 -0
- package/dist/toolkits/next-prisma/prisma.d.ts +8 -0
- package/dist/toolkits/next-prisma/prisma.js +76 -0
- package/dist/toolkits/next-prisma/seed.d.ts +9 -0
- package/dist/toolkits/next-prisma/seed.js +100 -0
- package/dist/toolkits/next-prisma/static.d.ts +8 -0
- package/dist/toolkits/next-prisma/static.js +61 -0
- package/dist/toolkits/next-prisma/types-gen.d.ts +10 -0
- package/dist/toolkits/next-prisma/types-gen.js +62 -0
- package/dist/toolkits/react-node/config.d.ts +1 -4
- package/dist/toolkits/react-node/config.js +3 -84
- package/dist/toolkits/react-node/helpers.d.ts +2 -23
- package/dist/toolkits/react-node/helpers.js +2 -67
- package/dist/toolkits/react-node/seed.d.ts +4 -3
- package/dist/toolkits/react-node/seed.js +4 -111
- package/dist/toolkits/react-node/shared.d.ts +2 -3
- package/dist/toolkits/react-node/shared.js +2 -115
- package/dist/toolkits/shared/color-utils.d.ts +10 -0
- package/dist/toolkits/shared/color-utils.js +85 -0
- package/dist/toolkits/shared/components.d.ts +12 -0
- package/dist/toolkits/shared/components.js +121 -0
- package/dist/toolkits/shared/helpers.d.ts +28 -0
- package/dist/toolkits/shared/helpers.js +72 -0
- package/dist/toolkits/shared/index.d.ts +9 -0
- package/dist/toolkits/shared/index.js +9 -0
- package/dist/toolkits/shared/seed-data.d.ts +18 -0
- package/dist/toolkits/shared/seed-data.js +119 -0
- package/package.json +1 -1
package/dist/factory/registry.js
CHANGED
|
@@ -25,4 +25,6 @@ export class ToolkitRegistry {
|
|
|
25
25
|
export const defaultRegistry = new ToolkitRegistry();
|
|
26
26
|
// Register built-in toolkits
|
|
27
27
|
import { reactNodeToolkit } from '../toolkits/react-node/index.js';
|
|
28
|
+
import { nextPrismaToolkit } from '../toolkits/next-prisma/index.js';
|
|
28
29
|
defaultRegistry.register(reactNodeToolkit);
|
|
30
|
+
defaultRegistry.register(nextPrismaToolkit);
|
package/dist/index.d.ts
CHANGED
|
@@ -19,4 +19,5 @@ export { ToolkitRegistry, defaultRegistry } from './factory/registry.js';
|
|
|
19
19
|
export { writeFactoryFiles } from './factory/file-writer.js';
|
|
20
20
|
export type { WriteResult } from './factory/file-writer.js';
|
|
21
21
|
export { reactNodeToolkit } from './toolkits/react-node/index.js';
|
|
22
|
+
export { nextPrismaToolkit } from './toolkits/next-prisma/index.js';
|
|
22
23
|
export { factoryScaffoldSkill, factorySkills } from './factory/skill.js';
|
package/dist/index.js
CHANGED
|
@@ -21,5 +21,7 @@ export { ToolkitRegistry, defaultRegistry } from './factory/registry.js';
|
|
|
21
21
|
export { writeFactoryFiles } from './factory/file-writer.js';
|
|
22
22
|
// React+Node Toolkit (Phase 4)
|
|
23
23
|
export { reactNodeToolkit } from './toolkits/react-node/index.js';
|
|
24
|
+
// Next.js + Prisma Toolkit
|
|
25
|
+
export { nextPrismaToolkit } from './toolkits/next-prisma/index.js';
|
|
24
26
|
// Factory skill (Phase 5)
|
|
25
27
|
export { factoryScaffoldSkill, factorySkills } from './factory/skill.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — API Routes Generator
|
|
3
|
+
*
|
|
4
|
+
* For each entity generates:
|
|
5
|
+
* app/api/[route]/route.ts — GET list (with search/filter) + POST create
|
|
6
|
+
* app/api/[route]/[id]/route.ts — GET by id (with ?include=) + PUT update + DELETE
|
|
7
|
+
*/
|
|
8
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
9
|
+
import type { FactoryFile } from '../types.js';
|
|
10
|
+
export declare function generateApiRoutes(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — API Routes Generator
|
|
3
|
+
*
|
|
4
|
+
* For each entity generates:
|
|
5
|
+
* app/api/[route]/route.ts — GET list (with search/filter) + POST create
|
|
6
|
+
* app/api/[route]/[id]/route.ts — GET by id (with ?include=) + PUT update + DELETE
|
|
7
|
+
*/
|
|
8
|
+
import { toCamelCase } from '../../model/naming.js';
|
|
9
|
+
import { belongsToRels, hasManyRels, appRoutePath } from './helpers.js';
|
|
10
|
+
export function generateApiRoutes(model) {
|
|
11
|
+
return model.entities.flatMap((entity) => [
|
|
12
|
+
generateListRoute(model, entity),
|
|
13
|
+
generateDetailRoute(model, entity),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// List Route — GET + POST
|
|
18
|
+
// =============================================================================
|
|
19
|
+
function generateListRoute(model, entity) {
|
|
20
|
+
const route = appRoutePath(entity);
|
|
21
|
+
const camel = toCamelCase(entity.name);
|
|
22
|
+
// Build search field list for where clause
|
|
23
|
+
const searchableFields = entity.fields
|
|
24
|
+
.filter((f) => f.type === 'string' || f.type === 'enum')
|
|
25
|
+
.map((f) => f.name);
|
|
26
|
+
let searchWhere;
|
|
27
|
+
if (searchableFields.length > 0) {
|
|
28
|
+
const searchOR = searchableFields
|
|
29
|
+
.map((name) => ` { ${name}: { contains: search } },`)
|
|
30
|
+
.join('\n');
|
|
31
|
+
searchWhere = ` if (search) {
|
|
32
|
+
where.OR = [
|
|
33
|
+
${searchOR}
|
|
34
|
+
];
|
|
35
|
+
}`;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
searchWhere = ` // No searchable string fields`;
|
|
39
|
+
}
|
|
40
|
+
// Filter support for enum fields
|
|
41
|
+
const enumFields = entity.fields.filter((f) => f.type === 'enum' && f.enumValues?.length);
|
|
42
|
+
let filterWhere;
|
|
43
|
+
if (enumFields.length > 0) {
|
|
44
|
+
const filterChecks = enumFields
|
|
45
|
+
.map((f) => ` const ${f.name}Filter = searchParams.get('filter[${f.name}]');\n if (${f.name}Filter) where.${f.name} = ${f.name}Filter;`)
|
|
46
|
+
.join('\n');
|
|
47
|
+
filterWhere = filterChecks;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
filterWhere = '';
|
|
51
|
+
}
|
|
52
|
+
// BelongsTo includes for populated list
|
|
53
|
+
const btoRels = belongsToRels(entity);
|
|
54
|
+
const includeClause = btoRels.length > 0
|
|
55
|
+
? `\n include: {\n${btoRels.map((rel) => ` ${toCamelCase(rel.target)}: true,`).join('\n')}\n },`
|
|
56
|
+
: '';
|
|
57
|
+
return {
|
|
58
|
+
path: `app/api/${route}/route.ts`,
|
|
59
|
+
content: `import { prisma } from '@/lib/prisma';
|
|
60
|
+
import { NextResponse } from 'next/server';
|
|
61
|
+
|
|
62
|
+
export async function GET(request: Request) {
|
|
63
|
+
const { searchParams } = new URL(request.url);
|
|
64
|
+
const search = searchParams.get('search') ?? '';
|
|
65
|
+
|
|
66
|
+
const where: Record<string, unknown> = {};
|
|
67
|
+
${searchWhere}
|
|
68
|
+
${filterWhere}
|
|
69
|
+
|
|
70
|
+
const items = await prisma.${camel}.findMany({
|
|
71
|
+
where,${includeClause}
|
|
72
|
+
orderBy: { createdAt: 'desc' },
|
|
73
|
+
});
|
|
74
|
+
const total = await prisma.${camel}.count({ where });
|
|
75
|
+
|
|
76
|
+
return NextResponse.json({ data: items, total });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function POST(request: Request) {
|
|
80
|
+
const body = await request.json();
|
|
81
|
+
const item = await prisma.${camel}.create({ data: body });
|
|
82
|
+
return NextResponse.json(item, { status: 201 });
|
|
83
|
+
}
|
|
84
|
+
`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// =============================================================================
|
|
88
|
+
// Detail Route — GET + PUT + DELETE
|
|
89
|
+
// =============================================================================
|
|
90
|
+
function generateDetailRoute(model, entity) {
|
|
91
|
+
const route = appRoutePath(entity);
|
|
92
|
+
const camel = toCamelCase(entity.name);
|
|
93
|
+
const btoRels = belongsToRels(entity);
|
|
94
|
+
const hmRels = hasManyRels(entity);
|
|
95
|
+
// Build dynamic include from ?include= param
|
|
96
|
+
const allIncludable = [];
|
|
97
|
+
for (const rel of btoRels) {
|
|
98
|
+
allIncludable.push(toCamelCase(rel.target));
|
|
99
|
+
}
|
|
100
|
+
for (const rel of hmRels) {
|
|
101
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
102
|
+
if (targetEntity) {
|
|
103
|
+
allIncludable.push(toCamelCase(targetEntity.pluralName));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let includeBlock;
|
|
107
|
+
if (allIncludable.length > 0) {
|
|
108
|
+
const includeChecks = allIncludable
|
|
109
|
+
.map((name) => ` if (include.includes('${name}')) prismaInclude.${name} = true;`)
|
|
110
|
+
.join('\n');
|
|
111
|
+
includeBlock = ` const include = (searchParams.get('include') ?? '').split(',').filter(Boolean);
|
|
112
|
+
const prismaInclude: Record<string, boolean> = {};
|
|
113
|
+
${includeChecks}
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
includeBlock = '';
|
|
118
|
+
}
|
|
119
|
+
const findUniqueInclude = allIncludable.length > 0
|
|
120
|
+
? `\n include: Object.keys(prismaInclude).length > 0 ? prismaInclude : undefined,`
|
|
121
|
+
: '';
|
|
122
|
+
return {
|
|
123
|
+
path: `app/api/${route}/[id]/route.ts`,
|
|
124
|
+
content: `import { prisma } from '@/lib/prisma';
|
|
125
|
+
import { NextResponse } from 'next/server';
|
|
126
|
+
|
|
127
|
+
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
|
128
|
+
const { searchParams } = new URL(request.url);
|
|
129
|
+
${includeBlock}
|
|
130
|
+
const item = await prisma.${camel}.findUnique({
|
|
131
|
+
where: { id: Number(params.id) },${findUniqueInclude}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!item) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
135
|
+
return NextResponse.json(item);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
|
139
|
+
const body = await request.json();
|
|
140
|
+
const item = await prisma.${camel}.update({
|
|
141
|
+
where: { id: Number(params.id) },
|
|
142
|
+
data: body,
|
|
143
|
+
});
|
|
144
|
+
return NextResponse.json(item);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function DELETE(_request: Request, { params }: { params: { id: string } }) {
|
|
148
|
+
await prisma.${camel}.delete({
|
|
149
|
+
where: { id: Number(params.id) },
|
|
150
|
+
});
|
|
151
|
+
return new NextResponse(null, { status: 204 });
|
|
152
|
+
}
|
|
153
|
+
`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Configuration Files Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates: package.json, next.config.mjs, tailwind.config.js,
|
|
5
|
+
* tsconfig.json, postcss.config.js, .env, .env.example
|
|
6
|
+
*/
|
|
7
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
8
|
+
import type { FactoryFile } from '../types.js';
|
|
9
|
+
export declare function generateConfigFiles(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Configuration Files Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates: package.json, next.config.mjs, tailwind.config.js,
|
|
5
|
+
* tsconfig.json, postcss.config.js, .env, .env.example
|
|
6
|
+
*/
|
|
7
|
+
import { toKebabCase } from '../../model/naming.js';
|
|
8
|
+
import { generateColorShades } from '../shared/color-utils.js';
|
|
9
|
+
export function generateConfigFiles(model) {
|
|
10
|
+
const appSlug = toKebabCase(model.identity.name);
|
|
11
|
+
return [
|
|
12
|
+
generatePackageJson(appSlug),
|
|
13
|
+
generateNextConfig(),
|
|
14
|
+
generateTailwindConfig(model),
|
|
15
|
+
generateTsConfig(),
|
|
16
|
+
generatePostCssConfig(),
|
|
17
|
+
generateEnv(),
|
|
18
|
+
generateEnvExample(),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
function generatePackageJson(appSlug) {
|
|
22
|
+
const pkg = {
|
|
23
|
+
name: appSlug,
|
|
24
|
+
version: '0.1.0',
|
|
25
|
+
private: true,
|
|
26
|
+
scripts: {
|
|
27
|
+
dev: 'next dev',
|
|
28
|
+
build: 'next build',
|
|
29
|
+
start: 'next start',
|
|
30
|
+
lint: 'next lint',
|
|
31
|
+
'db:push': 'prisma db push',
|
|
32
|
+
'db:seed': 'tsx prisma/seed.ts',
|
|
33
|
+
'db:studio': 'prisma studio',
|
|
34
|
+
postinstall: 'prisma generate',
|
|
35
|
+
},
|
|
36
|
+
dependencies: {
|
|
37
|
+
next: '^14.2.0',
|
|
38
|
+
react: '^18.3.1',
|
|
39
|
+
'react-dom': '^18.3.1',
|
|
40
|
+
'@prisma/client': '^5.20.0',
|
|
41
|
+
'@tailwindcss/forms': '^0.5.9',
|
|
42
|
+
autoprefixer: '^10.4.20',
|
|
43
|
+
postcss: '^8.4.49',
|
|
44
|
+
tailwindcss: '^3.4.15',
|
|
45
|
+
},
|
|
46
|
+
devDependencies: {
|
|
47
|
+
prisma: '^5.20.0',
|
|
48
|
+
typescript: '^5.6.3',
|
|
49
|
+
'@types/node': '^22.0.0',
|
|
50
|
+
'@types/react': '^18.3.12',
|
|
51
|
+
'@types/react-dom': '^18.3.1',
|
|
52
|
+
tsx: '^4.19.2',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return { path: 'package.json', content: JSON.stringify(pkg, null, 2) + '\n' };
|
|
56
|
+
}
|
|
57
|
+
function generateNextConfig() {
|
|
58
|
+
return {
|
|
59
|
+
path: 'next.config.mjs',
|
|
60
|
+
content: `/** @type {import('next').NextConfig} */
|
|
61
|
+
const nextConfig = {};
|
|
62
|
+
|
|
63
|
+
export default nextConfig;
|
|
64
|
+
`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function generateTailwindConfig(model) {
|
|
68
|
+
return {
|
|
69
|
+
path: 'tailwind.config.js',
|
|
70
|
+
content: `import forms from '@tailwindcss/forms';
|
|
71
|
+
|
|
72
|
+
/** @type {import('tailwindcss').Config} */
|
|
73
|
+
export default {
|
|
74
|
+
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
|
|
75
|
+
darkMode: 'class',
|
|
76
|
+
theme: {
|
|
77
|
+
extend: {
|
|
78
|
+
colors: {
|
|
79
|
+
primary: ${generateColorShades(model.theme.primaryColor)},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
plugins: [forms],
|
|
84
|
+
};
|
|
85
|
+
`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function generateTsConfig() {
|
|
89
|
+
const config = {
|
|
90
|
+
compilerOptions: {
|
|
91
|
+
target: 'ES2017',
|
|
92
|
+
lib: ['dom', 'dom.iterable', 'esnext'],
|
|
93
|
+
allowJs: true,
|
|
94
|
+
skipLibCheck: true,
|
|
95
|
+
strict: true,
|
|
96
|
+
noEmit: true,
|
|
97
|
+
esModuleInterop: true,
|
|
98
|
+
module: 'esnext',
|
|
99
|
+
moduleResolution: 'bundler',
|
|
100
|
+
resolveJsonModule: true,
|
|
101
|
+
isolatedModules: true,
|
|
102
|
+
jsx: 'preserve',
|
|
103
|
+
incremental: true,
|
|
104
|
+
plugins: [{ name: 'next' }],
|
|
105
|
+
paths: {
|
|
106
|
+
'@/*': ['./*'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
|
|
110
|
+
exclude: ['node_modules'],
|
|
111
|
+
};
|
|
112
|
+
return { path: 'tsconfig.json', content: JSON.stringify(config, null, 2) + '\n' };
|
|
113
|
+
}
|
|
114
|
+
function generatePostCssConfig() {
|
|
115
|
+
return {
|
|
116
|
+
path: 'postcss.config.js',
|
|
117
|
+
content: `export default {
|
|
118
|
+
plugins: {
|
|
119
|
+
tailwindcss: {},
|
|
120
|
+
autoprefixer: {},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function generateEnv() {
|
|
127
|
+
return {
|
|
128
|
+
path: '.env',
|
|
129
|
+
content: `DATABASE_URL="file:./dev.db"
|
|
130
|
+
`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function generateEnvExample() {
|
|
134
|
+
return {
|
|
135
|
+
path: '.env.example',
|
|
136
|
+
content: `DATABASE_URL="file:./dev.db"
|
|
137
|
+
`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Dashboard Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates app/page.tsx — an async Server Component using Prisma directly.
|
|
5
|
+
* No useState/useEffect/fetch — data is loaded at request time.
|
|
6
|
+
*/
|
|
7
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
8
|
+
import type { FactoryFile } from '../types.js';
|
|
9
|
+
export declare function generateDashboard(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Dashboard Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates app/page.tsx — an async Server Component using Prisma directly.
|
|
5
|
+
* No useState/useEffect/fetch — data is loaded at request time.
|
|
6
|
+
*/
|
|
7
|
+
import { toCamelCase } from '../../model/naming.js';
|
|
8
|
+
import { appRoutePath } from './helpers.js';
|
|
9
|
+
export function generateDashboard(model) {
|
|
10
|
+
if (!model.features.dashboard)
|
|
11
|
+
return [];
|
|
12
|
+
const prismaImports = model.entities
|
|
13
|
+
.map((e) => {
|
|
14
|
+
const camel = toCamelCase(e.name);
|
|
15
|
+
return ` prisma.${camel}.count(),\n prisma.${camel}.findMany({ take: 5, orderBy: { createdAt: 'desc' } }),`;
|
|
16
|
+
})
|
|
17
|
+
.join('\n');
|
|
18
|
+
// Destructure: [customerCount, recentCustomers, tableCount, recentTables, ...]
|
|
19
|
+
const destructureItems = model.entities
|
|
20
|
+
.map((e) => `${toCamelCase(e.name)}Count, recent${e.name}s`)
|
|
21
|
+
.join(', ');
|
|
22
|
+
const cards = model.entities
|
|
23
|
+
.map((e) => {
|
|
24
|
+
const path = appRoutePath(e);
|
|
25
|
+
return ` <Link href="/${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">
|
|
26
|
+
<div className="text-3xl">${e.icon}</div>
|
|
27
|
+
<div className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">{${toCamelCase(e.name)}Count}</div>
|
|
28
|
+
<div className="text-sm text-gray-500 dark:text-gray-400">${e.pluralName}</div>
|
|
29
|
+
</Link>`;
|
|
30
|
+
})
|
|
31
|
+
.join('\n');
|
|
32
|
+
const recentRows = model.entities
|
|
33
|
+
.map((e) => {
|
|
34
|
+
const firstStringField = e.fields.find((f) => f.type === 'string');
|
|
35
|
+
const displayExpr = firstStringField ? `item.${firstStringField.name}` : `String(item.id)`;
|
|
36
|
+
const path = appRoutePath(e);
|
|
37
|
+
return ` {recent${e.name}s.map((item) => (
|
|
38
|
+
<tr key={item.id}>
|
|
39
|
+
<td className="px-4 py-2 text-sm">${e.icon} ${e.name}</td>
|
|
40
|
+
<td className="px-4 py-2 text-sm"><Link href={\`/${path}/\${String(item.id)}\`} className="text-primary hover:underline">{${displayExpr}}</Link></td>
|
|
41
|
+
<td className="px-4 py-2 text-sm text-gray-500">{String(item.id)}</td>
|
|
42
|
+
</tr>
|
|
43
|
+
))}`;
|
|
44
|
+
})
|
|
45
|
+
.join('\n');
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
path: 'app/page.tsx',
|
|
49
|
+
content: `import Link from 'next/link';
|
|
50
|
+
import { prisma } from '@/lib/prisma';
|
|
51
|
+
|
|
52
|
+
export default async function Dashboard() {
|
|
53
|
+
const [${destructureItems}] = await Promise.all([
|
|
54
|
+
${prismaImports}
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div>
|
|
59
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
|
60
|
+
Welcome to ${model.identity.name}
|
|
61
|
+
</h1>
|
|
62
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${String(Math.min(model.entities.length, 4))}">
|
|
63
|
+
${cards}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<h2 className="mb-4 mt-8 text-xl font-bold text-gray-900 dark:text-white">Recent Activity</h2>
|
|
67
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
|
68
|
+
<table className="w-full text-left">
|
|
69
|
+
<thead className="bg-gray-50 dark:bg-gray-800">
|
|
70
|
+
<tr>
|
|
71
|
+
<th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">Type</th>
|
|
72
|
+
<th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">Name</th>
|
|
73
|
+
<th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">ID</th>
|
|
74
|
+
</tr>
|
|
75
|
+
</thead>
|
|
76
|
+
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
77
|
+
${recentRows}
|
|
78
|
+
</tbody>
|
|
79
|
+
</table>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
`,
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Entity Components Generator
|
|
3
|
+
*
|
|
4
|
+
* For each entity generates:
|
|
5
|
+
* components/[route]/[Entity]Card.tsx — Card component for grid view
|
|
6
|
+
* components/[route]/[Entity]Form.tsx — Create/Edit form (Client Component)
|
|
7
|
+
*/
|
|
8
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
9
|
+
import type { FactoryFile } from '../types.js';
|
|
10
|
+
export declare function generateEntityComponents(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Entity Components Generator
|
|
3
|
+
*
|
|
4
|
+
* For each entity generates:
|
|
5
|
+
* components/[route]/[Entity]Card.tsx — Card component for grid view
|
|
6
|
+
* components/[route]/[Entity]Form.tsx — Create/Edit form (Client Component)
|
|
7
|
+
*/
|
|
8
|
+
import { toCamelCase } from '../../model/naming.js';
|
|
9
|
+
import { fkFieldName, belongsToRels, inputType, appRoutePath } from './helpers.js';
|
|
10
|
+
export function generateEntityComponents(model) {
|
|
11
|
+
return model.entities.flatMap((entity) => [generateCard(entity), generateForm(model, entity)]);
|
|
12
|
+
}
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Card Component
|
|
15
|
+
// =============================================================================
|
|
16
|
+
function generateCard(entity) {
|
|
17
|
+
const route = appRoutePath(entity);
|
|
18
|
+
const previewFields = entity.fields.slice(0, 4);
|
|
19
|
+
const fieldDisplay = previewFields
|
|
20
|
+
.map((f) => ` <p className="text-sm text-gray-600 dark:text-gray-300">
|
|
21
|
+
<span className="font-medium">${f.label}:</span> {String(item.${f.name})}
|
|
22
|
+
</p>`)
|
|
23
|
+
.join('\n');
|
|
24
|
+
return {
|
|
25
|
+
path: `components/${route}/${entity.name}Card.tsx`,
|
|
26
|
+
content: `import Link from 'next/link';
|
|
27
|
+
|
|
28
|
+
interface ${entity.name}CardProps {
|
|
29
|
+
item: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function ${entity.name}Card({ item }: ${entity.name}CardProps) {
|
|
33
|
+
return (
|
|
34
|
+
<Link
|
|
35
|
+
href={\`/${route}/\${String(item.id)}\`}
|
|
36
|
+
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"
|
|
37
|
+
>
|
|
38
|
+
<div className="mb-2 flex items-center gap-2">
|
|
39
|
+
<span className="text-lg">${entity.icon}</span>
|
|
40
|
+
<span className="font-medium text-gray-900 dark:text-white">
|
|
41
|
+
{String(item.${entity.fields[0]?.name ?? 'id'})}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="space-y-1">
|
|
45
|
+
${fieldDisplay}
|
|
46
|
+
</div>
|
|
47
|
+
</Link>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Form Component (Client Component)
|
|
55
|
+
// =============================================================================
|
|
56
|
+
function generateForm(model, entity) {
|
|
57
|
+
const route = appRoutePath(entity);
|
|
58
|
+
const btoRels = belongsToRels(entity);
|
|
59
|
+
// State for belongsTo dropdowns
|
|
60
|
+
const relStateLines = btoRels
|
|
61
|
+
.map((rel) => {
|
|
62
|
+
const varName = toCamelCase(rel.target) + 'Options';
|
|
63
|
+
return ` const [${varName}, set${rel.target}Options] = useState<Array<Record<string, unknown>>>([]);`;
|
|
64
|
+
})
|
|
65
|
+
.join('\n');
|
|
66
|
+
const relFetches = btoRels
|
|
67
|
+
.map((rel) => {
|
|
68
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
69
|
+
if (!targetEntity)
|
|
70
|
+
return '';
|
|
71
|
+
const targetRoute = appRoutePath(targetEntity);
|
|
72
|
+
return ` fetch('/api/${targetRoute}').then((r) => r.json()).then((res: { data: Array<Record<string, unknown>>; total: number }) => set${rel.target}Options(res.data));`;
|
|
73
|
+
})
|
|
74
|
+
.join('\n');
|
|
75
|
+
// Initial form state
|
|
76
|
+
const formFields = [];
|
|
77
|
+
for (const field of entity.fields) {
|
|
78
|
+
const defaultVal = field.type === 'boolean' ? 'false' : field.type === 'number' ? '0' : "''";
|
|
79
|
+
formFields.push(` ${field.name}: ${defaultVal},`);
|
|
80
|
+
}
|
|
81
|
+
for (const rel of btoRels) {
|
|
82
|
+
formFields.push(` ${fkFieldName(rel)}: 0,`);
|
|
83
|
+
}
|
|
84
|
+
// Form inputs
|
|
85
|
+
const formInputs = entity.fields.map((f) => generateFormInput(f)).join('\n');
|
|
86
|
+
const relInputs = btoRels
|
|
87
|
+
.map((rel) => {
|
|
88
|
+
const fk = fkFieldName(rel);
|
|
89
|
+
const optionsVar = toCamelCase(rel.target) + 'Options';
|
|
90
|
+
const firstField = model.entities.find((e) => e.name === rel.target)?.fields[0]?.name ?? 'id';
|
|
91
|
+
return ` <div>
|
|
92
|
+
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${rel.target}</label>
|
|
93
|
+
<select
|
|
94
|
+
value={String(form.${fk})}
|
|
95
|
+
onChange={(e) => setForm({ ...form, ${fk}: Number(e.target.value) })}
|
|
96
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
97
|
+
>
|
|
98
|
+
<option value="0">Select ${rel.target}...</option>
|
|
99
|
+
{${optionsVar}.map((opt) => (
|
|
100
|
+
<option key={String(opt.id)} value={String(opt.id)}>{String(opt.${firstField})}</option>
|
|
101
|
+
))}
|
|
102
|
+
</select>
|
|
103
|
+
</div>`;
|
|
104
|
+
})
|
|
105
|
+
.join('\n');
|
|
106
|
+
return {
|
|
107
|
+
path: `components/${route}/${entity.name}Form.tsx`,
|
|
108
|
+
content: `'use client';
|
|
109
|
+
|
|
110
|
+
import { useState, useEffect } from 'react';
|
|
111
|
+
import { useRouter } from 'next/navigation';
|
|
112
|
+
|
|
113
|
+
interface ${entity.name}FormProps {
|
|
114
|
+
initialData?: Record<string, unknown>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default function ${entity.name}Form({ initialData }: ${entity.name}FormProps) {
|
|
118
|
+
const router = useRouter();
|
|
119
|
+
const isEdit = Boolean(initialData);
|
|
120
|
+
|
|
121
|
+
const [form, setForm] = useState({
|
|
122
|
+
${formFields.join('\n')}
|
|
123
|
+
});
|
|
124
|
+
${relStateLines ? '\n' + relStateLines : ''}
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (initialData) {
|
|
128
|
+
setForm(initialData as typeof form);
|
|
129
|
+
}
|
|
130
|
+
${relFetches}
|
|
131
|
+
}, [initialData]);
|
|
132
|
+
|
|
133
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
const method = isEdit ? 'PUT' : 'POST';
|
|
136
|
+
const url = isEdit ? \`/api/${route}/\${String(initialData?.id)}\` : '/api/${route}';
|
|
137
|
+
await fetch(url, {
|
|
138
|
+
method,
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify(form),
|
|
141
|
+
});
|
|
142
|
+
router.push('/${route}');
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div>
|
|
147
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">
|
|
148
|
+
{isEdit ? 'Edit' : 'New'} ${entity.name}
|
|
149
|
+
</h1>
|
|
150
|
+
<form onSubmit={handleSubmit} className="max-w-lg space-y-4">
|
|
151
|
+
${formInputs}
|
|
152
|
+
${relInputs}
|
|
153
|
+
<div className="flex gap-2 pt-4">
|
|
154
|
+
<button
|
|
155
|
+
type="submit"
|
|
156
|
+
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white hover:opacity-90"
|
|
157
|
+
>
|
|
158
|
+
{isEdit ? 'Update' : 'Create'}
|
|
159
|
+
</button>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => router.push('/${route}')}
|
|
163
|
+
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"
|
|
164
|
+
>
|
|
165
|
+
Cancel
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</form>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function generateFormInput(field) {
|
|
176
|
+
if (field.type === 'boolean') {
|
|
177
|
+
return ` <div className="flex items-center gap-2">
|
|
178
|
+
<input
|
|
179
|
+
type="checkbox"
|
|
180
|
+
checked={Boolean(form.${field.name})}
|
|
181
|
+
onChange={(e) => setForm({ ...form, ${field.name}: e.target.checked })}
|
|
182
|
+
className="h-4 w-4 rounded border-gray-300"
|
|
183
|
+
/>
|
|
184
|
+
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
|
|
185
|
+
</div>`;
|
|
186
|
+
}
|
|
187
|
+
if (field.type === 'enum') {
|
|
188
|
+
const options = (field.enumValues ?? [])
|
|
189
|
+
.map((v) => ` <option value="${v}">${v}</option>`)
|
|
190
|
+
.join('\n');
|
|
191
|
+
return ` <div>
|
|
192
|
+
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
|
|
193
|
+
<select
|
|
194
|
+
value={String(form.${field.name})}
|
|
195
|
+
onChange={(e) => setForm({ ...form, ${field.name}: e.target.value })}${field.required ? '\n required' : ''}
|
|
196
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
197
|
+
>
|
|
198
|
+
<option value="">Select...</option>
|
|
199
|
+
${options}
|
|
200
|
+
</select>
|
|
201
|
+
</div>`;
|
|
202
|
+
}
|
|
203
|
+
const type = inputType(field);
|
|
204
|
+
const valueExpr = field.type === 'number' ? `Number(form.${field.name})` : `String(form.${field.name})`;
|
|
205
|
+
const changeExpr = field.type === 'number'
|
|
206
|
+
? `setForm({ ...form, ${field.name}: Number(e.target.value) })`
|
|
207
|
+
: `setForm({ ...form, ${field.name}: e.target.value })`;
|
|
208
|
+
return ` <div>
|
|
209
|
+
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">${field.label}</label>
|
|
210
|
+
<input
|
|
211
|
+
type="${type}"
|
|
212
|
+
value={${valueExpr}}
|
|
213
|
+
onChange={(e) => ${changeExpr}}${field.required ? '\n required' : ''}
|
|
214
|
+
className="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
215
|
+
/>
|
|
216
|
+
</div>`;
|
|
217
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Entity Pages Generator
|
|
3
|
+
*
|
|
4
|
+
* For each entity generates:
|
|
5
|
+
* app/[route]/page.tsx — List page (Server + Client)
|
|
6
|
+
* app/[route]/[id]/page.tsx — Detail page (Server Component)
|
|
7
|
+
* app/[route]/new/page.tsx — New form wrapper
|
|
8
|
+
* app/[route]/[id]/edit/page.tsx — Edit form wrapper
|
|
9
|
+
*/
|
|
10
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
11
|
+
import type { FactoryFile } from '../types.js';
|
|
12
|
+
export declare function generateEntityPages(model: ApplicationModel): FactoryFile[];
|