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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/cli.js +306 -0
- package/branding.json +8 -0
- package/package.json +72 -0
- package/src/__tests__/cli-smoke.test.js +46 -0
- package/src/blueprint/__tests__/blueprint.test.js +116 -0
- package/src/blueprint/blueprint.js +181 -0
- package/src/blueprint/default.blueprint.json +78 -0
- package/src/blueprint/index.js +10 -0
- package/src/blueprint/loader.js +101 -0
- package/src/blueprint/schema-kit.js +161 -0
- package/src/blueprint/schema.js +78 -0
- package/src/branding/__tests__/branding.test.js +49 -0
- package/src/branding/index.js +48 -0
- package/src/commands/__tests__/commands.test.js +83 -0
- package/src/commands/check.js +71 -0
- package/src/commands/cleanup.js +347 -0
- package/src/commands/customize.js +263 -0
- package/src/commands/doctor.js +84 -0
- package/src/commands/env.js +75 -0
- package/src/commands/finalize.js +68 -0
- package/src/commands/generate/ci-cd.js +378 -0
- package/src/commands/generate/deploy-advanced.js +253 -0
- package/src/commands/generate/deploy.js +99 -0
- package/src/commands/generate/env.template.js +221 -0
- package/src/commands/generate/index.js +7 -0
- package/src/commands/generate/module.js +836 -0
- package/src/commands/generate/page.js +1415 -0
- package/src/commands/generate/test-scaffold.js +279 -0
- package/src/commands/generate/theme.js +67 -0
- package/src/commands/generate-resource.js +133 -0
- package/src/commands/index.js +9 -0
- package/src/commands/init.js +350 -0
- package/src/commands/make/resource.js +298 -0
- package/src/commands/preset.js +57 -0
- package/src/commands/remove.js +170 -0
- package/src/commands/rename.js +54 -0
- package/src/commands/rollback.js +90 -0
- package/src/commands/wizard.js +303 -0
- package/src/core/__tests__/generator.test.js +67 -0
- package/src/core/__tests__/marker-strategy.test.js +57 -0
- package/src/core/__tests__/resource-definition.test.js +32 -0
- package/src/core/generator.js +542 -0
- package/src/core/marker-strategy.js +138 -0
- package/src/core/resource-definition.js +346 -0
- package/src/core/state-tracker.js +67 -0
- package/src/core/template-loader.js +163 -0
- package/src/engine/__tests__/engine.test.js +306 -0
- package/src/engine/index.js +21 -0
- package/src/engine/injector.js +198 -0
- package/src/engine/pipeline.js +138 -0
- package/src/engine/transaction.js +105 -0
- package/src/engine/validator.js +190 -0
- package/src/index.js +4 -0
- package/src/recipes/__tests__/recipe.test.js +128 -0
- package/src/recipes/builtin/module.json +22 -0
- package/src/recipes/builtin/page.json +21 -0
- package/src/recipes/builtin/resource.json +35 -0
- package/src/recipes/condition.js +147 -0
- package/src/recipes/index.js +11 -0
- package/src/recipes/loader.js +95 -0
- package/src/recipes/recipe.js +89 -0
- package/src/recipes/schema.js +47 -0
- package/src/schemas/__tests__/schemas.test.js +67 -0
- package/src/schemas/index.js +18 -0
- package/src/schemas/options.js +38 -0
- package/src/schemas/resource.js +112 -0
- package/src/services/__tests__/reporter.test.js +98 -0
- package/src/services/clock.js +31 -0
- package/src/services/index.js +43 -0
- package/src/services/reporter.js +136 -0
- package/src/templates/resource/api.js.ejs +39 -0
- package/src/templates/resource/components/form.jsx.ejs +81 -0
- package/src/templates/resource/components/table.jsx.ejs +68 -0
- package/src/templates/resource/controller.js.ejs +154 -0
- package/src/templates/resource/hooks.js.ejs +46 -0
- package/src/templates/resource/model.js.ejs +64 -0
- package/src/templates/resource/page-detail.jsx.ejs +55 -0
- package/src/templates/resource/page-form.jsx.ejs +30 -0
- package/src/templates/resource/page-inline.jsx.ejs +74 -0
- package/src/templates/resource/page-modal.jsx.ejs +98 -0
- package/src/templates/resource/page-page.jsx.ejs +99 -0
- package/src/templates/resource/page-sidepanel.jsx.ejs +100 -0
- package/src/templates/resource/routes.js.ejs +35 -0
- package/src/templates/resource/service.js.ejs +132 -0
- package/src/templates/resource/test.ejs +71 -0
- package/src/templates/resource/types.ts.ejs +17 -0
- package/src/templates/resource/validator.js.ejs +26 -0
- package/src/templates/snippets/lazy-import.ejs +1 -0
- package/src/templates/snippets/nav-entry.ejs +1 -0
- package/src/templates/snippets/route-entry.ejs +5 -0
- package/src/templates/snippets/route-mount.ejs +1 -0
- package/src/utils/fieldValidators.js +371 -0
- package/src/utils/logging/logger.js +47 -0
- package/src/utils/namingUtils.js +38 -0
- 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
|
+
}
|