@erwininteractive/mvc 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -2
- package/dist/cli.js +22 -2
- package/dist/generators/generateResource.d.ts +11 -0
- package/dist/generators/generateResource.js +199 -0
- package/dist/generators/initApp.d.ts +1 -0
- package/dist/generators/initApp.js +17 -0
- package/dist/generators/utils.d.ts +19 -0
- package/dist/generators/utils.js +80 -0
- package/package.json +1 -1
- package/templates/ci/test.yml +49 -0
- package/templates/controller.resource.ts.ejs +181 -0
- package/templates/views/create.ejs.ejs +137 -0
- package/templates/views/edit.ejs.ejs +157 -0
- package/templates/views/index.ejs.ejs +97 -0
- package/templates/views/show.ejs.ejs +97 -0
package/README.md
CHANGED
|
@@ -110,9 +110,55 @@ app.get("/api/users", (req, res) => {
|
|
|
110
110
|
|
|
111
111
|
---
|
|
112
112
|
|
|
113
|
+
## Resources
|
|
114
|
+
|
|
115
|
+
Generate a complete resource (model + controller + views) with one command:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npx erwinmvc generate resource Post
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This creates:
|
|
122
|
+
- `prisma/schema.prisma` - Adds the Post model
|
|
123
|
+
- `src/controllers/PostController.ts` - Full CRUD controller with form handling
|
|
124
|
+
- `src/views/posts/index.ejs` - List view
|
|
125
|
+
- `src/views/posts/show.ejs` - Detail view
|
|
126
|
+
- `src/views/posts/create.ejs` - Create form
|
|
127
|
+
- `src/views/posts/edit.ejs` - Edit form
|
|
128
|
+
|
|
129
|
+
### Resource Routes
|
|
130
|
+
|
|
131
|
+
| Action | HTTP Method | URL | Description |
|
|
132
|
+
|-----------|-------------|------------------|------------------|
|
|
133
|
+
| `index` | GET | /posts | List all |
|
|
134
|
+
| `create` | GET | /posts/create | Show create form |
|
|
135
|
+
| `store` | POST | /posts | Create new |
|
|
136
|
+
| `show` | GET | /posts/:id | Show one |
|
|
137
|
+
| `edit` | GET | /posts/:id/edit | Show edit form |
|
|
138
|
+
| `update` | PUT | /posts/:id | Update |
|
|
139
|
+
| `destroy` | DELETE | /posts/:id | Delete |
|
|
140
|
+
|
|
141
|
+
### Wiring Up Routes
|
|
142
|
+
|
|
143
|
+
Add to `src/server.ts`:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import * as PostController from "./controllers/PostController";
|
|
147
|
+
|
|
148
|
+
app.get("/posts", PostController.index);
|
|
149
|
+
app.get("/posts/create", PostController.create);
|
|
150
|
+
app.post("/posts", PostController.store);
|
|
151
|
+
app.get("/posts/:id", PostController.show);
|
|
152
|
+
app.get("/posts/:id/edit", PostController.edit);
|
|
153
|
+
app.put("/posts/:id", PostController.update);
|
|
154
|
+
app.delete("/posts/:id", PostController.destroy);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
113
159
|
## Controllers
|
|
114
160
|
|
|
115
|
-
Generate a controller
|
|
161
|
+
Generate just a controller (without model/views):
|
|
116
162
|
|
|
117
163
|
```bash
|
|
118
164
|
npx erwinmvc generate controller Product
|
|
@@ -239,6 +285,7 @@ app.get("/protected", authenticate, (req, res) => {
|
|
|
239
285
|
| Command | Description |
|
|
240
286
|
|---------|-------------|
|
|
241
287
|
| `npx @erwininteractive/mvc init <dir>` | Create a new app |
|
|
288
|
+
| `npx erwinmvc generate resource <name>` | Generate model + controller + views |
|
|
242
289
|
| `npx erwinmvc generate controller <name>` | Generate a CRUD controller |
|
|
243
290
|
| `npx erwinmvc generate model <name>` | Generate a database model |
|
|
244
291
|
|
|
@@ -248,8 +295,19 @@ app.get("/protected", authenticate, (req, res) => {
|
|
|
248
295
|
|--------|-------------|
|
|
249
296
|
| `--skip-install` | Skip running npm install |
|
|
250
297
|
| `--with-database` | Include Prisma database setup |
|
|
298
|
+
| `--with-ci` | Include GitHub Actions CI workflow |
|
|
251
299
|
|
|
252
|
-
###
|
|
300
|
+
### Resource Options
|
|
301
|
+
|
|
302
|
+
| Option | Description |
|
|
303
|
+
|--------|-------------|
|
|
304
|
+
| `--skip-model` | Skip generating Prisma model |
|
|
305
|
+
| `--skip-controller` | Skip generating controller |
|
|
306
|
+
| `--skip-views` | Skip generating views |
|
|
307
|
+
| `--skip-migrate` | Skip running Prisma migrate |
|
|
308
|
+
| `--api-only` | Generate API-only controller (no views) |
|
|
309
|
+
|
|
310
|
+
### Other Generate Options
|
|
253
311
|
|
|
254
312
|
| Option | Description |
|
|
255
313
|
|--------|-------------|
|
|
@@ -299,6 +357,121 @@ public/images/logo.png → /images/logo.png
|
|
|
299
357
|
|
|
300
358
|
---
|
|
301
359
|
|
|
360
|
+
## CI/CD (Optional)
|
|
361
|
+
|
|
362
|
+
Add GitHub Actions CI to your project for automated testing:
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
npx @erwininteractive/mvc init myapp --with-ci
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Or add CI to an existing project by creating `.github/workflows/test.yml`:
|
|
369
|
+
|
|
370
|
+
```yaml
|
|
371
|
+
name: Test
|
|
372
|
+
|
|
373
|
+
on:
|
|
374
|
+
push:
|
|
375
|
+
branches: [main]
|
|
376
|
+
pull_request:
|
|
377
|
+
branches: [main]
|
|
378
|
+
|
|
379
|
+
jobs:
|
|
380
|
+
test:
|
|
381
|
+
runs-on: ubuntu-latest
|
|
382
|
+
|
|
383
|
+
steps:
|
|
384
|
+
- name: Checkout
|
|
385
|
+
uses: actions/checkout@v4
|
|
386
|
+
|
|
387
|
+
- name: Setup Node.js
|
|
388
|
+
uses: actions/setup-node@v4
|
|
389
|
+
with:
|
|
390
|
+
node-version: "20"
|
|
391
|
+
cache: "npm"
|
|
392
|
+
|
|
393
|
+
- name: Install dependencies
|
|
394
|
+
run: npm ci
|
|
395
|
+
|
|
396
|
+
- name: Run tests
|
|
397
|
+
run: npm test
|
|
398
|
+
|
|
399
|
+
- name: Build
|
|
400
|
+
run: npm run build
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Adding Database Tests
|
|
404
|
+
|
|
405
|
+
If your app uses a database, add PostgreSQL as a service:
|
|
406
|
+
|
|
407
|
+
```yaml
|
|
408
|
+
jobs:
|
|
409
|
+
test:
|
|
410
|
+
runs-on: ubuntu-latest
|
|
411
|
+
|
|
412
|
+
services:
|
|
413
|
+
postgres:
|
|
414
|
+
image: postgres:16
|
|
415
|
+
env:
|
|
416
|
+
POSTGRES_USER: postgres
|
|
417
|
+
POSTGRES_PASSWORD: postgres
|
|
418
|
+
POSTGRES_DB: test
|
|
419
|
+
ports:
|
|
420
|
+
- 5432:5432
|
|
421
|
+
options: >-
|
|
422
|
+
--health-cmd pg_isready
|
|
423
|
+
--health-interval 10s
|
|
424
|
+
--health-timeout 5s
|
|
425
|
+
--health-retries 5
|
|
426
|
+
|
|
427
|
+
steps:
|
|
428
|
+
- name: Checkout
|
|
429
|
+
uses: actions/checkout@v4
|
|
430
|
+
|
|
431
|
+
- name: Setup Node.js
|
|
432
|
+
uses: actions/setup-node@v4
|
|
433
|
+
with:
|
|
434
|
+
node-version: "20"
|
|
435
|
+
cache: "npm"
|
|
436
|
+
|
|
437
|
+
- name: Install dependencies
|
|
438
|
+
run: npm ci
|
|
439
|
+
|
|
440
|
+
- name: Run migrations
|
|
441
|
+
run: npx prisma migrate deploy
|
|
442
|
+
env:
|
|
443
|
+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
|
|
444
|
+
|
|
445
|
+
- name: Run tests
|
|
446
|
+
run: npm test
|
|
447
|
+
env:
|
|
448
|
+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
|
|
449
|
+
|
|
450
|
+
- name: Build
|
|
451
|
+
run: npm run build
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Secrets
|
|
455
|
+
|
|
456
|
+
For production deployments, add these secrets in your GitHub repository settings:
|
|
457
|
+
|
|
458
|
+
| Secret | Description |
|
|
459
|
+
|--------|-------------|
|
|
460
|
+
| `DATABASE_URL` | Production database connection string |
|
|
461
|
+
| `REDIS_URL` | Production Redis connection string |
|
|
462
|
+
| `JWT_SECRET` | Secret key for JWT signing |
|
|
463
|
+
| `SESSION_SECRET` | Secret key for session encryption |
|
|
464
|
+
|
|
465
|
+
Access secrets in your workflow:
|
|
466
|
+
|
|
467
|
+
```yaml
|
|
468
|
+
env:
|
|
469
|
+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
470
|
+
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
302
475
|
## Environment Variables
|
|
303
476
|
|
|
304
477
|
All optional. Create `.env` from `.env.example`:
|
package/dist/cli.js
CHANGED
|
@@ -5,17 +5,19 @@ const commander_1 = require("commander");
|
|
|
5
5
|
const initApp_1 = require("./generators/initApp");
|
|
6
6
|
const generateModel_1 = require("./generators/generateModel");
|
|
7
7
|
const generateController_1 = require("./generators/generateController");
|
|
8
|
+
const generateResource_1 = require("./generators/generateResource");
|
|
8
9
|
const program = new commander_1.Command();
|
|
9
10
|
program
|
|
10
11
|
.name("erwinmvc")
|
|
11
12
|
.description("CLI for @erwininteractive/mvc framework")
|
|
12
|
-
.version("0.
|
|
13
|
+
.version("0.2.0");
|
|
13
14
|
// Init command - scaffold a new application
|
|
14
15
|
program
|
|
15
16
|
.command("init <dir>")
|
|
16
17
|
.description("Scaffold a new MVC application")
|
|
17
18
|
.option("--skip-install", "Skip npm install")
|
|
18
19
|
.option("--with-database", "Include database/Prisma setup")
|
|
20
|
+
.option("--with-ci", "Include GitHub Actions CI workflow")
|
|
19
21
|
.action(async (dir, options) => {
|
|
20
22
|
try {
|
|
21
23
|
await (0, initApp_1.initApp)(dir, options);
|
|
@@ -29,7 +31,7 @@ program
|
|
|
29
31
|
const generate = program
|
|
30
32
|
.command("generate")
|
|
31
33
|
.alias("g")
|
|
32
|
-
.description("Generate models or
|
|
34
|
+
.description("Generate models, controllers, or resources");
|
|
33
35
|
// Generate model
|
|
34
36
|
generate
|
|
35
37
|
.command("model <name>")
|
|
@@ -58,4 +60,22 @@ generate
|
|
|
58
60
|
process.exit(1);
|
|
59
61
|
}
|
|
60
62
|
});
|
|
63
|
+
// Generate resource (model + controller + views)
|
|
64
|
+
generate
|
|
65
|
+
.command("resource <name>")
|
|
66
|
+
.description("Generate a complete resource (model + controller + views)")
|
|
67
|
+
.option("--skip-model", "Skip generating Prisma model")
|
|
68
|
+
.option("--skip-controller", "Skip generating controller")
|
|
69
|
+
.option("--skip-views", "Skip generating views")
|
|
70
|
+
.option("--skip-migrate", "Skip running Prisma migrate")
|
|
71
|
+
.option("--api-only", "Generate API-only controller (no views)")
|
|
72
|
+
.action(async (name, options) => {
|
|
73
|
+
try {
|
|
74
|
+
await (0, generateResource_1.generateResource)(name, options);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
61
81
|
program.parse();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface GenerateResourceOptions {
|
|
2
|
+
skipModel?: boolean;
|
|
3
|
+
skipController?: boolean;
|
|
4
|
+
skipViews?: boolean;
|
|
5
|
+
skipMigrate?: boolean;
|
|
6
|
+
apiOnly?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Generate a complete resource: model + controller + views.
|
|
10
|
+
*/
|
|
11
|
+
export declare function generateResource(name: string, options?: GenerateResourceOptions): Promise<void>;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.generateResource = generateResource;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const ejs_1 = __importDefault(require("ejs"));
|
|
10
|
+
const child_process_1 = require("child_process");
|
|
11
|
+
const paths_1 = require("./paths");
|
|
12
|
+
const utils_1 = require("./utils");
|
|
13
|
+
/**
|
|
14
|
+
* Generate a complete resource: model + controller + views.
|
|
15
|
+
*/
|
|
16
|
+
async function generateResource(name, options = {}) {
|
|
17
|
+
const modelName = (0, utils_1.capitalize)(name);
|
|
18
|
+
const lowerModelName = name.toLowerCase();
|
|
19
|
+
const controllerName = `${modelName}Controller`;
|
|
20
|
+
const resourcePath = (0, utils_1.pluralize)(lowerModelName);
|
|
21
|
+
const tableName = (0, utils_1.pluralize)(lowerModelName);
|
|
22
|
+
console.log(`\nGenerating resource: ${modelName}\n`);
|
|
23
|
+
// Generate model (if database is set up)
|
|
24
|
+
if (!options.skipModel) {
|
|
25
|
+
const schemaPath = path_1.default.resolve("prisma/schema.prisma");
|
|
26
|
+
if (fs_1.default.existsSync(schemaPath)) {
|
|
27
|
+
await generateModel(modelName, tableName, schemaPath, options.skipMigrate);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
console.log("Skipping model (no prisma/schema.prisma found)");
|
|
31
|
+
console.log("Run 'npm run db:setup' first to enable database features\n");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Generate controller
|
|
35
|
+
if (!options.skipController) {
|
|
36
|
+
await generateController(modelName, lowerModelName, controllerName, resourcePath, options.apiOnly);
|
|
37
|
+
}
|
|
38
|
+
// Generate views (unless API only)
|
|
39
|
+
if (!options.skipViews && !options.apiOnly) {
|
|
40
|
+
await generateViews(modelName, lowerModelName, resourcePath);
|
|
41
|
+
}
|
|
42
|
+
// Print summary
|
|
43
|
+
printSummary(modelName, controllerName, resourcePath, options);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Generate Prisma model.
|
|
47
|
+
*/
|
|
48
|
+
async function generateModel(modelName, tableName, schemaPath, skipMigrate) {
|
|
49
|
+
const templatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), "model.prisma.ejs");
|
|
50
|
+
if (!fs_1.default.existsSync(templatePath)) {
|
|
51
|
+
throw new Error("Model template not found");
|
|
52
|
+
}
|
|
53
|
+
// Check if model already exists
|
|
54
|
+
const existingSchema = fs_1.default.readFileSync(schemaPath, "utf-8");
|
|
55
|
+
if (existingSchema.includes(`model ${modelName} {`)) {
|
|
56
|
+
console.log(`Model ${modelName} already exists, skipping...`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const template = fs_1.default.readFileSync(templatePath, "utf-8");
|
|
60
|
+
const modelContent = ejs_1.default.render(template, { modelName, tableName });
|
|
61
|
+
fs_1.default.appendFileSync(schemaPath, "\n" + modelContent);
|
|
62
|
+
console.log(`Created model ${modelName} in prisma/schema.prisma`);
|
|
63
|
+
if (!skipMigrate) {
|
|
64
|
+
console.log("\nRunning Prisma migrate...");
|
|
65
|
+
try {
|
|
66
|
+
(0, child_process_1.execSync)(`npx prisma migrate dev --name add-${tableName}`, {
|
|
67
|
+
stdio: "inherit",
|
|
68
|
+
});
|
|
69
|
+
(0, child_process_1.execSync)("npx prisma generate", { stdio: "inherit" });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
console.error("Migration failed. Run manually: npx prisma migrate dev");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Generate controller file.
|
|
78
|
+
*/
|
|
79
|
+
async function generateController(modelName, lowerModelName, controllerName, resourcePath, apiOnly) {
|
|
80
|
+
const controllersDir = path_1.default.resolve("src/controllers");
|
|
81
|
+
if (!fs_1.default.existsSync(controllersDir)) {
|
|
82
|
+
fs_1.default.mkdirSync(controllersDir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
const controllerPath = path_1.default.join(controllersDir, `${controllerName}.ts`);
|
|
85
|
+
if (fs_1.default.existsSync(controllerPath)) {
|
|
86
|
+
console.log(`Controller ${controllerName}.ts already exists, skipping...`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Use resource controller template (with full CRUD + form handling)
|
|
90
|
+
const templateName = apiOnly ? "controller.api.ts.ejs" : "controller.resource.ts.ejs";
|
|
91
|
+
let templatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), templateName);
|
|
92
|
+
// Fall back to basic controller template if resource template doesn't exist
|
|
93
|
+
if (!fs_1.default.existsSync(templatePath)) {
|
|
94
|
+
templatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), "controller.ts.ejs");
|
|
95
|
+
}
|
|
96
|
+
if (!fs_1.default.existsSync(templatePath)) {
|
|
97
|
+
throw new Error("Controller template not found");
|
|
98
|
+
}
|
|
99
|
+
const template = fs_1.default.readFileSync(templatePath, "utf-8");
|
|
100
|
+
const controllerContent = ejs_1.default.render(template, {
|
|
101
|
+
modelName,
|
|
102
|
+
lowerModelName,
|
|
103
|
+
controllerName,
|
|
104
|
+
resourcePath,
|
|
105
|
+
});
|
|
106
|
+
fs_1.default.writeFileSync(controllerPath, controllerContent);
|
|
107
|
+
console.log(`Created src/controllers/${controllerName}.ts`);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Generate view files for the resource.
|
|
111
|
+
*/
|
|
112
|
+
async function generateViews(modelName, lowerModelName, resourcePath) {
|
|
113
|
+
const viewsDir = path_1.default.resolve(`src/views/${resourcePath}`);
|
|
114
|
+
if (!fs_1.default.existsSync(viewsDir)) {
|
|
115
|
+
fs_1.default.mkdirSync(viewsDir, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
const views = [
|
|
118
|
+
{ name: "index", title: `${modelName} List` },
|
|
119
|
+
{ name: "show", title: `${modelName} Details` },
|
|
120
|
+
{ name: "create", title: `Create ${modelName}` },
|
|
121
|
+
{ name: "edit", title: `Edit ${modelName}` },
|
|
122
|
+
];
|
|
123
|
+
for (const view of views) {
|
|
124
|
+
const viewPath = path_1.default.join(viewsDir, `${view.name}.ejs`);
|
|
125
|
+
if (fs_1.default.existsSync(viewPath)) {
|
|
126
|
+
console.log(`View ${view.name}.ejs already exists, skipping...`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Try specific template first, then fall back to generic
|
|
130
|
+
let templatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), `views/${view.name}.ejs.ejs`);
|
|
131
|
+
if (!fs_1.default.existsSync(templatePath)) {
|
|
132
|
+
templatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), "view.ejs.ejs");
|
|
133
|
+
}
|
|
134
|
+
if (!fs_1.default.existsSync(templatePath)) {
|
|
135
|
+
console.warn(`View template for ${view.name} not found, skipping...`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const template = fs_1.default.readFileSync(templatePath, "utf-8");
|
|
139
|
+
const viewContent = ejs_1.default.render(template, {
|
|
140
|
+
title: view.title,
|
|
141
|
+
modelName,
|
|
142
|
+
lowerName: lowerModelName,
|
|
143
|
+
resourcePath,
|
|
144
|
+
viewType: view.name,
|
|
145
|
+
});
|
|
146
|
+
fs_1.default.writeFileSync(viewPath, viewContent);
|
|
147
|
+
}
|
|
148
|
+
console.log(`Created views in src/views/${resourcePath}/`);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Print summary of generated files and next steps.
|
|
152
|
+
*/
|
|
153
|
+
function printSummary(modelName, controllerName, resourcePath, options) {
|
|
154
|
+
console.log(`
|
|
155
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
156
|
+
Resource ${modelName} created successfully!
|
|
157
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
158
|
+
|
|
159
|
+
Files created:`);
|
|
160
|
+
if (!options.skipModel) {
|
|
161
|
+
console.log(` prisma/schema.prisma (${modelName} model added)`);
|
|
162
|
+
}
|
|
163
|
+
if (!options.skipController) {
|
|
164
|
+
console.log(` src/controllers/${controllerName}.ts`);
|
|
165
|
+
}
|
|
166
|
+
if (!options.skipViews && !options.apiOnly) {
|
|
167
|
+
console.log(` src/views/${resourcePath}/index.ejs`);
|
|
168
|
+
console.log(` src/views/${resourcePath}/show.ejs`);
|
|
169
|
+
console.log(` src/views/${resourcePath}/create.ejs`);
|
|
170
|
+
console.log(` src/views/${resourcePath}/edit.ejs`);
|
|
171
|
+
}
|
|
172
|
+
console.log(`
|
|
173
|
+
Routes:
|
|
174
|
+
GET /${resourcePath} → List all ${resourcePath}
|
|
175
|
+
GET /${resourcePath}/create → Show create form
|
|
176
|
+
POST /${resourcePath} → Create new ${modelName.toLowerCase()}
|
|
177
|
+
GET /${resourcePath}/:id → Show ${modelName.toLowerCase()}
|
|
178
|
+
GET /${resourcePath}/:id/edit → Show edit form
|
|
179
|
+
PUT /${resourcePath}/:id → Update ${modelName.toLowerCase()}
|
|
180
|
+
DELETE /${resourcePath}/:id → Delete ${modelName.toLowerCase()}
|
|
181
|
+
|
|
182
|
+
Next steps:
|
|
183
|
+
1. Add routes to src/server.ts:
|
|
184
|
+
|
|
185
|
+
import * as ${controllerName} from "./controllers/${controllerName}";
|
|
186
|
+
|
|
187
|
+
app.get("/${resourcePath}", ${controllerName}.index);
|
|
188
|
+
app.get("/${resourcePath}/create", ${controllerName}.create);
|
|
189
|
+
app.post("/${resourcePath}", ${controllerName}.store);
|
|
190
|
+
app.get("/${resourcePath}/:id", ${controllerName}.show);
|
|
191
|
+
app.get("/${resourcePath}/:id/edit", ${controllerName}.edit);
|
|
192
|
+
app.put("/${resourcePath}/:id", ${controllerName}.update);
|
|
193
|
+
app.delete("/${resourcePath}/:id", ${controllerName}.destroy);
|
|
194
|
+
|
|
195
|
+
2. Edit the model in prisma/schema.prisma to add your fields
|
|
196
|
+
3. Run: npx prisma migrate dev --name add-${resourcePath}-fields
|
|
197
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
198
|
+
`);
|
|
199
|
+
}
|
|
@@ -68,6 +68,10 @@ async function initApp(dir, options = {}) {
|
|
|
68
68
|
if (options.withDatabase) {
|
|
69
69
|
setupDatabase(targetDir);
|
|
70
70
|
}
|
|
71
|
+
// Setup CI if requested
|
|
72
|
+
if (options.withCi) {
|
|
73
|
+
setupCi(targetDir);
|
|
74
|
+
}
|
|
71
75
|
console.log(`
|
|
72
76
|
Next steps:
|
|
73
77
|
cd ${dir}
|
|
@@ -81,6 +85,19 @@ To add database support later:
|
|
|
81
85
|
npx prisma migrate dev --name init
|
|
82
86
|
` : ""}`);
|
|
83
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Setup GitHub Actions CI workflow.
|
|
90
|
+
*/
|
|
91
|
+
function setupCi(targetDir) {
|
|
92
|
+
const workflowDir = path_1.default.join(targetDir, ".github", "workflows");
|
|
93
|
+
fs_1.default.mkdirSync(workflowDir, { recursive: true });
|
|
94
|
+
const ciTemplateDir = path_1.default.join((0, paths_1.getTemplatesDir)(), "ci");
|
|
95
|
+
const testWorkflow = path_1.default.join(ciTemplateDir, "test.yml");
|
|
96
|
+
if (fs_1.default.existsSync(testWorkflow)) {
|
|
97
|
+
fs_1.default.copyFileSync(testWorkflow, path_1.default.join(workflowDir, "test.yml"));
|
|
98
|
+
console.log("GitHub Actions CI workflow added.");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
84
101
|
/**
|
|
85
102
|
* Setup Prisma database support.
|
|
86
103
|
*/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for generators.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Capitalize the first letter of a string.
|
|
6
|
+
*/
|
|
7
|
+
export declare function capitalize(str: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Pluralize a word with common English rules.
|
|
10
|
+
*/
|
|
11
|
+
export declare function pluralize(str: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Convert to kebab-case (for URLs).
|
|
14
|
+
*/
|
|
15
|
+
export declare function kebabCase(str: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Convert to snake_case (for database tables).
|
|
18
|
+
*/
|
|
19
|
+
export declare function snakeCase(str: string): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared utilities for generators.
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.capitalize = capitalize;
|
|
7
|
+
exports.pluralize = pluralize;
|
|
8
|
+
exports.kebabCase = kebabCase;
|
|
9
|
+
exports.snakeCase = snakeCase;
|
|
10
|
+
/**
|
|
11
|
+
* Capitalize the first letter of a string.
|
|
12
|
+
*/
|
|
13
|
+
function capitalize(str) {
|
|
14
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Pluralize a word with common English rules.
|
|
18
|
+
*/
|
|
19
|
+
function pluralize(str) {
|
|
20
|
+
const lower = str.toLowerCase();
|
|
21
|
+
// Irregular plurals
|
|
22
|
+
const irregulars = {
|
|
23
|
+
person: "people",
|
|
24
|
+
child: "children",
|
|
25
|
+
man: "men",
|
|
26
|
+
woman: "women",
|
|
27
|
+
tooth: "teeth",
|
|
28
|
+
foot: "feet",
|
|
29
|
+
mouse: "mice",
|
|
30
|
+
goose: "geese",
|
|
31
|
+
};
|
|
32
|
+
if (irregulars[lower]) {
|
|
33
|
+
// Preserve original casing
|
|
34
|
+
return str.charAt(0) === str.charAt(0).toUpperCase()
|
|
35
|
+
? capitalize(irregulars[lower])
|
|
36
|
+
: irregulars[lower];
|
|
37
|
+
}
|
|
38
|
+
// Already plural or uncountable
|
|
39
|
+
if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z")) {
|
|
40
|
+
if (lower.endsWith("ss") || lower.endsWith("us") || lower.endsWith("is")) {
|
|
41
|
+
return str + "es";
|
|
42
|
+
}
|
|
43
|
+
return str;
|
|
44
|
+
}
|
|
45
|
+
// Words ending in consonant + y
|
|
46
|
+
if (lower.endsWith("y") && !/[aeiou]y$/.test(lower)) {
|
|
47
|
+
return str.slice(0, -1) + "ies";
|
|
48
|
+
}
|
|
49
|
+
// Words ending in ch, sh, x, z, s
|
|
50
|
+
if (/(?:ch|sh|x|z|s)$/.test(lower)) {
|
|
51
|
+
return str + "es";
|
|
52
|
+
}
|
|
53
|
+
// Words ending in f or fe
|
|
54
|
+
if (lower.endsWith("f")) {
|
|
55
|
+
return str.slice(0, -1) + "ves";
|
|
56
|
+
}
|
|
57
|
+
if (lower.endsWith("fe")) {
|
|
58
|
+
return str.slice(0, -2) + "ves";
|
|
59
|
+
}
|
|
60
|
+
// Default: add s
|
|
61
|
+
return str + "s";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Convert to kebab-case (for URLs).
|
|
65
|
+
*/
|
|
66
|
+
function kebabCase(str) {
|
|
67
|
+
return str
|
|
68
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
69
|
+
.replace(/[\s_]+/g, "-")
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Convert to snake_case (for database tables).
|
|
74
|
+
*/
|
|
75
|
+
function snakeCase(str) {
|
|
76
|
+
return str
|
|
77
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
78
|
+
.replace(/[\s-]+/g, "_")
|
|
79
|
+
.toLowerCase();
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
# Uncomment to add PostgreSQL for database tests
|
|
14
|
+
# services:
|
|
15
|
+
# postgres:
|
|
16
|
+
# image: postgres:16
|
|
17
|
+
# env:
|
|
18
|
+
# POSTGRES_USER: postgres
|
|
19
|
+
# POSTGRES_PASSWORD: postgres
|
|
20
|
+
# POSTGRES_DB: test
|
|
21
|
+
# ports:
|
|
22
|
+
# - 5432:5432
|
|
23
|
+
# options: >-
|
|
24
|
+
# --health-cmd pg_isready
|
|
25
|
+
# --health-interval 10s
|
|
26
|
+
# --health-timeout 5s
|
|
27
|
+
# --health-retries 5
|
|
28
|
+
|
|
29
|
+
steps:
|
|
30
|
+
- name: Checkout
|
|
31
|
+
uses: actions/checkout@v4
|
|
32
|
+
|
|
33
|
+
- name: Setup Node.js
|
|
34
|
+
uses: actions/setup-node@v4
|
|
35
|
+
with:
|
|
36
|
+
node-version: "20"
|
|
37
|
+
cache: "npm"
|
|
38
|
+
|
|
39
|
+
- name: Install dependencies
|
|
40
|
+
run: npm ci
|
|
41
|
+
|
|
42
|
+
- name: Run tests
|
|
43
|
+
run: npm test
|
|
44
|
+
# Uncomment for database tests
|
|
45
|
+
# env:
|
|
46
|
+
# DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
|
|
47
|
+
|
|
48
|
+
- name: Build
|
|
49
|
+
run: npm run build
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Request, Response } from "express";
|
|
2
|
+
import { getPrismaClient } from "@erwininteractive/mvc";
|
|
3
|
+
|
|
4
|
+
const prisma = getPrismaClient();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /<%= resourcePath %>
|
|
8
|
+
* List all <%= lowerModelName %> records.
|
|
9
|
+
*/
|
|
10
|
+
export async function index(req: Request, res: Response): Promise<void> {
|
|
11
|
+
try {
|
|
12
|
+
const items = await prisma.<%= lowerModelName %>.findMany({
|
|
13
|
+
orderBy: { createdAt: "desc" },
|
|
14
|
+
});
|
|
15
|
+
res.render("<%= resourcePath %>/index", {
|
|
16
|
+
title: "<%= modelName %> List",
|
|
17
|
+
items,
|
|
18
|
+
});
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error("Error fetching <%= resourcePath %>:", error);
|
|
21
|
+
res.status(500).render("error", {
|
|
22
|
+
title: "Error",
|
|
23
|
+
message: "Failed to load <%= resourcePath %>",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* GET /<%= resourcePath %>/create
|
|
30
|
+
* Show form to create a new <%= lowerModelName %>.
|
|
31
|
+
*/
|
|
32
|
+
export async function create(req: Request, res: Response): Promise<void> {
|
|
33
|
+
res.render("<%= resourcePath %>/create", {
|
|
34
|
+
title: "Create <%= modelName %>",
|
|
35
|
+
item: {},
|
|
36
|
+
errors: {},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* POST /<%= resourcePath %>
|
|
42
|
+
* Create a new <%= lowerModelName %> record.
|
|
43
|
+
*/
|
|
44
|
+
export async function store(req: Request, res: Response): Promise<void> {
|
|
45
|
+
try {
|
|
46
|
+
const data = req.body;
|
|
47
|
+
const item = await prisma.<%= lowerModelName %>.create({ data });
|
|
48
|
+
res.redirect(`/<%= resourcePath %>/${item.id}`);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error("Error creating <%= lowerModelName %>:", error);
|
|
51
|
+
res.status(400).render("<%= resourcePath %>/create", {
|
|
52
|
+
title: "Create <%= modelName %>",
|
|
53
|
+
item: req.body,
|
|
54
|
+
errors: { _form: "Failed to create <%= lowerModelName %>. Please check your input." },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* GET /<%= resourcePath %>/:id
|
|
61
|
+
* Show a single <%= lowerModelName %> record.
|
|
62
|
+
*/
|
|
63
|
+
export async function show(req: Request, res: Response): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
const id = Number(req.params.id);
|
|
66
|
+
if (isNaN(id)) {
|
|
67
|
+
res.status(400).render("error", { title: "Error", message: "Invalid ID" });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const item = await prisma.<%= lowerModelName %>.findUnique({ where: { id } });
|
|
72
|
+
if (!item) {
|
|
73
|
+
res.status(404).render("error", {
|
|
74
|
+
title: "Not Found",
|
|
75
|
+
message: "<%= modelName %> not found",
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
res.render("<%= resourcePath %>/show", {
|
|
81
|
+
title: "<%= modelName %> Details",
|
|
82
|
+
item,
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error("Error fetching <%= lowerModelName %>:", error);
|
|
86
|
+
res.status(500).render("error", {
|
|
87
|
+
title: "Error",
|
|
88
|
+
message: "Failed to load <%= lowerModelName %>",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* GET /<%= resourcePath %>/:id/edit
|
|
95
|
+
* Show form to edit a <%= lowerModelName %>.
|
|
96
|
+
*/
|
|
97
|
+
export async function edit(req: Request, res: Response): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
const id = Number(req.params.id);
|
|
100
|
+
if (isNaN(id)) {
|
|
101
|
+
res.status(400).render("error", { title: "Error", message: "Invalid ID" });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const item = await prisma.<%= lowerModelName %>.findUnique({ where: { id } });
|
|
106
|
+
if (!item) {
|
|
107
|
+
res.status(404).render("error", {
|
|
108
|
+
title: "Not Found",
|
|
109
|
+
message: "<%= modelName %> not found",
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
res.render("<%= resourcePath %>/edit", {
|
|
115
|
+
title: "Edit <%= modelName %>",
|
|
116
|
+
item,
|
|
117
|
+
errors: {},
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error("Error fetching <%= lowerModelName %>:", error);
|
|
121
|
+
res.status(500).render("error", {
|
|
122
|
+
title: "Error",
|
|
123
|
+
message: "Failed to load <%= lowerModelName %>",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* PUT /<%= resourcePath %>/:id
|
|
130
|
+
* Update a <%= lowerModelName %> record.
|
|
131
|
+
*/
|
|
132
|
+
export async function update(req: Request, res: Response): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
const id = Number(req.params.id);
|
|
135
|
+
if (isNaN(id)) {
|
|
136
|
+
res.status(400).render("error", { title: "Error", message: "Invalid ID" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const data = req.body;
|
|
141
|
+
await prisma.<%= lowerModelName %>.update({
|
|
142
|
+
where: { id },
|
|
143
|
+
data,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
res.redirect(`/<%= resourcePath %>/${id}`);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error("Error updating <%= lowerModelName %>:", error);
|
|
149
|
+
res.status(400).render("<%= resourcePath %>/edit", {
|
|
150
|
+
title: "Edit <%= modelName %>",
|
|
151
|
+
item: { id: req.params.id, ...req.body },
|
|
152
|
+
errors: { _form: "Failed to update <%= lowerModelName %>. Please check your input." },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* DELETE /<%= resourcePath %>/:id
|
|
159
|
+
* Delete a <%= lowerModelName %> record.
|
|
160
|
+
*/
|
|
161
|
+
export async function destroy(req: Request, res: Response): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
const id = Number(req.params.id);
|
|
164
|
+
if (isNaN(id)) {
|
|
165
|
+
res.status(400).json({ error: "Invalid ID" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await prisma.<%= lowerModelName %>.delete({ where: { id } });
|
|
170
|
+
|
|
171
|
+
// Check if request wants JSON response
|
|
172
|
+
if (req.accepts("json") && !req.accepts("html")) {
|
|
173
|
+
res.json({ success: true });
|
|
174
|
+
} else {
|
|
175
|
+
res.redirect("/<%= resourcePath %>");
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error("Error deleting <%= lowerModelName %>:", error);
|
|
179
|
+
res.status(500).json({ error: "Failed to delete <%= lowerModelName %>" });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%%= title %></title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
11
|
+
line-height: 1.6;
|
|
12
|
+
max-width: 600px;
|
|
13
|
+
margin: 0 auto;
|
|
14
|
+
padding: 2rem;
|
|
15
|
+
background: #f5f5f5;
|
|
16
|
+
}
|
|
17
|
+
h1 { color: #333; margin-bottom: 1rem; }
|
|
18
|
+
.breadcrumb {
|
|
19
|
+
margin-bottom: 1.5rem;
|
|
20
|
+
color: #666;
|
|
21
|
+
}
|
|
22
|
+
.breadcrumb a { color: #667eea; text-decoration: none; }
|
|
23
|
+
.breadcrumb a:hover { text-decoration: underline; }
|
|
24
|
+
.card {
|
|
25
|
+
background: white;
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
28
|
+
padding: 2rem;
|
|
29
|
+
}
|
|
30
|
+
.form-group { margin-bottom: 1.5rem; }
|
|
31
|
+
label {
|
|
32
|
+
display: block;
|
|
33
|
+
font-weight: 500;
|
|
34
|
+
margin-bottom: 0.5rem;
|
|
35
|
+
color: #333;
|
|
36
|
+
}
|
|
37
|
+
input[type="text"],
|
|
38
|
+
input[type="email"],
|
|
39
|
+
input[type="number"],
|
|
40
|
+
input[type="date"],
|
|
41
|
+
textarea,
|
|
42
|
+
select {
|
|
43
|
+
width: 100%;
|
|
44
|
+
padding: 0.75rem;
|
|
45
|
+
border: 1px solid #ddd;
|
|
46
|
+
border-radius: 6px;
|
|
47
|
+
font-size: 1rem;
|
|
48
|
+
transition: border-color 0.2s;
|
|
49
|
+
}
|
|
50
|
+
input:focus, textarea:focus, select:focus {
|
|
51
|
+
outline: none;
|
|
52
|
+
border-color: #667eea;
|
|
53
|
+
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
54
|
+
}
|
|
55
|
+
textarea { min-height: 120px; resize: vertical; }
|
|
56
|
+
.error-message {
|
|
57
|
+
color: #dc3545;
|
|
58
|
+
font-size: 0.875rem;
|
|
59
|
+
margin-top: 0.25rem;
|
|
60
|
+
}
|
|
61
|
+
.form-error {
|
|
62
|
+
background: #f8d7da;
|
|
63
|
+
color: #721c24;
|
|
64
|
+
padding: 1rem;
|
|
65
|
+
border-radius: 6px;
|
|
66
|
+
margin-bottom: 1.5rem;
|
|
67
|
+
}
|
|
68
|
+
.actions {
|
|
69
|
+
display: flex;
|
|
70
|
+
gap: 0.75rem;
|
|
71
|
+
margin-top: 1.5rem;
|
|
72
|
+
padding-top: 1.5rem;
|
|
73
|
+
border-top: 1px solid #eee;
|
|
74
|
+
}
|
|
75
|
+
.btn {
|
|
76
|
+
display: inline-block;
|
|
77
|
+
padding: 0.75rem 1.5rem;
|
|
78
|
+
background: #667eea;
|
|
79
|
+
color: white;
|
|
80
|
+
text-decoration: none;
|
|
81
|
+
border-radius: 6px;
|
|
82
|
+
border: none;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
font-size: 1rem;
|
|
85
|
+
font-weight: 500;
|
|
86
|
+
}
|
|
87
|
+
.btn:hover { background: #5a6fd6; }
|
|
88
|
+
.btn-secondary { background: #6c757d; }
|
|
89
|
+
.btn-secondary:hover { background: #5a6268; }
|
|
90
|
+
</style>
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
<div class="breadcrumb">
|
|
94
|
+
<a href="/<%= resourcePath %>"><%= modelName %> List</a> / Create New
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<h1><%%= title %></h1>
|
|
98
|
+
|
|
99
|
+
<div class="card">
|
|
100
|
+
<%% if (errors && errors._form) { %>
|
|
101
|
+
<div class="form-error"><%%= errors._form %></div>
|
|
102
|
+
<%% } %>
|
|
103
|
+
|
|
104
|
+
<form method="POST" action="/<%= resourcePath %>">
|
|
105
|
+
<!--
|
|
106
|
+
Add your form fields here. Example:
|
|
107
|
+
|
|
108
|
+
<div class="form-group">
|
|
109
|
+
<label for="title">Title</label>
|
|
110
|
+
<input type="text" id="title" name="title" value="<%%- item.title || '' %>" required>
|
|
111
|
+
<%% if (errors && errors.title) { %>
|
|
112
|
+
<div class="error-message"><%%= errors.title %></div>
|
|
113
|
+
<%% } %>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div class="form-group">
|
|
117
|
+
<label for="description">Description</label>
|
|
118
|
+
<textarea id="description" name="description"><%%- item.description || '' %></textarea>
|
|
119
|
+
</div>
|
|
120
|
+
-->
|
|
121
|
+
|
|
122
|
+
<div class="form-group">
|
|
123
|
+
<label for="placeholder">Placeholder Field</label>
|
|
124
|
+
<input type="text" id="placeholder" name="placeholder" placeholder="Edit this form to add your fields">
|
|
125
|
+
<p style="color: #666; font-size: 0.875rem; margin-top: 0.5rem;">
|
|
126
|
+
Edit this template at <code>src/views/<%= resourcePath %>/create.ejs</code> to add your model fields.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="actions">
|
|
131
|
+
<button type="submit" class="btn">Create <%= modelName %></button>
|
|
132
|
+
<a href="/<%= resourcePath %>" class="btn btn-secondary">Cancel</a>
|
|
133
|
+
</div>
|
|
134
|
+
</form>
|
|
135
|
+
</div>
|
|
136
|
+
</body>
|
|
137
|
+
</html>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%%= title %></title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
11
|
+
line-height: 1.6;
|
|
12
|
+
max-width: 600px;
|
|
13
|
+
margin: 0 auto;
|
|
14
|
+
padding: 2rem;
|
|
15
|
+
background: #f5f5f5;
|
|
16
|
+
}
|
|
17
|
+
h1 { color: #333; margin-bottom: 1rem; }
|
|
18
|
+
.breadcrumb {
|
|
19
|
+
margin-bottom: 1.5rem;
|
|
20
|
+
color: #666;
|
|
21
|
+
}
|
|
22
|
+
.breadcrumb a { color: #667eea; text-decoration: none; }
|
|
23
|
+
.breadcrumb a:hover { text-decoration: underline; }
|
|
24
|
+
.card {
|
|
25
|
+
background: white;
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
28
|
+
padding: 2rem;
|
|
29
|
+
}
|
|
30
|
+
.form-group { margin-bottom: 1.5rem; }
|
|
31
|
+
label {
|
|
32
|
+
display: block;
|
|
33
|
+
font-weight: 500;
|
|
34
|
+
margin-bottom: 0.5rem;
|
|
35
|
+
color: #333;
|
|
36
|
+
}
|
|
37
|
+
input[type="text"],
|
|
38
|
+
input[type="email"],
|
|
39
|
+
input[type="number"],
|
|
40
|
+
input[type="date"],
|
|
41
|
+
textarea,
|
|
42
|
+
select {
|
|
43
|
+
width: 100%;
|
|
44
|
+
padding: 0.75rem;
|
|
45
|
+
border: 1px solid #ddd;
|
|
46
|
+
border-radius: 6px;
|
|
47
|
+
font-size: 1rem;
|
|
48
|
+
transition: border-color 0.2s;
|
|
49
|
+
}
|
|
50
|
+
input:focus, textarea:focus, select:focus {
|
|
51
|
+
outline: none;
|
|
52
|
+
border-color: #667eea;
|
|
53
|
+
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
54
|
+
}
|
|
55
|
+
textarea { min-height: 120px; resize: vertical; }
|
|
56
|
+
.error-message {
|
|
57
|
+
color: #dc3545;
|
|
58
|
+
font-size: 0.875rem;
|
|
59
|
+
margin-top: 0.25rem;
|
|
60
|
+
}
|
|
61
|
+
.form-error {
|
|
62
|
+
background: #f8d7da;
|
|
63
|
+
color: #721c24;
|
|
64
|
+
padding: 1rem;
|
|
65
|
+
border-radius: 6px;
|
|
66
|
+
margin-bottom: 1.5rem;
|
|
67
|
+
}
|
|
68
|
+
.meta {
|
|
69
|
+
font-size: 0.875rem;
|
|
70
|
+
color: #666;
|
|
71
|
+
margin-bottom: 1.5rem;
|
|
72
|
+
padding-bottom: 1rem;
|
|
73
|
+
border-bottom: 1px solid #eee;
|
|
74
|
+
}
|
|
75
|
+
.actions {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: 0.75rem;
|
|
78
|
+
margin-top: 1.5rem;
|
|
79
|
+
padding-top: 1.5rem;
|
|
80
|
+
border-top: 1px solid #eee;
|
|
81
|
+
}
|
|
82
|
+
.btn {
|
|
83
|
+
display: inline-block;
|
|
84
|
+
padding: 0.75rem 1.5rem;
|
|
85
|
+
background: #667eea;
|
|
86
|
+
color: white;
|
|
87
|
+
text-decoration: none;
|
|
88
|
+
border-radius: 6px;
|
|
89
|
+
border: none;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
font-size: 1rem;
|
|
92
|
+
font-weight: 500;
|
|
93
|
+
}
|
|
94
|
+
.btn:hover { background: #5a6fd6; }
|
|
95
|
+
.btn-secondary { background: #6c757d; }
|
|
96
|
+
.btn-secondary:hover { background: #5a6268; }
|
|
97
|
+
.btn-danger { background: #dc3545; }
|
|
98
|
+
.btn-danger:hover { background: #c82333; }
|
|
99
|
+
</style>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<div class="breadcrumb">
|
|
103
|
+
<a href="/<%= resourcePath %>"><%= modelName %> List</a> /
|
|
104
|
+
<a href="/<%= resourcePath %>/<%%= item.id %>"><%%= item.id %></a> / Edit
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<h1><%%= title %></h1>
|
|
108
|
+
|
|
109
|
+
<div class="card">
|
|
110
|
+
<div class="meta">
|
|
111
|
+
Editing <%= modelName %> #<%%= item.id %>
|
|
112
|
+
<%% if (item.updatedAt) { %>
|
|
113
|
+
· Last updated: <%%= new Date(item.updatedAt).toLocaleString() %>
|
|
114
|
+
<%% } %>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<%% if (errors && errors._form) { %>
|
|
118
|
+
<div class="form-error"><%%= errors._form %></div>
|
|
119
|
+
<%% } %>
|
|
120
|
+
|
|
121
|
+
<form method="POST" action="/<%= resourcePath %>/<%%= item.id %>?_method=PUT">
|
|
122
|
+
<!--
|
|
123
|
+
Add your form fields here. Example:
|
|
124
|
+
|
|
125
|
+
<div class="form-group">
|
|
126
|
+
<label for="title">Title</label>
|
|
127
|
+
<input type="text" id="title" name="title" value="<%%- item.title || '' %>" required>
|
|
128
|
+
<%% if (errors && errors.title) { %>
|
|
129
|
+
<div class="error-message"><%%= errors.title %></div>
|
|
130
|
+
<%% } %>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div class="form-group">
|
|
134
|
+
<label for="description">Description</label>
|
|
135
|
+
<textarea id="description" name="description"><%%- item.description || '' %></textarea>
|
|
136
|
+
</div>
|
|
137
|
+
-->
|
|
138
|
+
|
|
139
|
+
<div class="form-group">
|
|
140
|
+
<label for="placeholder">Placeholder Field</label>
|
|
141
|
+
<input type="text" id="placeholder" name="placeholder" placeholder="Edit this form to add your fields">
|
|
142
|
+
<p style="color: #666; font-size: 0.875rem; margin-top: 0.5rem;">
|
|
143
|
+
Edit this template at <code>src/views/<%= resourcePath %>/edit.ejs</code> to add your model fields.
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div class="actions">
|
|
148
|
+
<button type="submit" class="btn">Update <%= modelName %></button>
|
|
149
|
+
<a href="/<%= resourcePath %>/<%%= item.id %>" class="btn btn-secondary">Cancel</a>
|
|
150
|
+
<form method="POST" action="/<%= resourcePath %>/<%%= item.id %>?_method=DELETE" style="display:inline; margin-left: auto;">
|
|
151
|
+
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this <%= lowerName %>?')">Delete</button>
|
|
152
|
+
</form>
|
|
153
|
+
</div>
|
|
154
|
+
</form>
|
|
155
|
+
</div>
|
|
156
|
+
</body>
|
|
157
|
+
</html>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%%= title %></title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
11
|
+
line-height: 1.6;
|
|
12
|
+
max-width: 900px;
|
|
13
|
+
margin: 0 auto;
|
|
14
|
+
padding: 2rem;
|
|
15
|
+
background: #f5f5f5;
|
|
16
|
+
}
|
|
17
|
+
h1 { color: #333; margin-bottom: 1rem; }
|
|
18
|
+
.actions { margin-bottom: 1.5rem; }
|
|
19
|
+
.btn {
|
|
20
|
+
display: inline-block;
|
|
21
|
+
padding: 0.5rem 1rem;
|
|
22
|
+
background: #667eea;
|
|
23
|
+
color: white;
|
|
24
|
+
text-decoration: none;
|
|
25
|
+
border-radius: 6px;
|
|
26
|
+
border: none;
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
font-size: 1rem;
|
|
29
|
+
}
|
|
30
|
+
.btn:hover { background: #5a6fd6; }
|
|
31
|
+
.btn-danger { background: #dc3545; }
|
|
32
|
+
.btn-danger:hover { background: #c82333; }
|
|
33
|
+
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
|
|
34
|
+
table {
|
|
35
|
+
width: 100%;
|
|
36
|
+
border-collapse: collapse;
|
|
37
|
+
background: white;
|
|
38
|
+
border-radius: 8px;
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
41
|
+
}
|
|
42
|
+
th, td {
|
|
43
|
+
padding: 1rem;
|
|
44
|
+
text-align: left;
|
|
45
|
+
border-bottom: 1px solid #eee;
|
|
46
|
+
}
|
|
47
|
+
th { background: #f8f9fa; font-weight: 600; }
|
|
48
|
+
tr:hover { background: #f8f9fa; }
|
|
49
|
+
.empty {
|
|
50
|
+
text-align: center;
|
|
51
|
+
padding: 3rem;
|
|
52
|
+
color: #666;
|
|
53
|
+
}
|
|
54
|
+
.item-actions { white-space: nowrap; }
|
|
55
|
+
.item-actions a, .item-actions button { margin-right: 0.5rem; }
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<h1><%%= title %></h1>
|
|
60
|
+
|
|
61
|
+
<div class="actions">
|
|
62
|
+
<a href="/<%= resourcePath %>/create" class="btn">Create New <%= modelName %></a>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<%% if (items && items.length > 0) { %>
|
|
66
|
+
<table>
|
|
67
|
+
<thead>
|
|
68
|
+
<tr>
|
|
69
|
+
<th>ID</th>
|
|
70
|
+
<th>Created</th>
|
|
71
|
+
<th>Actions</th>
|
|
72
|
+
</tr>
|
|
73
|
+
</thead>
|
|
74
|
+
<tbody>
|
|
75
|
+
<%% items.forEach(item => { %>
|
|
76
|
+
<tr>
|
|
77
|
+
<td><%%= item.id %></td>
|
|
78
|
+
<td><%%= new Date(item.createdAt).toLocaleDateString() %></td>
|
|
79
|
+
<td class="item-actions">
|
|
80
|
+
<a href="/<%= resourcePath %>/<%%= item.id %>" class="btn btn-sm">View</a>
|
|
81
|
+
<a href="/<%= resourcePath %>/<%%= item.id %>/edit" class="btn btn-sm">Edit</a>
|
|
82
|
+
<form method="POST" action="/<%= resourcePath %>/<%%= item.id %>?_method=DELETE" style="display:inline">
|
|
83
|
+
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure?')">Delete</button>
|
|
84
|
+
</form>
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
<%% }); %>
|
|
88
|
+
</tbody>
|
|
89
|
+
</table>
|
|
90
|
+
<%% } else { %>
|
|
91
|
+
<div class="empty">
|
|
92
|
+
<p>No <%= resourcePath %> found.</p>
|
|
93
|
+
<a href="/<%= resourcePath %>/create" class="btn">Create your first <%= lowerName %></a>
|
|
94
|
+
</div>
|
|
95
|
+
<%% } %>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title><%%= title %></title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
11
|
+
line-height: 1.6;
|
|
12
|
+
max-width: 700px;
|
|
13
|
+
margin: 0 auto;
|
|
14
|
+
padding: 2rem;
|
|
15
|
+
background: #f5f5f5;
|
|
16
|
+
}
|
|
17
|
+
h1 { color: #333; margin-bottom: 1rem; }
|
|
18
|
+
.breadcrumb {
|
|
19
|
+
margin-bottom: 1.5rem;
|
|
20
|
+
color: #666;
|
|
21
|
+
}
|
|
22
|
+
.breadcrumb a { color: #667eea; text-decoration: none; }
|
|
23
|
+
.breadcrumb a:hover { text-decoration: underline; }
|
|
24
|
+
.card {
|
|
25
|
+
background: white;
|
|
26
|
+
border-radius: 8px;
|
|
27
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
28
|
+
padding: 2rem;
|
|
29
|
+
margin-bottom: 1.5rem;
|
|
30
|
+
}
|
|
31
|
+
.field { margin-bottom: 1.5rem; }
|
|
32
|
+
.field:last-child { margin-bottom: 0; }
|
|
33
|
+
.field-label {
|
|
34
|
+
font-size: 0.875rem;
|
|
35
|
+
color: #666;
|
|
36
|
+
margin-bottom: 0.25rem;
|
|
37
|
+
text-transform: uppercase;
|
|
38
|
+
letter-spacing: 0.05em;
|
|
39
|
+
}
|
|
40
|
+
.field-value {
|
|
41
|
+
font-size: 1.1rem;
|
|
42
|
+
color: #333;
|
|
43
|
+
}
|
|
44
|
+
.actions { display: flex; gap: 0.75rem; }
|
|
45
|
+
.btn {
|
|
46
|
+
display: inline-block;
|
|
47
|
+
padding: 0.5rem 1rem;
|
|
48
|
+
background: #667eea;
|
|
49
|
+
color: white;
|
|
50
|
+
text-decoration: none;
|
|
51
|
+
border-radius: 6px;
|
|
52
|
+
border: none;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
font-size: 1rem;
|
|
55
|
+
}
|
|
56
|
+
.btn:hover { background: #5a6fd6; }
|
|
57
|
+
.btn-secondary { background: #6c757d; }
|
|
58
|
+
.btn-secondary:hover { background: #5a6268; }
|
|
59
|
+
.btn-danger { background: #dc3545; }
|
|
60
|
+
.btn-danger:hover { background: #c82333; }
|
|
61
|
+
</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<div class="breadcrumb">
|
|
65
|
+
<a href="/<%= resourcePath %>"><%= modelName %> List</a> / Details
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<h1><%%= title %></h1>
|
|
69
|
+
|
|
70
|
+
<div class="card">
|
|
71
|
+
<div class="field">
|
|
72
|
+
<div class="field-label">ID</div>
|
|
73
|
+
<div class="field-value"><%%= item.id %></div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="field">
|
|
77
|
+
<div class="field-label">Created At</div>
|
|
78
|
+
<div class="field-value"><%%= new Date(item.createdAt).toLocaleString() %></div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="field">
|
|
82
|
+
<div class="field-label">Updated At</div>
|
|
83
|
+
<div class="field-value"><%%= new Date(item.updatedAt).toLocaleString() %></div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- Add more fields here as you add them to your model -->
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="actions">
|
|
90
|
+
<a href="/<%= resourcePath %>/<%%= item.id %>/edit" class="btn">Edit</a>
|
|
91
|
+
<a href="/<%= resourcePath %>" class="btn btn-secondary">Back to List</a>
|
|
92
|
+
<form method="POST" action="/<%= resourcePath %>/<%%= item.id %>?_method=DELETE" style="display:inline">
|
|
93
|
+
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this <%= lowerName %>?')">Delete</button>
|
|
94
|
+
</form>
|
|
95
|
+
</div>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|