@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 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 with the CLI:
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
- ### Generate Options
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.1.4");
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 controllers");
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
+ }
@@ -1,6 +1,7 @@
1
1
  export interface InitOptions {
2
2
  skipInstall?: boolean;
3
3
  withDatabase?: boolean;
4
+ withCi?: boolean;
4
5
  }
5
6
  /**
6
7
  * Scaffold a new MVC application.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erwininteractive/mvc",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "description": "A lightweight, full-featured MVC framework for Node.js with Express, Prisma, and EJS",
5
5
  "main": "dist/framework/index.js",
6
6
  "types": "dist/framework/index.d.ts",
@@ -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
+ &middot; 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>