stackloom-cli 1.0.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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/cli.js +306 -0
  4. package/branding.json +8 -0
  5. package/package.json +72 -0
  6. package/src/__tests__/cli-smoke.test.js +46 -0
  7. package/src/blueprint/__tests__/blueprint.test.js +116 -0
  8. package/src/blueprint/blueprint.js +181 -0
  9. package/src/blueprint/default.blueprint.json +78 -0
  10. package/src/blueprint/index.js +10 -0
  11. package/src/blueprint/loader.js +101 -0
  12. package/src/blueprint/schema-kit.js +161 -0
  13. package/src/blueprint/schema.js +78 -0
  14. package/src/branding/__tests__/branding.test.js +49 -0
  15. package/src/branding/index.js +48 -0
  16. package/src/commands/__tests__/commands.test.js +83 -0
  17. package/src/commands/check.js +71 -0
  18. package/src/commands/cleanup.js +347 -0
  19. package/src/commands/customize.js +263 -0
  20. package/src/commands/doctor.js +84 -0
  21. package/src/commands/env.js +75 -0
  22. package/src/commands/finalize.js +68 -0
  23. package/src/commands/generate/ci-cd.js +378 -0
  24. package/src/commands/generate/deploy-advanced.js +253 -0
  25. package/src/commands/generate/deploy.js +99 -0
  26. package/src/commands/generate/env.template.js +221 -0
  27. package/src/commands/generate/index.js +7 -0
  28. package/src/commands/generate/module.js +836 -0
  29. package/src/commands/generate/page.js +1415 -0
  30. package/src/commands/generate/test-scaffold.js +279 -0
  31. package/src/commands/generate/theme.js +67 -0
  32. package/src/commands/generate-resource.js +133 -0
  33. package/src/commands/index.js +9 -0
  34. package/src/commands/init.js +350 -0
  35. package/src/commands/make/resource.js +298 -0
  36. package/src/commands/preset.js +57 -0
  37. package/src/commands/remove.js +170 -0
  38. package/src/commands/rename.js +54 -0
  39. package/src/commands/rollback.js +90 -0
  40. package/src/commands/wizard.js +303 -0
  41. package/src/core/__tests__/generator.test.js +67 -0
  42. package/src/core/__tests__/marker-strategy.test.js +57 -0
  43. package/src/core/__tests__/resource-definition.test.js +32 -0
  44. package/src/core/generator.js +542 -0
  45. package/src/core/marker-strategy.js +138 -0
  46. package/src/core/resource-definition.js +346 -0
  47. package/src/core/state-tracker.js +67 -0
  48. package/src/core/template-loader.js +163 -0
  49. package/src/engine/__tests__/engine.test.js +306 -0
  50. package/src/engine/index.js +21 -0
  51. package/src/engine/injector.js +198 -0
  52. package/src/engine/pipeline.js +138 -0
  53. package/src/engine/transaction.js +105 -0
  54. package/src/engine/validator.js +190 -0
  55. package/src/index.js +4 -0
  56. package/src/recipes/__tests__/recipe.test.js +128 -0
  57. package/src/recipes/builtin/module.json +22 -0
  58. package/src/recipes/builtin/page.json +21 -0
  59. package/src/recipes/builtin/resource.json +35 -0
  60. package/src/recipes/condition.js +147 -0
  61. package/src/recipes/index.js +11 -0
  62. package/src/recipes/loader.js +95 -0
  63. package/src/recipes/recipe.js +89 -0
  64. package/src/recipes/schema.js +47 -0
  65. package/src/schemas/__tests__/schemas.test.js +67 -0
  66. package/src/schemas/index.js +18 -0
  67. package/src/schemas/options.js +38 -0
  68. package/src/schemas/resource.js +112 -0
  69. package/src/services/__tests__/reporter.test.js +98 -0
  70. package/src/services/clock.js +31 -0
  71. package/src/services/index.js +43 -0
  72. package/src/services/reporter.js +136 -0
  73. package/src/templates/resource/api.js.ejs +39 -0
  74. package/src/templates/resource/components/form.jsx.ejs +81 -0
  75. package/src/templates/resource/components/table.jsx.ejs +68 -0
  76. package/src/templates/resource/controller.js.ejs +154 -0
  77. package/src/templates/resource/hooks.js.ejs +46 -0
  78. package/src/templates/resource/model.js.ejs +64 -0
  79. package/src/templates/resource/page-detail.jsx.ejs +55 -0
  80. package/src/templates/resource/page-form.jsx.ejs +30 -0
  81. package/src/templates/resource/page-inline.jsx.ejs +74 -0
  82. package/src/templates/resource/page-modal.jsx.ejs +98 -0
  83. package/src/templates/resource/page-page.jsx.ejs +99 -0
  84. package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
  85. package/src/templates/resource/routes.js.ejs +35 -0
  86. package/src/templates/resource/service.js.ejs +132 -0
  87. package/src/templates/resource/test.ejs +71 -0
  88. package/src/templates/resource/types.ts.ejs +17 -0
  89. package/src/templates/resource/validator.js.ejs +26 -0
  90. package/src/templates/snippets/lazy-import.ejs +1 -0
  91. package/src/templates/snippets/nav-entry.ejs +1 -0
  92. package/src/templates/snippets/route-entry.ejs +5 -0
  93. package/src/templates/snippets/route-mount.ejs +1 -0
  94. package/src/utils/fieldValidators.js +371 -0
  95. package/src/utils/logging/logger.js +47 -0
  96. package/src/utils/namingUtils.js +38 -0
  97. package/src/utils/sanitize.js +200 -0
@@ -0,0 +1,1415 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import inquirer from "inquirer";
8
+ import { blueprintLoader } from '../../blueprint/index.js';
9
+
10
+ /**
11
+ * Resolve frontend/backend directory names via the architecture blueprint.
12
+ * Single source of truth — shared with core/generator.js, no duplicated candidate lists.
13
+ */
14
+ export async function getConfigPaths(projectRoot) {
15
+ const blueprint = await blueprintLoader.load(projectRoot);
16
+ return {
17
+ frontendDir: blueprint.resolveRoot('frontend', projectRoot),
18
+ backendDir: blueprint.resolveRoot('backend', projectRoot),
19
+ };
20
+ }
21
+
22
+ export default async function generatePageCmd(name, options) {
23
+ console.warn(
24
+ chalk.yellow(
25
+ "⚠ 'generate page' is superseded by 'loom generate resource --recipe page'\n" +
26
+ " (engine-backed: recipe-driven, transactional, validated). This command still works.",
27
+ ),
28
+ );
29
+ const spinner = ora();
30
+ const projectRoot = process.cwd();
31
+ const { frontendDir, backendDir } = await getConfigPaths(projectRoot);
32
+
33
+ if (!fs.existsSync(path.join(projectRoot, frontendDir, 'src/App.jsx'))) {
34
+ console.log(chalk.red('✖ Not a MERN Starter Kit frontend.'));
35
+ process.exit(1);
36
+ }
37
+
38
+ const isDashboard = name.toLowerCase() === "dashboard";
39
+ const pageName = isDashboard ? "Dashboard" : (name.charAt(0).toUpperCase() + name.slice(1));
40
+ const pageDir = path.join(projectRoot, frontendDir, 'src/pages', isDashboard ? "dashboard" : name);
41
+ const pageFile = path.join(pageDir, `${pageName}Page.jsx`);
42
+
43
+ if (fs.existsSync(pageFile)) {
44
+ if (options.force) {
45
+ spinner.warn(`${pageFile} exists — will overwrite (--force)`);
46
+ } else {
47
+ console.log(chalk.yellow(`⚠ Page ${pageName} already exists. Use --force to overwrite.`));
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ await fs.ensureDir(pageDir);
53
+
54
+ let formFields = [];
55
+ let formMode = "page";
56
+ if (options.withForm) {
57
+ formMode = options.formMode || "page";
58
+ if (options.formFields) {
59
+ formFields = parseFormFields(options.formFields);
60
+ } else if (options.interactive) {
61
+ formFields = await askFormFields();
62
+ } else {
63
+ formFields = [{ name: "name", type: "text", label: "Name", required: true }];
64
+ }
65
+ }
66
+
67
+ let pageContent;
68
+ if (isDashboard) {
69
+ pageContent = generateDashboardPage(pageName);
70
+ } else {
71
+ pageContent = generatePageComponent(pageName, name, formFields, formMode);
72
+
73
+ if (formFields.length > 0) {
74
+ const formDir = path.join(pageDir, "components");
75
+ await fs.ensureDir(formDir);
76
+ const formFile = path.join(formDir, `${pageName}Form.jsx`);
77
+ const formContent = generateFormComponent(pageName, formFields);
78
+ await fs.writeFile(formFile, formContent);
79
+ spinner.succeed(`Created form component: ${formFile}`);
80
+ }
81
+ }
82
+
83
+ await fs.writeFile(pageFile, pageContent);
84
+ spinner.succeed(`Created page: ${pageFile}`);
85
+
86
+ const routerPath = path.join(projectRoot, frontendDir, 'src/routes/AppRouter.jsx');
87
+ if (fs.existsSync(routerPath)) {
88
+ await updateRouter(routerPath, pageName, isDashboard ? "dashboard" : name, options.route, spinner);
89
+ } else {
90
+ console.log(chalk.yellow("⚠ AppRouter.jsx not found — add route manually."));
91
+ }
92
+
93
+ if (!options.noNav) {
94
+ await updateNavigation(path.join(projectRoot, frontendDir, 'src/config/app-preset.js'), pageName, options.route || `/${isDashboard ? "dashboard" : name}`, options.icon, spinner);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Parse page form field specification
100
+ * Format: "name:type:rule1|rule2;name2:type2:ruleA|ruleB"
101
+ */
102
+ export function parseFormFields(str) {
103
+ if (!str || typeof str !== "string") return [];
104
+
105
+ return str.split(";").map(fieldPart => {
106
+ const parts = fieldPart.split(":");
107
+ const name = parts[0]?.trim();
108
+ const type = (parts[1] || "text").trim();
109
+ const rulesPart = parts[2]?.trim() || "";
110
+
111
+ if (!name) return null;
112
+
113
+ const field = { name, type, label: name.charAt(0).toUpperCase() + name.slice(1) };
114
+
115
+ if (rulesPart) {
116
+ const rules = rulesPart.split("|");
117
+ rules.forEach(rule => {
118
+ const [key, value] = rule.split("=").map(s => s.trim());
119
+ if (!value && key === "required") {
120
+ field.required = true;
121
+ } else if (!value && key === "unique") {
122
+ field.unique = true;
123
+ } else if (value) {
124
+ const num = parseFloat(value);
125
+ field[key] = isNaN(num) ? value : num;
126
+ }
127
+ });
128
+ if (field.default !== undefined) {
129
+ field.defaultValue = field.default;
130
+ }
131
+ }
132
+
133
+ return field;
134
+ }).filter(Boolean);
135
+ }
136
+
137
+ export async function askFormFields() {
138
+ const { continueAdding } = await inquirer.prompt([
139
+ { type: "confirm", name: "continueAdding", message: "Add form fields?", default: true },
140
+ ]);
141
+
142
+ const fields = [];
143
+ while (continueAdding) {
144
+ const answers = await inquirer.prompt([
145
+ {
146
+ type: "input",
147
+ name: "name",
148
+ message: "Field name:",
149
+ validate: (v) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v) || "Valid JS identifier required"
150
+ },
151
+ {
152
+ type: "list",
153
+ name: "type",
154
+ message: "Field type:",
155
+ choices: [
156
+ { name: "Text (single line)", value: "text" },
157
+ { name: "Email", value: "email" },
158
+ { name: "Password", value: "password" },
159
+ { name: "Number", value: "number" },
160
+ { name: "Phone", value: "tel" },
161
+ { name: "URL", value: "url" },
162
+ { name: "Date", value: "date" },
163
+ { name: "DateTime", value: "datetime-local" },
164
+ { name: "Time", value: "time" },
165
+ { name: "Textarea", value: "textarea" },
166
+ { name: "Color", value: "color" },
167
+ { name: "Range", value: "range" },
168
+ { name: "File", value: "file" },
169
+ { name: "Hidden", value: "hidden" },
170
+ ],
171
+ },
172
+ {
173
+ type: "input",
174
+ name: "label",
175
+ message: "Label (optional):",
176
+ default: (prev) => prev.name.charAt(0).toUpperCase() + prev.name.slice(1)
177
+ },
178
+ {
179
+ type: "checkbox",
180
+ name: "validation",
181
+ message: "Validation rules:",
182
+ choices: [
183
+ { name: "Required", value: "required", checked: true },
184
+ { name: "Unique", value: "unique", checked: false },
185
+ ]
186
+ },
187
+ {
188
+ type: "input",
189
+ name: "minLength",
190
+ message: "Min length (optional):",
191
+ validate: (v) => !v || /^\d+$/.test(v) || "Enter a number or leave empty"
192
+ },
193
+ {
194
+ type: "input",
195
+ name: "maxLength",
196
+ message: "Max length (optional):",
197
+ validate: (v) => !v || /^\d+$/.test(v) || "Enter a number or leave empty"
198
+ },
199
+ {
200
+ type: "input",
201
+ name: "minValue",
202
+ message: "Min value (for numbers/dates):",
203
+ validate: (v) => !v || !isNaN(v) || "Enter a number/date or leave empty"
204
+ },
205
+ {
206
+ type: "input",
207
+ name: "maxValue",
208
+ message: "Max value (for numbers/dates):",
209
+ validate: (v) => !v || !isNaN(v) || "Enter a number/date or leave empty"
210
+ },
211
+ {
212
+ type: "input",
213
+ name: "pattern",
214
+ message: "Regex pattern (e.g., /^[A-Z]+$/):",
215
+ validate: (v) => !v || v.startsWith("/") || "Enter regex like /^[A-Z]+$/ or leave empty"
216
+ },
217
+ {
218
+ type: "input",
219
+ name: "placeholder",
220
+ message: "Placeholder text (optional):"
221
+ },
222
+ {
223
+ type: "input",
224
+ name: "helperText",
225
+ message: "Helper/instructions text (optional):"
226
+ },
227
+ ]);
228
+
229
+ const field = {
230
+ name: answers.name,
231
+ type: answers.type,
232
+ label: answers.label,
233
+ required: answers.validation.includes("required"),
234
+ unique: answers.validation.includes("unique"),
235
+ placeholder: answers.placeholder,
236
+ helperText: answers.helperText,
237
+ };
238
+
239
+ if (answers.minLength) field.minLength = parseInt(answers.minLength);
240
+ if (answers.maxLength) field.maxLength = parseInt(answers.maxLength);
241
+ if (answers.minValue !== undefined && answers.minValue !== "") field.min = parseFloat(answers.minValue);
242
+ if (answers.maxValue !== undefined && answers.maxValue !== "") field.max = parseFloat(answers.maxValue);
243
+ if (answers.pattern) field.pattern = answers.pattern;
244
+
245
+ if (field.type === "range") {
246
+ field.min = field.min ?? 0;
247
+ field.max = field.max ?? 100;
248
+ field.step = field.step ?? 1;
249
+ }
250
+
251
+ fields.push(field);
252
+ const { more } = await inquirer.prompt([{ type: "confirm", name: "more", message: "Add another field?", default: false }]);
253
+ if (!more) break;
254
+ }
255
+ return fields;
256
+ }
257
+
258
+ function singularize(word) {
259
+ if (word.endsWith('ies')) return word.slice(0, -3) + 'y';
260
+ // Words ending in ch, sh, ss, s, x, z, o + es -> remove 'es'
261
+ if (word.endsWith('es') && word.length > 2) {
262
+ const stem = word.slice(0, -2); // remove 'es' to get stem
263
+ if (stem.endsWith('s') || stem.endsWith('x') || stem.endsWith('z') || stem.endsWith('o')) {
264
+ return stem; // classes -> class, boxes -> box
265
+ }
266
+ }
267
+ if (word.endsWith('s') && !word.endsWith('ss') && !word.endsWith('us')) return word.slice(0, -1);
268
+ return word;
269
+ }
270
+
271
+ function generatePageComponent(pageName, routeName, formFields, formMode = "page") {
272
+ const formComponentName = `${pageName}Form`;
273
+ const singularName = singularize(pageName);
274
+
275
+ const pageImports = formFields.length ?
276
+ `import { ${formComponentName} } from "./components/${formComponentName}";\n` : "";
277
+
278
+ // Helper data for table and list
279
+ const displayFields = formFields.filter(f => !['file', 'hidden', 'password'].includes(f.type));
280
+ const tableHeaders = displayFields.map(f =>
281
+ ` <TableHead>${f.label || f.name.charAt(0).toUpperCase() + f.name.slice(1)}</TableHead>`
282
+ ).join('\n');
283
+ const tableCells = displayFields.map(f =>
284
+ ' <TableCell>{String(item.' + f.name + ' || "")}</TableCell>'
285
+ ).join('\n');
286
+
287
+ const dropdownActions = ` <DropdownMenu>
288
+ <DropdownMenuTrigger asChild>
289
+ <Button variant="ghost" size="icon">
290
+ <MoreHorizontal className="h-4 w-4" />
291
+ </Button>
292
+ </DropdownMenuTrigger>
293
+ <DropdownMenuContent align="end">
294
+ <DropdownMenuItem onClick={() => handleEdit(item)}>
295
+ <Pencil className="mr-2 h-4 w-4" />
296
+ Edit
297
+ </DropdownMenuItem>
298
+ <DropdownMenuItem onClick={() => handleDelete(item._id || item.id)} className="text-destructive">
299
+ <Trash2 className="mr-2 h-4 w-4" />
300
+ Delete
301
+ </DropdownMenuItem>
302
+ </DropdownMenuContent>
303
+ </DropdownMenu>`;
304
+
305
+ const simpleActions = ` <div className="flex gap-2">
306
+ <Button size="sm" variant="outline" onClick={() => handleEdit(item)}>Edit</Button>
307
+ <Button size="sm" variant="destructive" onClick={() => handleDelete(item._id || item.id)}>Delete</Button>
308
+ </div>`;
309
+
310
+ if (formMode === "modal") {
311
+ const modalImports = `import { useState, useEffect } from "react";
312
+ import { toast } from "sonner";
313
+ import { PageWrapper } from "@/components/layout/PageWrapper";
314
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
315
+ import { Button } from "@/components/ui/button";
316
+ import { api } from "@/api/axiosInstance";
317
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
318
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
319
+ import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
320
+ ${pageImports}`;
321
+
322
+ return `${modalImports}export default function ${pageName}Page() {
323
+ const [items, setItems] = useState([]);
324
+ const [loading, setLoading] = useState(true);
325
+ const [showForm, setShowForm] = useState(false);
326
+ const [editingId, setEditingId] = useState(null);
327
+
328
+ useEffect(() => {
329
+ fetchItems();
330
+ }, []);
331
+
332
+ const fetchItems = async () => {
333
+ setLoading(true);
334
+ try {
335
+ const { data } = await api.get("/${pageName.toLowerCase()}");
336
+ setItems(data?.data || []);
337
+ } catch (err) {
338
+ toast.error("Failed to load ${singularName.toLowerCase()}s");
339
+ console.error(err);
340
+ } finally {
341
+ setLoading(false);
342
+ }
343
+ };
344
+
345
+ const handleEdit = (item) => {
346
+ setEditingId(item._id || item.id);
347
+ setShowForm(true);
348
+ };
349
+
350
+ const handleDelete = async (id) => {
351
+ if (!confirm("Are you sure you want to delete this ${singularName.toLowerCase()}?")) return;
352
+ try {
353
+ await api.delete(\`/${pageName.toLowerCase()}/\${id}\`);
354
+ toast.success("${singularName} deleted successfully");
355
+ fetchItems();
356
+ } catch (err) {
357
+ toast.error("Failed to delete ${singularName.toLowerCase()}");
358
+ }
359
+ };
360
+
361
+ const handleSuccess = () => {
362
+ setShowForm(false);
363
+ setEditingId(null);
364
+ fetchItems();
365
+ };
366
+
367
+ return (
368
+ <PageWrapper className="space-y-6">
369
+ <section>
370
+ <h1 className="text-3xl font-semibold">${pageName}</h1>
371
+ <p className="text-muted-foreground">Manage ${routeName.toLowerCase()} here.</p>
372
+ </section>
373
+
374
+ <section>
375
+ <Button onClick={() => { setEditingId(null); setShowForm(true); }}>
376
+ Add New ${singularName}
377
+ </Button>
378
+ </section>
379
+
380
+ <section className="bg-card p-6 rounded-lg border">
381
+ <h2 className="text-xl font-medium mb-4">All Items</h2>
382
+ {loading ? (
383
+ <p>Loading...</p>
384
+ ) : items.length === 0 ? (
385
+ <p className="text-muted-foreground">No ${singularName.toLowerCase()}s yet. Click "Add New ${singularName}" to get started.</p>
386
+ ) : (
387
+ <Table>
388
+ <TableHeader>
389
+ <TableRow>
390
+ ${tableHeaders}
391
+ <TableHead className="text-right">Actions</TableHead>
392
+ </TableRow>
393
+ </TableHeader>
394
+ <TableBody>
395
+ {items.map((item) => (
396
+ <TableRow key={item._id || item.id}>
397
+ ${tableCells}
398
+ <TableCell className="text-right">
399
+ ${dropdownActions}
400
+ </TableCell>
401
+ </TableRow>
402
+ ))}
403
+ </TableBody>
404
+ </Table>
405
+ )}
406
+ </section>
407
+
408
+ <Dialog open={showForm} onOpenChange={(open) => { setShowForm(open); if (!open) setEditingId(null); }}>
409
+ <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
410
+ <DialogHeader>
411
+ <DialogTitle>{editingId ? "Edit ${singularName}" : "Create ${singularName}"}</DialogTitle>
412
+ <DialogDescription>
413
+ {editingId ? "Update the details below." : "Fill in the details below to create a new ${singularName.toLowerCase()}."}
414
+ </DialogDescription>
415
+ </DialogHeader>
416
+ <${formComponentName} onSuccess={handleSuccess} editId={editingId} />
417
+ </DialogContent>
418
+ </Dialog>
419
+ </PageWrapper>
420
+ );
421
+ }
422
+ `;
423
+ }
424
+
425
+ if (formMode === "sidepanel") {
426
+ const sidepanelImports = `import { useState, useEffect } from "react";
427
+ import { toast } from "sonner";
428
+ import { PageWrapper } from "@/components/layout/PageWrapper";
429
+ import { Button } from "@/components/ui/button";
430
+ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
431
+ import { api } from "@/api/axiosInstance";
432
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
433
+ import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
434
+ ${pageImports}`;
435
+
436
+ return `${sidepanelImports}export default function ${pageName}Page() {
437
+ const [items, setItems] = useState([]);
438
+ const [loading, setLoading] = useState(true);
439
+ const [showForm, setShowForm] = useState(false);
440
+ const [editingId, setEditingId] = useState(null);
441
+
442
+ useEffect(() => {
443
+ fetchItems();
444
+ }, []);
445
+
446
+ const fetchItems = async () => {
447
+ setLoading(true);
448
+ try {
449
+ const { data } = await api.get("/${pageName.toLowerCase()}");
450
+ setItems(data?.data || []);
451
+ } catch (err) {
452
+ toast.error("Failed to load ${singularName.toLowerCase()}s");
453
+ console.error(err);
454
+ } finally {
455
+ setLoading(false);
456
+ }
457
+ };
458
+
459
+ const handleEdit = (item) => {
460
+ setEditingId(item._id || item.id);
461
+ setShowForm(true);
462
+ };
463
+
464
+ const handleDelete = async (id) => {
465
+ if (!confirm("Are you sure you want to delete this ${singularName.toLowerCase()}?")) return;
466
+ try {
467
+ await api.delete(\`/${pageName.toLowerCase()}/\${id}\`);
468
+ toast.success("${singularName} deleted successfully");
469
+ fetchItems();
470
+ } catch (err) {
471
+ toast.error("Failed to delete ${singularName.toLowerCase()}");
472
+ }
473
+ };
474
+
475
+ const handleSuccess = () => {
476
+ setShowForm(false);
477
+ setEditingId(null);
478
+ fetchItems();
479
+ };
480
+
481
+ return (
482
+ <PageWrapper className="space-y-6">
483
+ <section>
484
+ <h1 className="text-3xl font-semibold">${pageName}</h1>
485
+ <p className="text-muted-foreground">Manage ${routeName.toLowerCase()} here.</p>
486
+ </section>
487
+
488
+ <section>
489
+ <Button onClick={() => { setEditingId(null); setShowForm(true); }}>
490
+ Add New ${singularName}
491
+ </Button>
492
+ </section>
493
+
494
+ <section className="bg-card p-6 rounded-lg border">
495
+ <h2 className="text-xl font-medium mb-4">All Items</h2>
496
+ {loading ? (
497
+ <p>Loading...</p>
498
+ ) : items.length === 0 ? (
499
+ <p className="text-muted-foreground">No ${singularName.toLowerCase()}s yet.</p>
500
+ ) : (
501
+ <div className="space-y-2">
502
+ {items.map((item) => (
503
+ <div key={item._id || item.id} className="flex items-center justify-between p-3 border rounded-lg">
504
+ <div>
505
+ ${displayFields.length ? displayFields.map(f => '<span className="font-medium">' + f.label + ':</span> {String(item.' + f.name + ' || "")}').join(' — ') : item._id || item.id}
506
+ </div>
507
+ ${simpleActions}
508
+ </div>
509
+ ))}
510
+ </div>
511
+ )}
512
+ </section>
513
+
514
+ <Sheet open={showForm} onOpenChange={(open) => { setShowForm(open); if (!open) setEditingId(null); }}>
515
+ <SheetContent>
516
+ <SheetHeader>
517
+ <SheetTitle>{editingId ? "Edit ${singularName}" : "Create ${singularName}"}</SheetTitle>
518
+ <SheetDescription>
519
+ {editingId ? "Update the details below." : "Fill in the details below to create a new ${singularName.toLowerCase()}."}
520
+ </SheetDescription>
521
+ </SheetHeader>
522
+ <div className="mt-4">
523
+ <${formComponentName} onSuccess={handleSuccess} editId={editingId} />
524
+ </div>
525
+ </SheetContent>
526
+ </Sheet>
527
+ </PageWrapper>
528
+ );
529
+ }
530
+ `;
531
+ }
532
+
533
+ if (formMode === "inline") {
534
+ const inlineImports = `import { useState, useEffect } from "react";
535
+ import { toast } from "sonner";
536
+ import { PageWrapper } from "@/components/layout/PageWrapper";
537
+ import { Button } from "@/components/ui/button";
538
+ import { api } from "@/api/axiosInstance";
539
+ ${pageImports}`;
540
+
541
+ return `${inlineImports}export default function ${pageName}Page() {
542
+ const [items, setItems] = useState([]);
543
+ const [loading, setLoading] = useState(true);
544
+ const [editingId, setEditingId] = useState(null);
545
+
546
+ useEffect(() => {
547
+ fetchItems();
548
+ }, []);
549
+
550
+ const fetchItems = async () => {
551
+ setLoading(true);
552
+ try {
553
+ const { data } = await api.get("/${pageName.toLowerCase()}");
554
+ setItems(data?.data || []);
555
+ } catch (err) {
556
+ toast.error("Failed to load ${singularName.toLowerCase()}s");
557
+ console.error(err);
558
+ } finally {
559
+ setLoading(false);
560
+ }
561
+ };
562
+
563
+ const handleDelete = async (id) => {
564
+ if (!confirm("Are you sure you want to delete this ${singularName.toLowerCase()}?")) return;
565
+ try {
566
+ await api.delete(\`/${pageName.toLowerCase()}/\${id}\`);
567
+ toast.success("${singularName} deleted successfully");
568
+ fetchItems();
569
+ } catch (err) {
570
+ toast.error("Failed to delete ${singularName.toLowerCase()}");
571
+ }
572
+ };
573
+
574
+ const handleSuccess = () => {
575
+ setEditingId(null);
576
+ fetchItems();
577
+ };
578
+
579
+ return (
580
+ <PageWrapper className="space-y-6">
581
+ <section>
582
+ <h1 className="text-3xl font-semibold">${pageName}</h1>
583
+ <p className="text-muted-foreground">Manage ${routeName.toLowerCase()} here.</p>
584
+ </section>
585
+
586
+ <section className="bg-card p-6 rounded-lg border">
587
+ <h2 className="text-xl font-medium mb-4">Create New</h2>
588
+ <${formComponentName} onSuccess={handleSuccess} editId={editingId} />
589
+ </section>
590
+
591
+ <section className="bg-card p-6 rounded-lg border">
592
+ <h2 className="text-xl font-medium mb-4">All Items</h2>
593
+ {loading ? (
594
+ <p>Loading...</p>
595
+ ) : items.length === 0 ? (
596
+ <p className="text-muted-foreground">No ${singularName.toLowerCase()}s yet.</p>
597
+ ) : (
598
+ <div className="space-y-2">
599
+ {items.map((item) => (
600
+ <div key={item._id || item.id} className="flex items-center justify-between p-3 border rounded-lg">
601
+ <div>
602
+ ${displayFields.length ? displayFields.map(f => '<span className="font-medium">' + f.label + ':</span> {String(item.' + f.name + ' || "")}').join(' — ') : item._id || item.id}
603
+ </div>
604
+ <div className="flex gap-2">
605
+ <Button size="sm" variant="outline" onClick={() => { setEditingId(item._id || item.id); }}>
606
+ Edit
607
+ </Button>
608
+ <Button size="sm" variant="destructive" onClick={() => handleDelete(item._id || item.id)}>
609
+ Delete
610
+ </Button>
611
+ </div>
612
+ </div>
613
+ ))}
614
+ </div>
615
+ )}
616
+ </section>
617
+ </PageWrapper>
618
+ );
619
+ }
620
+ `;
621
+ }
622
+
623
+ // Default: page mode
624
+ const pageImportsWithEffects = pageImports ?
625
+ `import { useState, useEffect } from "react";
626
+ import { toast } from "sonner";
627
+ import { api } from "@/api/axiosInstance";
628
+ import { PageWrapper } from "@/components/layout/PageWrapper";
629
+ import { Button } from "@/components/ui/button";
630
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
631
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
632
+ import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
633
+ ${pageImports}` :
634
+ `import { useState, useEffect } from "react";
635
+ import { toast } from "sonner";
636
+ import { api } from "@/api/axiosInstance";
637
+ import { useAuth } from "@/hooks/useAuth";
638
+ import { ROUTES } from "@/utils/constants";
639
+ import { PageWrapper } from "@/components/layout/PageWrapper";
640
+ import { Button } from "@/components/ui/button";
641
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
642
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
643
+ import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
644
+ ${pageImports}`;
645
+
646
+ return `${pageImportsWithEffects}export default function ${pageName}Page() {
647
+ const [items, setItems] = useState([]);
648
+ const [loading, setLoading] = useState(true);
649
+ const [editingId, setEditingId] = useState(null);
650
+ ${pageImports ? '' : 'const { user } = useAuth();'}
651
+
652
+ useEffect(() => {
653
+ fetchItems();
654
+ }, []);
655
+
656
+ const fetchItems = async () => {
657
+ setLoading(true);
658
+ try {
659
+ const { data } = await api.get("/${pageName.toLowerCase()}");
660
+ setItems(data?.data || []);
661
+ } catch (err) {
662
+ toast.error("Failed to load ${singularName.toLowerCase()}s");
663
+ console.error(err);
664
+ } finally {
665
+ setLoading(false);
666
+ }
667
+ };
668
+
669
+ const handleDelete = async (id) => {
670
+ if (!confirm("Are you sure you want to delete this ${singularName.toLowerCase()}?")) return;
671
+ try {
672
+ await api.delete(\`/${pageName.toLowerCase()}/\${id}\`);
673
+ toast.success("${singularName} deleted successfully");
674
+ fetchItems();
675
+ } catch (err) {
676
+ toast.error("Failed to delete ${singularName.toLowerCase()}");
677
+ }
678
+ };
679
+
680
+ const handleSuccess = () => {
681
+ setEditingId(null);
682
+ fetchItems();
683
+ };
684
+
685
+ return (
686
+ <PageWrapper className="space-y-6">
687
+ <section>
688
+ <h1 className="text-3xl font-semibold">${pageName}</h1>
689
+ <p className="text-muted-foreground">Manage ${routeName.toLowerCase()} here.</p>
690
+ </section>
691
+
692
+ ${formFields.length ? `
693
+ <section className="bg-card p-6 rounded-lg border">
694
+ <h2 className="text-xl font-medium mb-4">Create New</h2>
695
+ <${formComponentName} onSuccess={handleSuccess} editId={editingId} />
696
+ </section>` : ''}
697
+
698
+ <section className="bg-card p-6 rounded-lg border">
699
+ <h2 className="text-xl font-medium mb-4">All Items</h2>
700
+ {loading ? (
701
+ <p>Loading...</p>
702
+ ) : items.length === 0 ? (
703
+ <p className="text-muted-foreground">No ${singularName.toLowerCase()}s yet.</p>
704
+ ) : (
705
+ <Table>
706
+ <TableHeader>
707
+ <TableRow>
708
+ ${tableHeaders}
709
+ <TableHead className="text-right">Actions</TableHead>
710
+ </TableRow>
711
+ </TableHeader>
712
+ <TableBody>
713
+ {items.map((item) => (
714
+ <TableRow key={item._id || item.id}>
715
+ ${tableCells}
716
+ <TableCell className="text-right">
717
+ <div className="flex gap-2 justify-end">
718
+ <Button size="sm" variant="outline" onClick={() => setEditingId(item._id || item.id)}>
719
+ Edit
720
+ </Button>
721
+ <Button size="sm" variant="destructive" onClick={() => handleDelete(item._id || item.id)}>
722
+ Delete
723
+ </Button>
724
+ </div>
725
+ </TableCell>
726
+ </TableRow>
727
+ ))}
728
+ </TableBody>
729
+ </Table>
730
+ )}
731
+ </section>
732
+ </PageWrapper>
733
+ );
734
+ }
735
+ `;
736
+ }
737
+ export function generateDashboardPage(pageName, modules = []) {
738
+ const hasModules = modules.length > 0;
739
+ const moduleRefs = modules.map(m => m.name).join(', ');
740
+ const fetchStatsPromises = modules.map(m => `api.get("/api/${m.name}").then(r => r.data?.data?.length || 0)`).join(', ');
741
+
742
+ return `import { useQuery } from "@tanstack/react-query";
743
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
744
+ import { Button } from "@/components/ui/button";
745
+ import { api } from "@/api/axiosInstance";
746
+ import { PageWrapper } from "@/components/layout/PageWrapper";
747
+ import { Link } from "react-router-dom";
748
+ import { useState } from "react";
749
+ import { ChevronLeft, ChevronRight } from "lucide-react";
750
+
751
+ const fetchStats = async () => {
752
+ const promises = [${fetchStatsPromises || 'Promise.resolve(0)'}];
753
+ const results = await Promise.all(promises);
754
+ return { ${modules.map((m, i) => `${m.name}: results[${i}]`).join(', ')} };
755
+ };
756
+
757
+ const fetchActivity = async ({ queryKey }) => {
758
+ const [_key, page] = queryKey;
759
+ const responses = await Promise.all([
760
+ ${modules.slice(0, 3).map(m => `api.get("/api/${m.name}?limit=5&skip=" + page * 5)`).join(',\n ')}
761
+ ]);
762
+ return responses.flatMap((r, i) =>
763
+ (r.data?.data || []).map(item => ({ ...item, module: "${modules[0]?.name || 'item'}" }))
764
+ ).slice(0, 10);
765
+ };
766
+
767
+ export default function ${pageName}Page() {
768
+ const [activityPage, setActivityPage] = useState(0);
769
+ const { data: stats, isLoading: statsLoading } = useQuery({
770
+ queryKey: ["dashboard-stats"],
771
+ queryFn: fetchStats,
772
+ refetchInterval: 30000,
773
+ });
774
+
775
+ const { data: activity, isLoading: activityLoading } = useQuery({
776
+ queryKey: ["dashboard-activity", activityPage],
777
+ queryFn: fetchActivity,
778
+ });
779
+
780
+ return (
781
+ <PageWrapper className="space-y-6">
782
+ <section>
783
+ <h1 className="text-3xl font-semibold">${pageName}</h1>
784
+ <p className="text-muted-foreground">Overview of your application metrics.</p>
785
+ </section>
786
+
787
+ <section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
788
+ ${hasModules ? modules.map(m => `
789
+ <Card>
790
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
791
+ <CardTitle className="text-sm font-medium">${m.name.charAt(0).toUpperCase() + m.name.slice(1)}</CardTitle>
792
+ <span className="text-2xl">📊</span>
793
+ </CardHeader>
794
+ <CardContent>
795
+ <div className="text-2xl font-bold">{statsLoading ? "..." : stats?.${m.name} ?? 0}</div>
796
+ <p className="text-xs text-muted-foreground">Total records</p>
797
+ </CardContent>
798
+ </Card>`).join('\n') : `
799
+ <Card>
800
+ <CardHeader>
801
+ <CardTitle>Welcome</CardTitle>
802
+ </CardHeader>
803
+ <CardContent>
804
+ <p>No modules detected. Generate modules to see stats.</p>
805
+ <Button asChild className="mt-2">
806
+ <Link to="/modules">View Modules</Link>
807
+ </Button>
808
+ </CardContent>
809
+ </Card>`}
810
+ </section>
811
+
812
+ <section>
813
+ <Card>
814
+ <CardHeader>
815
+ <CardTitle>Recent Activity</CardTitle>
816
+ </CardHeader>
817
+ <CardContent>
818
+ {activityLoading ? (
819
+ <p className="text-muted-foreground">Loading...</p>
820
+ ) : (
821
+ <>
822
+ <div className="rounded-md border">
823
+ <table className="w-full">
824
+ <thead>
825
+ <tr className="border-b">
826
+ <th className="p-2 text-left">Module</th>
827
+ <th className="p-2 text-left">Action</th>
828
+ <th className="p-2 text-left">Time</th>
829
+ </tr>
830
+ </thead>
831
+ <tbody>
832
+ {activity?.slice(0, 10).map((item, i) => (
833
+ <tr key={i} className="border-b">
834
+ <td className="p-2">{item.module}</td>
835
+ <td className="p-2">Updated</td>
836
+ <td className="p-2">{new Date().toLocaleDateString()}</td>
837
+ </tr>
838
+ )) || <tr><td className="p-2" colSpan={3}>No recent activity</td></tr>}
839
+ </tbody>
840
+ </table>
841
+ </div>
842
+ <div className="flex items-center justify-end gap-2 mt-4">
843
+ <Button
844
+ variant="outline"
845
+ size="sm"
846
+ onClick={() => setActivityPage(p => Math.max(0, p - 1))}
847
+ disabled={activityPage === 0}
848
+ >
849
+ <ChevronLeft className="h-4 w-4" />
850
+ </Button>
851
+ <span className="text-sm">Page {activityPage + 1}</span>
852
+ <Button
853
+ variant="outline"
854
+ size="sm"
855
+ onClick={() => setActivityPage(p => p + 1)}
856
+ >
857
+ <ChevronRight className="h-4 w-4" />
858
+ </Button>
859
+ </div>
860
+ </>
861
+ )}
862
+ </CardContent>
863
+ </Card>
864
+ </section>
865
+ </PageWrapper>
866
+ );
867
+ }
868
+ `;
869
+ }
870
+
871
+ function generateFormComponent(pageName, fields) {
872
+ const fieldInputs = fields.filter(f => f.type !== 'hidden').map((field) => {
873
+ const id = field.name.toLowerCase();
874
+ const label = field.label || field.name.charAt(0).toUpperCase() + field.name.slice(1);
875
+ const required = field.required ? "required" : "";
876
+ const placeholder = field.placeholder ? `placeholder="${field.placeholder}"` : "";
877
+ const helperText = field.helperText ? `<p className="text-xs text-muted-foreground mt-1">${field.helperText}</p>` : "";
878
+
879
+ let validationAttrs = "";
880
+ if (field.type === "number" || field.type === "range") {
881
+ if (field.min !== undefined) validationAttrs += ` min="${field.min}"`;
882
+ if (field.max !== undefined) validationAttrs += ` max="${field.max}"`;
883
+ if (field.step) validationAttrs += ` step="${field.step}"`;
884
+ }
885
+ if (field.type === "text" || field.type === "string") {
886
+ if (field.minLength !== undefined) validationAttrs += ` minLength="${field.minLength}"`;
887
+ if (field.maxLength !== undefined) validationAttrs += ` maxLength="${field.maxLength}"`;
888
+ }
889
+
890
+ let inputElement;
891
+ switch (field.type) {
892
+ case "textarea":
893
+ inputElement = `<textarea
894
+ id="${id}"
895
+ name="${id}"
896
+ rows={3}
897
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
898
+ ${required}
899
+ ${placeholder}
900
+ ${validationAttrs}
901
+ onChange={handleChange}
902
+ value={values.${field.name}}
903
+ />`;
904
+ break;
905
+
906
+ case "select":
907
+ inputElement = `<select
908
+ id="${id}"
909
+ name="${id}"
910
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
911
+ ${required}
912
+ onChange={handleChange}
913
+ value={values.${field.name}}
914
+ >
915
+ <option value="">Select...</option>
916
+ {field.options?.map(opt => '<option value="' + (opt.value || opt) + '">' + (opt.label || opt) + '</option>').join("\\n ") || ""}
917
+ </select>`;
918
+ break;
919
+
920
+ case "color":
921
+ inputElement = `<div className="flex items-center gap-2">
922
+ <input
923
+ type="color"
924
+ id="${id}"
925
+ name="${id}"
926
+ className="h-10 w-20 rounded-md border cursor-pointer"
927
+ onChange={handleChange}
928
+ value={values.${field.name}}
929
+ />
930
+ <input type="text" readOnly value="#000000" className="flex-1 rounded-md border px-3 py-2 text-sm bg-muted" />
931
+ </div>`;
932
+ break;
933
+
934
+ case "file":
935
+ inputElement = `<input
936
+ type="file"
937
+ id="${id}"
938
+ name="${id}"
939
+ className="w-full rounded-md border px-3 py-2 text-sm file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90"
940
+ accept="${field.accept || "*/*"}"
941
+ ${field.multiple ? "multiple" : ""}
942
+ />`;
943
+ break;
944
+
945
+ case "range":
946
+ inputElement = `<div className="space-y-2">
947
+ <div className="flex justify-between text-xs">
948
+ <span>${field.min || 0}</span>
949
+ <span id="${id}-display" className="font-medium">${field.defaultValue || Math.round((field.min || 0) + (field.max || 100) / 2)}</span>
950
+ <span>${field.max || 100}</span>
951
+ </div>
952
+ <input
953
+ type="range"
954
+ id="${id}"
955
+ name="${id}"
956
+ min="${field.min || 0}"
957
+ max="${field.max || 100}"
958
+ step="${field.step || 1}"
959
+ className="w-full accent-primary"
960
+ onChange={(e) => {
961
+ handleChange(e);
962
+ document.getElementById('${id}-display').textContent = e.target.value;
963
+ }}
964
+ value={values.${field.name}}
965
+ />
966
+ </div>`;
967
+ break;
968
+
969
+ case "hidden":
970
+ inputElement = `<input type="hidden" id="${id}" name="${id}" value={values.${field.name}} />`;
971
+ break;
972
+
973
+ case "date":
974
+ inputElement = `<input
975
+ type="date"
976
+ id="${id}"
977
+ name="${id}"
978
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
979
+ ${required}
980
+ ${field.min ? `min="${field.min}"` : ''}
981
+ ${field.max ? `max="${field.max}"` : ''}
982
+ onChange={handleChange}
983
+ value={values.${field.name}}
984
+ />`;
985
+ break;
986
+
987
+ case "time":
988
+ inputElement = `<input
989
+ type="time"
990
+ id="${id}"
991
+ name="${id}"
992
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
993
+ ${required}
994
+ onChange={handleChange}
995
+ value={values.${field.name}}
996
+ />`;
997
+ break;
998
+
999
+ case "datetime-local":
1000
+ inputElement = `<input
1001
+ type="datetime-local"
1002
+ id="${id}"
1003
+ name="${id}"
1004
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
1005
+ ${required}
1006
+ ${field.min ? `min="${field.min}"` : ''}
1007
+ ${field.max ? `max="${field.max}"` : ''}
1008
+ onChange={handleChange}
1009
+ value={values.${field.name}}
1010
+ />`;
1011
+ break;
1012
+
1013
+ case "tel":
1014
+ inputElement = `<input
1015
+ type="tel"
1016
+ id="${id}"
1017
+ name="${id}"
1018
+ inputMode="tel"
1019
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
1020
+ ${required}
1021
+ ${placeholder}
1022
+ pattern="^[+]?[1-9]\\d{1,14}$"
1023
+ title="E.164 format: +[country code][number]"
1024
+ onChange={handleChange}
1025
+ value={values.${field.name}}
1026
+ />`;
1027
+ break;
1028
+
1029
+ case "url":
1030
+ inputElement = `<input
1031
+ type="url"
1032
+ id="${id}"
1033
+ name="${id}"
1034
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
1035
+ ${required}
1036
+ ${placeholder}
1037
+ onChange={handleChange}
1038
+ value={values.${field.name}}
1039
+ />`;
1040
+ break;
1041
+
1042
+ case "email":
1043
+ inputElement = `<input
1044
+ type="email"
1045
+ id="${id}"
1046
+ name="${id}"
1047
+ inputMode="email"
1048
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
1049
+ ${required}
1050
+ ${placeholder}
1051
+ autoComplete="email"
1052
+ onChange={handleChange}
1053
+ value={values.${field.name}}
1054
+ />`;
1055
+ break;
1056
+
1057
+ case "password":
1058
+ inputElement = `<input
1059
+ type="password"
1060
+ id="${id}"
1061
+ name="${id}"
1062
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
1063
+ ${required}
1064
+ minLength="8"
1065
+ autoComplete="${field.name.toLowerCase().includes('current') ? 'current-password' : 'new-password'}"
1066
+ onChange={handleChange}
1067
+ value={values.${field.name}}
1068
+ />`;
1069
+ break;
1070
+
1071
+ case "number":
1072
+ inputElement = `<input
1073
+ type="number"
1074
+ id="${id}"
1075
+ name="${id}"
1076
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
1077
+ ${required}
1078
+ ${field.min !== undefined ? `min="${field.min}"` : ''}
1079
+ ${field.max !== undefined ? `max="${field.max}"` : ''}
1080
+ ${field.step ? `step="${field.step}"` : ''}
1081
+ onChange={handleChange}
1082
+ value={values.${field.name}}
1083
+ />`;
1084
+ break;
1085
+
1086
+ case "boolean":
1087
+ inputElement = `<div className="flex items-center space-x-2">
1088
+ <input
1089
+ type="checkbox"
1090
+ id="${id}"
1091
+ name="${id}"
1092
+ className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
1093
+ ${required ? 'required' : ''}
1094
+ onChange={handleChange}
1095
+ checked={values.${field.name}}
1096
+ />
1097
+ <label htmlFor="${id}" className="text-sm font-medium">${field.label || ''}</label>
1098
+ </div>`;
1099
+ break;
1100
+
1101
+ default:
1102
+ inputElement = `<input
1103
+ type="text"
1104
+ id="${id}"
1105
+ name="${id}"
1106
+ className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
1107
+ ${required}
1108
+ ${placeholder}
1109
+ ${validationAttrs}
1110
+ onChange={handleChange}
1111
+ value={values.${field.name}}
1112
+ />`;
1113
+ }
1114
+
1115
+ return ` <div key="${id}" className="space-y-2">
1116
+ <label htmlFor="${id}" className="block text-sm font-medium">
1117
+ ${label}${field.required ? '<span className="text-destructive ml-1">*</span>' : ''}
1118
+ </label>
1119
+ ${inputElement}
1120
+ ${helperText}
1121
+ </div>`;
1122
+ }).join("\n\n");
1123
+
1124
+ const formFieldsObject = fields.map((f) => {
1125
+ let defaultValue = '""';
1126
+ switch (f.type) {
1127
+ case "hidden": defaultValue = f.defaultValue !== undefined ? JSON.stringify(f.defaultValue) : '""'; break;
1128
+ case "number":
1129
+ case "range": defaultValue = f.defaultValue ?? 0; break;
1130
+ case "boolean": defaultValue = f.defaultValue ?? false; break;
1131
+ case "date":
1132
+ case "datetime-local": defaultValue = f.defaultValue ?? '""'; break;
1133
+ case "text":
1134
+ case "string":
1135
+ case "email":
1136
+ case "tel":
1137
+ case "url":
1138
+ case "password":
1139
+ case "textarea": defaultValue = f.defaultValue !== undefined ? JSON.stringify(f.defaultValue) : '""'; break;
1140
+ }
1141
+ return ` ${f.name}: ${defaultValue}`;
1142
+ }).join(",\n");
1143
+
1144
+ const sanitizationImports = fields.some(f => ["email", "url", "tel", "text", "string"].includes(f.type))
1145
+ ? `import { sanitizeEmail, sanitizeUrl, sanitizePhone, sanitizeText } from "@/utils/sanitize";\n`
1146
+ : '';
1147
+
1148
+ // Build validation blocks only for relevant fields, avoiding empty lines
1149
+ const requiredChecks = fields.filter(f => f.required).map(f => {
1150
+ if (f.type === 'boolean') {
1151
+ return `if (values.${f.name} !== true) errors.push("${f.label} is required");`;
1152
+ }
1153
+ return `if (values.${f.name} === undefined || values.${f.name} === null || values.${f.name} === '') errors.push("${f.label} is required");`;
1154
+ });
1155
+
1156
+ const numberChecks = fields.filter(f => f.type === "number" || f.type === "range").flatMap(f => {
1157
+ const checks = [];
1158
+ if (f.min !== undefined) checks.push(`if (values.${f.name} !== undefined && values.${f.name} !== null && values.${f.name} < ${f.min}) errors.push("${f.label} must be >= ${f.min}");`);
1159
+ if (f.max !== undefined) checks.push(`if (values.${f.name} !== undefined && values.${f.name} !== null && values.${f.name} > ${f.max}) errors.push("${f.label} must be <= ${f.max}");`);
1160
+ return checks;
1161
+ });
1162
+
1163
+ const lengthChecks = fields.filter(f => (f.minLength || f.maxLength) && ["text", "textarea", "string", "email", "tel", "url", "password"].includes(f.type)).flatMap(f => {
1164
+ const checks = [];
1165
+ if (f.minLength) checks.push(`if (values.${f.name} && values.${f.name}.length < ${f.minLength}) errors.push("Min ${f.minLength} characters");`);
1166
+ if (f.maxLength) checks.push(`if (values.${f.name} && values.${f.name}.length > ${f.maxLength}) errors.push("Max ${f.maxLength} characters");`);
1167
+ return checks;
1168
+ });
1169
+
1170
+ const patternChecks = fields.filter(f => f.pattern).map(f =>
1171
+ `if (!${f.pattern}.test(values.${f.name})) errors.push("${f.label} has invalid format");`
1172
+ );
1173
+
1174
+ const emailChecks = fields.filter(f => f.type === "email").map(f =>
1175
+ `if (values.${f.name} && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(values.${f.name})) errors.push("Invalid email");`
1176
+ );
1177
+
1178
+ const urlChecks = fields.filter(f => f.type === "url").map(f =>
1179
+ `if (values.${f.name} && !/^https?:\\/\\//.test(values.${f.name})) errors.push("URL must start with http:// or https://");`
1180
+ );
1181
+
1182
+ const telChecks = fields.filter(f => f.type === "tel").map(f =>
1183
+ `if (values.${f.name} && !/^[+]?[1-9]\\d{1,14}$/.test(values.${f.name})) errors.push("Invalid phone (E.164: +1234567890)");`
1184
+ );
1185
+
1186
+ const validationBlocks = [
1187
+ ...requiredChecks,
1188
+ ...numberChecks,
1189
+ ...lengthChecks,
1190
+ ...patternChecks,
1191
+ ...emailChecks,
1192
+ ...urlChecks,
1193
+ ...telChecks,
1194
+ ];
1195
+
1196
+ const validationCode = validationBlocks.length > 0
1197
+ ? `\n ${validationBlocks.join('\n ')}\n `
1198
+ : '';
1199
+
1200
+ const resetValues = fields.map(f => {
1201
+ if (["number","range","boolean"].includes(f.type) && f.defaultValue !== undefined) return `${f.name}: ${f.defaultValue}`;
1202
+ if (["text","string","email","tel","url","password","textarea"].includes(f.type) && f.defaultValue !== undefined) return `${f.name}: ${JSON.stringify(f.defaultValue)}`;
1203
+ if (["date","datetime-local"].includes(f.type) && f.defaultValue !== undefined) return `${f.name}: ${JSON.stringify(f.defaultValue)}`;
1204
+ return `${f.name}: ${f.type === "boolean" ? false : f.type === "number" || f.type === "range" ? 0 : '""'}`;
1205
+ }).join(", ");
1206
+
1207
+ return `import { useState, useEffect } from "react";
1208
+ import { toast } from "sonner";
1209
+ import { Button } from "@/components/ui/button";
1210
+ import { api } from "@/api/axiosInstance";
1211
+ ${sanitizationImports}export function ${pageName}Form({ onSuccess, editId = null } = {}) {
1212
+ const [loading, setLoading] = useState(false);
1213
+ const [editing, setEditing] = useState(false);
1214
+ const [values, setValues] = useState({
1215
+ ${formFieldsObject}
1216
+ });
1217
+
1218
+ // Load existing data when editing
1219
+ useEffect(() => {
1220
+ if (editId) {
1221
+ setEditing(true);
1222
+ const fetchData = async () => {
1223
+ try {
1224
+ const { data } = await api.get(\`/${pageName.toLowerCase()}/\${editId}\`);
1225
+ if (data?.data) {
1226
+ setValues(prev => ({ ...prev, ...data.data }));
1227
+ }
1228
+ } catch (err) {
1229
+ toast.error("Failed to load item");
1230
+ console.error(err);
1231
+ }
1232
+ };
1233
+ fetchData();
1234
+ }
1235
+ }, [editId]);
1236
+
1237
+ const handleChange = (e) => {
1238
+ const { name, value, type, checked } = e.target;
1239
+ const val = type === 'checkbox' ? checked : value;
1240
+ setValues((prev) => ({ ...prev, [name]: val }));
1241
+ };
1242
+
1243
+ const sanitizeInput = (key, value) => {
1244
+ switch (key) {
1245
+ ${fields.filter(f => ["email", "url", "tel", "text", "string"].includes(f.type)).map(f => {
1246
+ const sanitizers = { email: "Email", url: "Url", tel: "Phone", text: "Text", string: "Text" };
1247
+ return `case "${f.name}": return sanitize${sanitizers[f.type]}(value);`;
1248
+ }).join('\n ')}
1249
+ default: return value;
1250
+ }
1251
+ };
1252
+
1253
+ const validateForm = () => {
1254
+ const errors = [];${validationCode}
1255
+ return errors;
1256
+ };
1257
+
1258
+ async function handleSubmit(e) {
1259
+ e.preventDefault();
1260
+ setLoading(true);
1261
+
1262
+ try {
1263
+ const errors = validateForm();
1264
+ if (errors.length > 0) {
1265
+ errors.forEach(err => toast.error(err));
1266
+ setLoading(false);
1267
+ return;
1268
+ }
1269
+
1270
+ const sanitizedData = Object.fromEntries(
1271
+ Object.entries(values).map(([k, v]) => [k, sanitizeInput(k, v)])
1272
+ );
1273
+
1274
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
1275
+ const config = {
1276
+ headers: {
1277
+ 'Content-Type': 'application/json',
1278
+ ...(csrfToken && { 'X-CSRF-Token': csrfToken })
1279
+ }
1280
+ };
1281
+
1282
+ if (editing && editId) {
1283
+ await api.put(\`/${pageName.toLowerCase()}/\${editId}\`, sanitizedData, config);
1284
+ toast.success("Item updated successfully!");
1285
+ } else {
1286
+ await api.post(\`/${pageName.toLowerCase()}\`, sanitizedData, config);
1287
+ toast.success("Item created successfully!");
1288
+ }
1289
+
1290
+ setValues({${resetValues}});
1291
+ setEditing(false);
1292
+ if (onSuccess) onSuccess();
1293
+ } catch (err) {
1294
+ const errorMsg = err?.response?.data?.message || err?.message || "Failed to save";
1295
+ toast.error(errorMsg);
1296
+ if (process.env.NODE_ENV === 'development') console.error("Form error:", err);
1297
+ } finally {
1298
+ setLoading(false);
1299
+ }
1300
+ }
1301
+
1302
+ async function handleDelete() {
1303
+ if (!editId) return;
1304
+
1305
+ if (!confirm("Are you sure you want to delete this item?")) return;
1306
+
1307
+ setLoading(true);
1308
+ try {
1309
+ await api.delete(\`/${pageName.toLowerCase()}/\${editId}\`);
1310
+ toast.success("Item deleted successfully!");
1311
+ setValues({${resetValues}});
1312
+ setEditing(false);
1313
+ if (onSuccess) onSuccess();
1314
+ } catch (err) {
1315
+ const errorMsg = err?.response?.data?.message || err?.message || "Failed to delete";
1316
+ toast.error(errorMsg);
1317
+ } finally {
1318
+ setLoading(false);
1319
+ }
1320
+ }
1321
+
1322
+
1323
+ return (
1324
+ <form onSubmit={handleSubmit} className="space-y-4">
1325
+ ${fieldInputs}
1326
+ ${fields.filter(f => f.type === "hidden").map(f => ` <input type="hidden" name="${f.name}" value={values.${f.name}} />`).join('\n')}
1327
+ {editing ?
1328
+ <div className="pt-2 flex gap-2">
1329
+ <Button type="submit" disabled={loading}>
1330
+ {loading ? "Saving..." : "Update ${pageName}"}
1331
+ </Button>
1332
+ <Button type="button" variant="destructive" onClick={handleDelete} disabled={loading}>
1333
+ {loading ? "Deleting..." : "Delete"}
1334
+ </Button>
1335
+ <Button type="button" variant="outline" onClick={() => { setEditing(false); setValues({${resetValues}}); }}>
1336
+ Cancel
1337
+ </Button>
1338
+ </div> :
1339
+ <div className="pt-2">
1340
+ <Button type="submit" disabled={loading}>
1341
+ {loading ? "Creating..." : "Create ${pageName}"}
1342
+ </Button>
1343
+ </div>}
1344
+ </form>
1345
+ );
1346
+ }
1347
+ `;
1348
+ }
1349
+
1350
+ async function updateRouter(routerPath, pageName, name, customRoute, spinner) {
1351
+ let routerCode = await fs.readFile(routerPath, "utf-8");
1352
+ const importLine = `const ${pageName}Page = lazy(() => import("@/pages/${name.toLowerCase()}/${pageName}Page"));`;
1353
+
1354
+ if (!routerCode.includes(importLine)) {
1355
+ const pageImportRegex = /^const \w+Page = lazy\(.*?\);/gm;
1356
+ let lastMatch, match;
1357
+ while ((match = pageImportRegex.exec(routerCode)) !== null) lastMatch = match;
1358
+ if (lastMatch) {
1359
+ routerCode = routerCode.replace(lastMatch[0], `${lastMatch[0]}\n${importLine}`);
1360
+ } else {
1361
+ routerCode = routerCode.replace("export function AppRouter()", `${importLine}\nexport function AppRouter()`);
1362
+ }
1363
+ await fs.writeFile(routerPath, routerCode);
1364
+ spinner.succeed("Added lazy import to AppRouter.jsx");
1365
+ routerCode = await fs.readFile(routerPath, "utf-8");
1366
+ }
1367
+
1368
+ const routePath = customRoute || `/${name}`;
1369
+ const routeBlock = `${pageName}Page`;
1370
+ const indentedInsert = `\n {/* ${pageName} */}
1371
+ <Route
1372
+ path="${routePath}"
1373
+ element={<AppShell secure><${routeBlock} /></AppShell>}
1374
+ />`;
1375
+
1376
+ if (routerCode.includes(`path="\${routePath}"`)) {
1377
+ console.log(chalk.gray("ℹ Route already exists"));
1378
+ } else {
1379
+ const wildcardRegex = /^(\s*)<Route\s+path="\*"\s+element=.*?\/>/m;
1380
+ const wildcardMatch = routerCode.match(wildcardRegex);
1381
+ if (wildcardMatch) {
1382
+ routerCode = routerCode.replace(wildcardRegex, indentedInsert + "\n" + wildcardMatch[0]);
1383
+ } else {
1384
+ routerCode = routerCode.replace("</Routes>", `${indentedInsert}\n </Routes>`);
1385
+ }
1386
+ await fs.writeFile(routerPath, routerCode);
1387
+ spinner.succeed("Added route to AppRouter.jsx");
1388
+ }
1389
+ }
1390
+
1391
+ async function updateNavigation(presetPath, pageName, routePath, icon, spinner) {
1392
+ if (!fs.existsSync(presetPath)) {
1393
+ console.log(chalk.yellow("⚠ app-preset.js not found — add nav manually."));
1394
+ return;
1395
+ }
1396
+ let presetCode = await fs.readFile(presetPath, "utf-8");
1397
+ const navEntry = `{ label: "${pageName}", href: "${routePath}", icon: "${icon || "layout"}" },`;
1398
+
1399
+ if (presetCode.includes(`href: "\${routePath}"`)) {
1400
+ console.log(chalk.gray("ℹ Navigation entry already present"));
1401
+ } else {
1402
+ const navMatch = presetCode.match(/navigation:\s*\[([\s\S]*?)\]/);
1403
+ if (!navMatch) {
1404
+ console.log(chalk.yellow("⚠ Could not find navigation array — skipping"));
1405
+ } else {
1406
+ let existingItems = navMatch[1].trim();
1407
+ const cleaned = existingItems.replace(/,?\s*\\$\{newItems\}/, "").replace(/,\s*\$/, "");
1408
+ const newItems = cleaned ? `${cleaned},\n ${navEntry}` : navEntry;
1409
+ const replacement = `navigation: [\n ${newItems}\n ]`;
1410
+ presetCode = presetCode.replace(/navigation:\s*\[([\s\S]*?)\]/, replacement);
1411
+ await fs.writeFile(presetPath, presetCode, "utf-8");
1412
+ spinner.succeed("Added navigation entry to app-preset.js");
1413
+ }
1414
+ }
1415
+ }