@compilr-dev/factory 0.1.7 → 0.1.9

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.
Files changed (54) hide show
  1. package/dist/factory/registry.js +4 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +4 -0
  4. package/dist/toolkits/next-prisma/api-routes.d.ts +10 -0
  5. package/dist/toolkits/next-prisma/api-routes.js +155 -0
  6. package/dist/toolkits/next-prisma/config.d.ts +9 -0
  7. package/dist/toolkits/next-prisma/config.js +139 -0
  8. package/dist/toolkits/next-prisma/dashboard.d.ts +9 -0
  9. package/dist/toolkits/next-prisma/dashboard.js +87 -0
  10. package/dist/toolkits/next-prisma/entity-components.d.ts +10 -0
  11. package/dist/toolkits/next-prisma/entity-components.js +217 -0
  12. package/dist/toolkits/next-prisma/entity-pages.d.ts +12 -0
  13. package/dist/toolkits/next-prisma/entity-pages.js +348 -0
  14. package/dist/toolkits/next-prisma/helpers.d.ts +13 -0
  15. package/dist/toolkits/next-prisma/helpers.js +37 -0
  16. package/dist/toolkits/next-prisma/index.d.ts +9 -0
  17. package/dist/toolkits/next-prisma/index.js +57 -0
  18. package/dist/toolkits/next-prisma/layout.d.ts +9 -0
  19. package/dist/toolkits/next-prisma/layout.js +157 -0
  20. package/dist/toolkits/next-prisma/prisma.d.ts +8 -0
  21. package/dist/toolkits/next-prisma/prisma.js +76 -0
  22. package/dist/toolkits/next-prisma/seed.d.ts +9 -0
  23. package/dist/toolkits/next-prisma/seed.js +100 -0
  24. package/dist/toolkits/next-prisma/static.d.ts +8 -0
  25. package/dist/toolkits/next-prisma/static.js +61 -0
  26. package/dist/toolkits/next-prisma/types-gen.d.ts +10 -0
  27. package/dist/toolkits/next-prisma/types-gen.js +62 -0
  28. package/dist/toolkits/react-node/config.d.ts +1 -4
  29. package/dist/toolkits/react-node/config.js +3 -84
  30. package/dist/toolkits/react-node/helpers.d.ts +2 -23
  31. package/dist/toolkits/react-node/helpers.js +2 -67
  32. package/dist/toolkits/react-node/seed.d.ts +4 -3
  33. package/dist/toolkits/react-node/seed.js +4 -111
  34. package/dist/toolkits/react-node/shared.d.ts +2 -3
  35. package/dist/toolkits/react-node/shared.js +2 -115
  36. package/dist/toolkits/shared/color-utils.d.ts +10 -0
  37. package/dist/toolkits/shared/color-utils.js +85 -0
  38. package/dist/toolkits/shared/components.d.ts +12 -0
  39. package/dist/toolkits/shared/components.js +121 -0
  40. package/dist/toolkits/shared/helpers.d.ts +28 -0
  41. package/dist/toolkits/shared/helpers.js +72 -0
  42. package/dist/toolkits/shared/index.d.ts +9 -0
  43. package/dist/toolkits/shared/index.js +9 -0
  44. package/dist/toolkits/shared/seed-data.d.ts +18 -0
  45. package/dist/toolkits/shared/seed-data.js +119 -0
  46. package/dist/toolkits/static-landing/config.d.ts +8 -0
  47. package/dist/toolkits/static-landing/config.js +63 -0
  48. package/dist/toolkits/static-landing/index.d.ts +8 -0
  49. package/dist/toolkits/static-landing/index.js +35 -0
  50. package/dist/toolkits/static-landing/pages.d.ts +8 -0
  51. package/dist/toolkits/static-landing/pages.js +191 -0
  52. package/dist/toolkits/static-landing/static.d.ts +8 -0
  53. package/dist/toolkits/static-landing/static.js +65 -0
  54. package/package.json +1 -1
@@ -25,4 +25,8 @@ 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';
29
+ import { staticLandingToolkit } from '../toolkits/static-landing/index.js';
28
30
  defaultRegistry.register(reactNodeToolkit);
31
+ defaultRegistry.register(nextPrismaToolkit);
32
+ defaultRegistry.register(staticLandingToolkit);
package/dist/index.d.ts CHANGED
@@ -19,4 +19,6 @@ 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';
23
+ export { staticLandingToolkit } from './toolkits/static-landing/index.js';
22
24
  export { factoryScaffoldSkill, factorySkills } from './factory/skill.js';
package/dist/index.js CHANGED
@@ -21,5 +21,9 @@ 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';
26
+ // Static Landing Page Toolkit
27
+ export { staticLandingToolkit } from './toolkits/static-landing/index.js';
24
28
  // Factory skill (Phase 5)
25
29
  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
+ }