tss-stack 1.2.3 → 1.3.1

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.
@@ -1,542 +1,794 @@
1
- const fs = require("fs-extra");
2
- const path = require("path");
3
- const { toPascal, toRoute } = require("./utils");
4
-
5
- function generateFrontend(config) {
6
- const { projectName, tables, needsAuth, needsReports, targetDir } = config;
7
- const root = path.join(targetDir, "frontend-project");
8
-
9
- fs.outputFileSync(
10
- path.join(root, "package.json"),
11
- JSON.stringify(
12
- {
13
- name: "frontend-project",
14
- version: "1.0.0",
15
- type:"module",
16
- scripts: { dev: "vite", build: "vite build" },
17
- dependencies: {
18
- react: "^18.2.0",
19
- "react-dom": "^18.2.0",
20
- "react-router-dom": "^6.18.0",
21
- axios: "^1.6.0",
22
- },
23
- devDependencies: {
24
- vite: "^5.0.0",
25
- "@vitejs/plugin-react": "^4.2.0",
26
- tailwindcss: "^3.3.0",
27
- autoprefixer: "^10.4.16",
28
- postcss: "^8.4.31",
29
- },
30
- },
31
- null,
32
- 2
33
- )
34
- );
35
-
36
- fs.outputFileSync(
37
- path.join(root, "vite.config.js"),
38
- `import { defineConfig } from 'vite';
39
- import react from '@vitejs/plugin-react';
40
-
41
- export default defineConfig({
42
- plugins: [react()],
43
- server: {
44
- port: 5173,
45
- strictPort: false,
46
- },
47
- });
48
- `
49
- );
50
-
51
- fs.outputFileSync(
52
- path.join(root, "tailwind.config.js"),
53
- `export default {
54
- content: [
55
- "./index.html",
56
- "./src/**/*.{js,jsx}",
57
- ],
58
- theme: {
59
- extend: {},
60
- },
61
- plugins: [],
62
- };
63
- `
64
- );
65
-
66
- fs.outputFileSync(
67
- path.join(root, "postcss.config.js"),
68
- `export default {
69
- plugins: {
70
- tailwindcss: {},
71
- autoprefixer: {},
72
- },
73
- };
74
- `
75
- );
76
-
77
- fs.outputFileSync(
78
- path.join(root, "index.html"),
79
- `<!DOCTYPE html>
80
- <html lang="en">
81
- <head>
82
- <meta charset="UTF-8" />
83
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
84
- <title>${projectName}</title>
85
- </head>
86
- <body>
87
- <div id="root"></div>
88
- <script type="module" src="/src/main.jsx"><\/script>
89
- </body>
90
- </html>
91
- `
92
- );
93
-
94
- fs.outputFileSync(
95
- path.join(root, ".env.local.example"),
96
- `VITE_API_URL=http://localhost:5000
97
- `
98
- );
99
-
100
- fs.outputFileSync(
101
- path.join(root, ".gitignore"),
102
- `node_modules/
103
- dist/
104
- .env
105
- .env.local
106
- *.log
107
- .DS_Store
108
- .idea/
109
- .vscode/
110
- `
111
- );
112
-
113
- fs.outputFileSync(
114
- path.join(root, "src", "api", "axios.js"),
115
- `import axios from "axios";
116
-
117
- const API = axios.create({
118
- baseURL: import.meta.env.VITE_API_URL || "http://localhost:5000",
119
- withCredentials: true,
120
- });
121
-
122
- export default API;
123
- `
124
- );
125
-
126
- fs.outputFileSync(
127
- path.join(root, "src", "main.jsx"),
128
- `import React from "react";
129
- import ReactDOM from "react-dom/client";
130
- import App from "./App";
131
- import "./index.css";
132
-
133
- ReactDOM.createRoot(document.getElementById("root")).render(
134
- <React.StrictMode>
135
- <App />
136
- </React.StrictMode>
137
- );
138
- `
139
- );
140
-
141
- fs.outputFileSync(
142
- path.join(root, "src", "index.css"),
143
- `@tailwind base;
144
- @tailwind components;
145
- @tailwind utilities;
146
-
147
- body {
148
- font-family: system-ui, -apple-system, sans-serif;
149
- }
150
- `
151
- );
152
-
153
- if (needsAuth) {
154
- fs.outputFileSync(
155
- path.join(root, "src", "context", "AuthContext.jsx"),
156
- `import React, { createContext, useState, useEffect } from "react";
157
- import API from "../api/axios";
158
-
159
- export const AuthContext = createContext();
160
-
161
- export function AuthProvider({ children }) {
162
- const [user, setUser] = useState(null);
163
- const [loading, setLoading] = useState(true);
164
-
165
- useEffect(() => {
166
- const checkAuth = async () => {
167
- try {
168
- const res = await API.get("/auth/me");
169
- setUser(res.data);
170
- } catch {
171
- setUser(null);
172
- } finally {
173
- setLoading(false);
174
- }
175
- };
176
- checkAuth();
177
- }, []);
178
-
179
- return (
180
- <AuthContext.Provider value={{ user, setUser, loading }}>
181
- {children}
182
- </AuthContext.Provider>
183
- );
184
- }
185
- `
186
- );
187
-
188
- fs.outputFileSync(
189
- path.join(root, "src", "components", "PrivateRoute.jsx"),
190
- `import { Navigate } from "react-router-dom";
191
- import { useContext } from "react";
192
- import { AuthContext } from "../context/AuthContext";
193
-
194
- export default function PrivateRoute({ children }) {
195
- const { user, loading } = useContext(AuthContext);
196
-
197
- if (loading) return <div className="p-6">Loading...</div>;
198
- return user ? children : <Navigate to="/login" />;
199
- }
200
- `
201
- );
202
- }
203
-
204
- fs.outputFileSync(
205
- path.join(root, "src", "pages", "Home.jsx"),
206
- `export default function Home() {
207
- return (
208
- <div className="p-6 max-w-2xl">
209
- <h1 className="text-3xl font-bold mb-4">Welcome</h1>
210
- <p className="text-gray-600">Select an option from the navigation above to get started.</p>
211
- </div>
212
- );
213
- }
214
- `
215
- );
216
-
217
- for (const table of tables) {
218
- const name = toPascal(table.name);
219
- const route = toRoute(table.name);
220
- const fields = table.fields;
221
- const ops = table.operations;
222
-
223
- const stateFields = fields.map((f) => ` ${f}: ""`).join(",\n");
224
- const formReset = fields.map((f) => `${f}: ""`).join(", ");
225
- const editSet = fields.map((f) => `${f}: item.${f}`).join(", ");
226
- const inputs = fields
227
- .map(
228
- (f) => ` <input
229
- type="text"
230
- placeholder="${f}"
231
- value={form.${f}}
232
- onChange={(e) => setForm({ ...form, ${f}: e.target.value })}
233
- className="border p-2 rounded w-full"
234
- required
235
- />`
236
- )
237
- .join("\n");
238
-
239
- const tableHeaders = ["id", ...fields, "created_at"]
240
- .map((f) => ` <th className="border px-4 py-2">${f}</th>`)
241
- .join("\n");
242
-
243
- const tableRow = ["id", ...fields, "created_at"]
244
- .map((f) => ` <td className="border px-4 py-2">{item.${f}}</td>`)
245
- .join("\n");
246
-
247
- let page = `import { useState, useEffect } from "react";
248
- import API from "../api/axios";
249
-
250
- export default function ${name}() {
251
- const [items, setItems] = useState([]);
252
- const [form, setForm] = useState({
253
- ${stateFields}
254
- });
255
- ${ops.includes("update") ? " const [editId, setEditId] = useState(null);" : ""}
256
- const [loading, setLoading] = useState(false);
257
- const [error, setError] = useState("");
258
- const [success, setSuccess] = useState("");
259
-
260
- const fetchAll = async () => {
261
- try {
262
- setError("");
263
- const res = await API.get("/${route}");
264
- setItems(res.data);
265
- } catch (err) {
266
- setError(err.response?.data?.error || "Failed to load data");
267
- }
268
- };
269
-
270
- useEffect(() => {
271
- fetchAll();
272
- }, []);
273
-
274
- `;
275
-
276
- if (ops.includes("insert")) {
277
- page += ` const handleSubmit = async (e) => {
278
- e.preventDefault();
279
- setLoading(true);
280
- setError("");
281
- setSuccess("");
282
- try {
283
- ${ops.includes("update") ? ` if (editId) {
284
- await API.put(\`/${route}/\${editId}\`, form);
285
- setSuccess("Updated successfully");
286
- setEditId(null);
287
- } else {
288
- await API.post("/${route}", form);
289
- setSuccess("Created successfully");
290
- }` : ` await API.post("/${route}", form);
291
- setSuccess("Created successfully");`}
292
- setForm({ ${formReset} });
293
- fetchAll();
294
- } catch (err) {
295
- setError(err.response?.data?.error || "Operation failed");
296
- } finally {
297
- setLoading(false);
298
- }
299
- };
300
-
301
- `;
302
- }
303
-
304
- if (ops.includes("delete")) {
305
- page += ` const handleDelete = async (id) => {
306
- if (!window.confirm("Are you sure?")) return;
307
- setLoading(true);
308
- setError("");
309
- try {
310
- await API.delete(\`/${route}/\${id}\`);
311
- setSuccess("Deleted successfully");
312
- fetchAll();
313
- } catch (err) {
314
- setError(err.response?.data?.error || "Delete failed");
315
- } finally {
316
- setLoading(false);
317
- }
318
- };
319
-
320
- `;
321
- }
322
-
323
- if (ops.includes("update")) {
324
- page += ` const handleEdit = (item) => {
325
- setEditId(item.id);
326
- setForm({ ${editSet} });
327
- };
328
-
329
- const handleCancel = () => {
330
- setEditId(null);
331
- setForm({ ${formReset} });
332
- };
333
-
334
- `;
335
- }
336
-
337
- page += ` return (
338
- <div className="p-6">
339
- <h1 className="text-2xl font-bold mb-4">${name}</h1>
340
-
341
- {error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded mb-4">{error}</div>}
342
- {success && <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded mb-4">{success}</div>}
343
-
344
- ${ops.includes("insert") ? ` <form onSubmit={handleSubmit} className="flex flex-col gap-3 mb-6 max-w-md">
345
- ${inputs}
346
- <div className="flex gap-2">
347
- <button type="submit" disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
348
- {loading ? "Processing..." : (${ops.includes("update") ? 'editId ? "Update" : "Add"' : '"Add"'})}
349
- </button>
350
- ${ops.includes("update") ? ' {editId && <button type="button" onClick={handleCancel} className="bg-gray-400 text-white px-4 py-2 rounded hover:bg-gray-500">Cancel</button>}' : ""}
351
- </div>
352
- </form>` : ""}
353
-
354
- <table className="w-full border-collapse text-sm">
355
- <thead className="bg-gray-100">
356
- <tr>
357
- ${tableHeaders}
358
- ${(ops.includes("update") || ops.includes("delete")) ? ' <th className="border px-4 py-2">Actions</th>' : ""}
359
- </tr>
360
- </thead>
361
- <tbody>
362
- {items.map((item) => (
363
- <tr key={item.id} className="hover:bg-gray-50">
364
- ${tableRow}
365
- ${(ops.includes("update") || ops.includes("delete")) ? ` <td className="border px-4 py-2 space-x-2">
366
- ${ops.includes("update") ? ' <button onClick={() => handleEdit(item)} className="text-blue-600 hover:underline">Edit</button>' : ""}
367
- ${ops.includes("delete") ? ' <button onClick={() => handleDelete(item.id)} disabled={loading} className="text-red-600 hover:underline disabled:opacity-50">Delete</button>' : ""}
368
- </td>` : ""}
369
- </tr>
370
- ))}
371
- </tbody>
372
- </table>
373
- </div>
374
- );
375
- }
376
- `;
377
-
378
- fs.outputFileSync(path.join(root, "src", "pages", `${name}.jsx`), page);
379
- console.log(` [✓] pages/${name}.jsx`);
380
- }
381
-
382
- if (needsAuth) {
383
- fs.outputFileSync(
384
- path.join(root, "src", "pages", "Login.jsx"),
385
- `import { useState, useContext } from "react";
386
- import { useNavigate } from "react-router-dom";
387
- import { AuthContext } from "../context/AuthContext";
388
- import API from "../api/axios";
389
-
390
- export default function Login() {
391
- const navigate = useNavigate();
392
- const { setUser } = useContext(AuthContext);
393
- const [form, setForm] = useState({ username: "", password: "" });
394
- const [error, setError] = useState("");
395
- const [loading, setLoading] = useState(false);
396
- const [isRegistering, setIsRegistering] = useState(false);
397
-
398
- const handleSubmit = async (e) => {
399
- e.preventDefault();
400
- setError("");
401
- setLoading(true);
402
- try {
403
- const endpoint = isRegistering ? "/auth/register" : "/auth/login";
404
- const res = await API.post(endpoint, form);
405
- setUser(res.data.user);
406
- navigate("/");
407
- } catch (err) {
408
- setError(err.response?.data?.message || (isRegistering ? "Registration failed" : "Login failed"));
409
- } finally {
410
- setLoading(false);
411
- }
412
- };
413
-
414
- return (
415
- <div className="flex items-center justify-center min-h-screen bg-gray-100">
416
- <div className="bg-white p-8 rounded shadow-md max-w-md w-full">
417
- <h1 className="text-2xl font-bold mb-4">{isRegistering ? "Register" : "Login"}</h1>
418
- {error && <p className="text-red-600 mb-3">{error}</p>}
419
- <form onSubmit={handleSubmit} className="flex flex-col gap-3">
420
- <input
421
- className="border p-2 rounded"
422
- placeholder="Username"
423
- value={form.username}
424
- onChange={(e) => setForm({ ...form, username: e.target.value })}
425
- required
426
- />
427
- <input
428
- className="border p-2 rounded"
429
- type="password"
430
- placeholder="Password"
431
- value={form.password}
432
- onChange={(e) => setForm({ ...form, password: e.target.value })}
433
- required
434
- />
435
- <button type="submit" disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
436
- {loading ? "Processing..." : isRegistering ? "Register" : "Login"}
437
- </button>
438
- </form>
439
- <button
440
- type="button"
441
- onClick={() => setIsRegistering(!isRegistering)}
442
- className="text-blue-600 hover:underline mt-3 w-full text-sm"
443
- >
444
- {isRegistering ? "Have an account? Login" : "Need an account? Register"}
445
- </button>
446
- </div>
447
- </div>
448
- );
449
- }
450
- `
451
- );
452
- }
453
-
454
- if (needsReports) {
455
- fs.outputFileSync(
456
- path.join(root, "src", "pages", "Reports.jsx"),
457
- `export default function Reports() {
458
- return (
459
- <div className="p-6">
460
- <h1 className="text-2xl font-bold mb-4">Reports</h1>
461
- <p className="text-gray-600">Build your reports dashboard here. Add charts, analytics, and visualizations.</p>
462
- </div>
463
- );
464
- }
465
- `
466
- );
467
- }
468
-
469
- const imports = tables
470
- .map((t) => `import ${toPascal(t.name)} from "./pages/${toPascal(t.name)}";`)
471
- .join("\n");
472
-
473
- const routes = tables
474
- .map((t) => {
475
- const route = `<Route path="/${toRoute(t.name)}" element={<${toPascal(t.name)} />} />`;
476
- return needsAuth
477
- ? ` <Route path="/${toRoute(t.name)}" element={<PrivateRoute><${toPascal(t.name)} /></PrivateRoute>} />`
478
- : ` ${route}`;
479
- })
480
- .join("\n");
481
-
482
- const navLinks = tables
483
- .map((t) => ` <Link to="/${toRoute(t.name)}" className="hover:underline">${toPascal(t.name)}</Link>`)
484
- .join("\n");
485
-
486
- let app = `import { BrowserRouter, Routes, Route, Link, useNavigate } from "react-router-dom";
487
- ${imports}
488
- ${needsAuth ? 'import Login from "./pages/Login";\nimport PrivateRoute from "./components/PrivateRoute";\nimport { AuthContext, AuthProvider } from "./context/AuthContext";\nimport { useContext } from "react";' : ""}
489
- ${needsReports ? 'import Reports from "./pages/Reports";' : ""}
490
- import Home from "./pages/Home";
491
- import API from "./api/axios";
492
-
493
- function Navbar() {
494
- const navigate = useNavigate();
495
- ${needsAuth ? ' const { user } = useContext(AuthContext);' : ""}
496
-
497
- const logout = async () => {
498
- try {
499
- await API.post("/auth/logout");
500
- navigate("/login");
501
- window.location.reload();
502
- } catch (err) {
503
- console.error("Logout failed:", err);
504
- }
505
- };
506
-
507
- return (
508
- <nav className="bg-blue-700 text-white px-6 py-3 flex gap-6 items-center">
509
- <span className="font-bold text-lg"><Link to="/" className="hover:opacity-80">${projectName}</Link></span>
510
- ${navLinks}
511
- ${needsReports ? ' <Link to="/reports" className="hover:underline">Reports</Link>' : ""}
512
- ${needsAuth ? ' {user && <button onClick={logout} className="ml-auto hover:underline">Logout ({user.username})</button>}' : ""}
513
- </nav>
514
- );
515
- }
516
-
517
- function AppRoutes() {
518
- return (
519
- <>
520
- <Navbar />
521
- <Routes>
522
- ${needsAuth ? ' <Route path="/login" element={<Login />} />' : ""}
523
- <Route path="/" element={<Home />} />
524
- ${routes}
525
- ${needsReports ? (needsAuth ? ' <Route path="/reports" element={<PrivateRoute><Reports /></PrivateRoute>} />' : ' <Route path="/reports" element={<Reports />} />') : ""}
526
- </Routes>
527
- </>
528
- );
529
- }
530
-
531
- export default function App() {
532
- return (
533
- ${needsAuth ? ' <AuthProvider>\n <BrowserRouter>\n <AppRoutes />\n </BrowserRouter>\n </AuthProvider>' : ' <BrowserRouter>\n <AppRoutes />\n </BrowserRouter>'}
534
- );
535
- }
536
- `;
537
-
538
- fs.outputFileSync(path.join(root, "src", "App.jsx"), app);
539
- console.log(" [✓] App.jsx");
540
- }
541
-
542
- module.exports = { generateFrontend };
1
+ const fs = require("fs-extra");
2
+ const path = require("path");
3
+ const { toPascal, toRoute, inferReportConfig } = require("./utils");
4
+
5
+ async function generateFrontend(config) {
6
+ const { projectName, tables, needsAuth, needsReports, targetDir } = config;
7
+ const root = path.join(targetDir, "frontend-project");
8
+
9
+ // Tables that opted into reports
10
+ const reportTables = needsReports ? tables.filter((t) => t.reports) : [];
11
+
12
+ // ── package.json ───────────────────────────────────────────────────────
13
+ await fs.outputFile(
14
+ path.join(root, "package.json"),
15
+ JSON.stringify(
16
+ {
17
+ name: "frontend-project",
18
+ version: "1.0.0",
19
+ type: "module",
20
+ scripts: { dev: "vite", build: "vite build" },
21
+ dependencies: {
22
+ react: "^18.2.0",
23
+ "react-dom": "^18.2.0",
24
+ "react-router-dom": "^6.18.0",
25
+ axios: "^1.6.0",
26
+ },
27
+ devDependencies: {
28
+ vite: "^5.0.0",
29
+ "@vitejs/plugin-react": "^4.2.0",
30
+ tailwindcss: "^3.3.0",
31
+ autoprefixer: "^10.4.16",
32
+ postcss: "^8.4.31",
33
+ },
34
+ },
35
+ null,
36
+ 2
37
+ )
38
+ );
39
+
40
+ // ── Config files ───────────────────────────────────────────────────────
41
+ await fs.outputFile(
42
+ path.join(root, "vite.config.js"),
43
+ `import { defineConfig } from 'vite';
44
+ import react from '@vitejs/plugin-react';
45
+
46
+ export default defineConfig({
47
+ plugins: [react()],
48
+ server: { port: 5173, strictPort: false },
49
+ });
50
+ `
51
+ );
52
+
53
+ await fs.outputFile(
54
+ path.join(root, "tailwind.config.js"),
55
+ `export default {
56
+ content: ["./index.html", "./src/**/*.{js,jsx}"],
57
+ theme: { extend: {} },
58
+ plugins: [],
59
+ };
60
+ `
61
+ );
62
+
63
+ await fs.outputFile(
64
+ path.join(root, "postcss.config.js"),
65
+ `export default {
66
+ plugins: { tailwindcss: {}, autoprefixer: {} },
67
+ };
68
+ `
69
+ );
70
+
71
+ await fs.outputFile(
72
+ path.join(root, "index.html"),
73
+ `<!DOCTYPE html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="UTF-8" />
77
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
78
+ <title>${projectName}</title>
79
+ </head>
80
+ <body>
81
+ <div id="root"></div>
82
+ <script type="module" src="/src/main.jsx"><\/script>
83
+ </body>
84
+ </html>
85
+ `
86
+ );
87
+
88
+ await fs.outputFile(path.join(root, ".env.local.example"), `VITE_API_URL=http://localhost:5000\n`);
89
+
90
+ await fs.outputFile(
91
+ path.join(root, ".gitignore"),
92
+ `node_modules/\ndist/\n.env\n.env.local\n*.log\n.DS_Store\n`
93
+ );
94
+
95
+ // ── src/api/axios.js ───────────────────────────────────────────────────
96
+ await fs.outputFile(
97
+ path.join(root, "src", "api", "axios.js"),
98
+ `import axios from "axios";
99
+
100
+ const API = axios.create({
101
+ baseURL: import.meta.env.VITE_API_URL || "http://localhost:5000",
102
+ withCredentials: true,
103
+ });
104
+
105
+ export default API;
106
+ `
107
+ );
108
+
109
+ // ── src/main.jsx ───────────────────────────────────────────────────────
110
+ await fs.outputFile(
111
+ path.join(root, "src", "main.jsx"),
112
+ `import React from "react";
113
+ import ReactDOM from "react-dom/client";
114
+ import App from "./App";
115
+ import "./index.css";
116
+
117
+ ReactDOM.createRoot(document.getElementById("root")).render(
118
+ <React.StrictMode>
119
+ <App />
120
+ </React.StrictMode>
121
+ );
122
+ `
123
+ );
124
+
125
+ // ── src/index.css ──────────────────────────────────────────────────────
126
+ await fs.outputFile(
127
+ path.join(root, "src", "index.css"),
128
+ `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n font-family: system-ui, -apple-system, sans-serif;\n}\n`
129
+ );
130
+
131
+ // ── Auth context + PrivateRoute ────────────────────────────────────────
132
+ if (needsAuth) {
133
+ await fs.outputFile(
134
+ path.join(root, "src", "context", "AuthContext.jsx"),
135
+ `import React, { createContext, useState, useEffect } from "react";
136
+ import API from "../api/axios";
137
+
138
+ export const AuthContext = createContext();
139
+
140
+ export function AuthProvider({ children }) {
141
+ const [user, setUser] = useState(null);
142
+ const [loading, setLoading] = useState(true);
143
+
144
+ useEffect(() => {
145
+ const checkAuth = async () => {
146
+ try {
147
+ const res = await API.get("/auth/me");
148
+ setUser(res.data);
149
+ } catch {
150
+ setUser(null);
151
+ } finally {
152
+ setLoading(false);
153
+ }
154
+ };
155
+ checkAuth();
156
+ }, []);
157
+
158
+ return (
159
+ <AuthContext.Provider value={{ user, setUser, loading }}>
160
+ {children}
161
+ </AuthContext.Provider>
162
+ );
163
+ }
164
+ `
165
+ );
166
+
167
+ await fs.outputFile(
168
+ path.join(root, "src", "components", "PrivateRoute.jsx"),
169
+ `import { Navigate } from "react-router-dom";
170
+ import { useContext } from "react";
171
+ import { AuthContext } from "../context/AuthContext";
172
+
173
+ export default function PrivateRoute({ children }) {
174
+ const { user, loading } = useContext(AuthContext);
175
+ if (loading) return <div className="p-6">Loading...</div>;
176
+ return user ? children : <Navigate to="/login" />;
177
+ }
178
+ `
179
+ );
180
+ }
181
+
182
+ // ── Home page ──────────────────────────────────────────────────────────
183
+ await fs.outputFile(
184
+ path.join(root, "src", "pages", "Home.jsx"),
185
+ `export default function Home() {
186
+ return (
187
+ <div className="p-6 max-w-2xl">
188
+ <h1 className="text-3xl font-bold mb-4">Welcome</h1>
189
+ <p className="text-gray-600">Select an option from the navigation above to get started.</p>
190
+ </div>
191
+ );
192
+ }
193
+ `
194
+ );
195
+
196
+ // ── One CRUD page per table ────────────────────────────────────────────
197
+ for (const table of tables) {
198
+ const name = toPascal(table.name);
199
+ const route = toRoute(table.name);
200
+ const fields = table.fields;
201
+ const ops = table.operations;
202
+
203
+ const stateFields = fields.map((f) => ` ${f}: ""`).join(",\n");
204
+ const formReset = fields.map((f) => `${f}: ""`).join(", ");
205
+ const editSet = fields.map((f) => `${f}: item.${f}`).join(", ");
206
+
207
+ const inputs = fields
208
+ .map(
209
+ (f) => ` <input
210
+ type="text"
211
+ placeholder="${f}"
212
+ value={form.${f}}
213
+ onChange={(e) => setForm({ ...form, ${f}: e.target.value })}
214
+ className="border p-2 rounded w-full"
215
+ required
216
+ />`
217
+ )
218
+ .join("\n");
219
+
220
+ const tableHeaders = ["id", ...fields, "created_at"]
221
+ .map((f) => ` <th className="border px-4 py-2">${f}</th>`)
222
+ .join("\n");
223
+
224
+ const tableRow = ["id", ...fields, "created_at"]
225
+ .map((f) => ` <td className="border px-4 py-2">{item.${f}}</td>`)
226
+ .join("\n");
227
+
228
+ let page = `import { useState, useEffect } from "react";
229
+ import API from "../api/axios";
230
+
231
+ export default function ${name}() {
232
+ const [items, setItems] = useState([]);
233
+ const [form, setForm] = useState({
234
+ ${stateFields}
235
+ });
236
+ ${ops.includes("update") ? " const [editId, setEditId] = useState(null);" : ""}
237
+ const [loading, setLoading] = useState(false);
238
+ const [error, setError] = useState("");
239
+ const [success, setSuccess] = useState("");
240
+
241
+ const fetchAll = async () => {
242
+ try {
243
+ setError("");
244
+ const res = await API.get("/${route}");
245
+ setItems(res.data);
246
+ } catch (err) {
247
+ setError(err.response?.data?.error || "Failed to load data");
248
+ }
249
+ };
250
+
251
+ useEffect(() => { fetchAll(); }, []);
252
+
253
+ `;
254
+
255
+ if (ops.includes("insert")) {
256
+ page += ` const handleSubmit = async (e) => {
257
+ e.preventDefault();
258
+ setLoading(true);
259
+ setError("");
260
+ setSuccess("");
261
+ try {
262
+ ${
263
+ ops.includes("update")
264
+ ? ` if (editId) {
265
+ await API.put(\`/${route}/\${editId}\`, form);
266
+ setSuccess("Updated successfully");
267
+ setEditId(null);
268
+ } else {
269
+ await API.post("/${route}", form);
270
+ setSuccess("Created successfully");
271
+ }`
272
+ : ` await API.post("/${route}", form);
273
+ setSuccess("Created successfully");`
274
+ }
275
+ setForm({ ${formReset} });
276
+ fetchAll();
277
+ } catch (err) {
278
+ setError(err.response?.data?.error || "Operation failed");
279
+ } finally {
280
+ setLoading(false);
281
+ }
282
+ };
283
+
284
+ `;
285
+ }
286
+
287
+ if (ops.includes("delete")) {
288
+ page += ` const handleDelete = async (id) => {
289
+ if (!window.confirm("Are you sure?")) return;
290
+ setLoading(true);
291
+ setError("");
292
+ try {
293
+ await API.delete(\`/${route}/\${id}\`);
294
+ setSuccess("Deleted successfully");
295
+ fetchAll();
296
+ } catch (err) {
297
+ setError(err.response?.data?.error || "Delete failed");
298
+ } finally {
299
+ setLoading(false);
300
+ }
301
+ };
302
+
303
+ `;
304
+ }
305
+
306
+ if (ops.includes("update")) {
307
+ page += ` const handleEdit = (item) => {
308
+ setEditId(item.id);
309
+ setForm({ ${editSet} });
310
+ };
311
+
312
+ const handleCancel = () => {
313
+ setEditId(null);
314
+ setForm({ ${formReset} });
315
+ };
316
+
317
+ `;
318
+ }
319
+
320
+ page += ` return (
321
+ <div className="p-6">
322
+ <h1 className="text-2xl font-bold mb-4">${name}</h1>
323
+
324
+ {error && <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded mb-4">{error}</div>}
325
+ {success && <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-2 rounded mb-4">{success}</div>}
326
+
327
+ ${
328
+ ops.includes("insert")
329
+ ? ` <form onSubmit={handleSubmit} className="flex flex-col gap-3 mb-6 max-w-md">
330
+ ${inputs}
331
+ <div className="flex gap-2">
332
+ <button type="submit" disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
333
+ {loading ? "Processing..." : (${ops.includes("update") ? 'editId ? "Update" : "Add"' : '"Add"'})}
334
+ </button>
335
+ ${ops.includes("update") ? ' {editId && <button type="button" onClick={handleCancel} className="bg-gray-400 text-white px-4 py-2 rounded hover:bg-gray-500">Cancel</button>}' : ""}
336
+ </div>
337
+ </form>`
338
+ : ""
339
+ }
340
+
341
+ <table className="w-full border-collapse text-sm">
342
+ <thead className="bg-gray-100">
343
+ <tr>
344
+ ${tableHeaders}
345
+ ${ops.includes("update") || ops.includes("delete") ? ' <th className="border px-4 py-2">Actions</th>' : ""}
346
+ </tr>
347
+ </thead>
348
+ <tbody>
349
+ {items.map((item) => (
350
+ <tr key={item.id} className="hover:bg-gray-50">
351
+ ${tableRow}
352
+ ${
353
+ ops.includes("update") || ops.includes("delete")
354
+ ? ` <td className="border px-4 py-2 space-x-2">
355
+ ${ops.includes("update") ? ' <button onClick={() => handleEdit(item)} className="text-blue-600 hover:underline">Edit</button>' : ""}
356
+ ${ops.includes("delete") ? ' <button onClick={() => handleDelete(item.id)} disabled={loading} className="text-red-600 hover:underline disabled:opacity-50">Delete</button>' : ""}
357
+ </td>`
358
+ : ""
359
+ }
360
+ </tr>
361
+ ))}
362
+ </tbody>
363
+ </table>
364
+ </div>
365
+ );
366
+ }
367
+ `;
368
+
369
+ await fs.outputFile(path.join(root, "src", "pages", `${name}.jsx`), page);
370
+ console.log(` [✓] pages/${name}.jsx`);
371
+ }
372
+
373
+ // ── Login page ─────────────────────────────────────────────────────────
374
+ if (needsAuth) {
375
+ await fs.outputFile(
376
+ path.join(root, "src", "pages", "Login.jsx"),
377
+ `import { useState, useContext } from "react";
378
+ import { useNavigate } from "react-router-dom";
379
+ import { AuthContext } from "../context/AuthContext";
380
+ import API from "../api/axios";
381
+
382
+ export default function Login() {
383
+ const navigate = useNavigate();
384
+ const { setUser } = useContext(AuthContext);
385
+ const [form, setForm] = useState({ username: "", password: "" });
386
+ const [error, setError] = useState("");
387
+ const [loading, setLoading] = useState(false);
388
+ const [isRegistering, setIsRegistering] = useState(false);
389
+
390
+ const handleSubmit = async (e) => {
391
+ e.preventDefault();
392
+ setError("");
393
+ setLoading(true);
394
+ try {
395
+ const endpoint = isRegistering ? "/auth/register" : "/auth/login";
396
+ const res = await API.post(endpoint, form);
397
+ setUser(res.data.user);
398
+ navigate("/");
399
+ } catch (err) {
400
+ setError(err.response?.data?.message || (isRegistering ? "Registration failed" : "Login failed"));
401
+ } finally {
402
+ setLoading(false);
403
+ }
404
+ };
405
+
406
+ return (
407
+ <div className="flex items-center justify-center min-h-screen bg-gray-100">
408
+ <div className="bg-white p-8 rounded shadow-md max-w-md w-full">
409
+ <h1 className="text-2xl font-bold mb-4">{isRegistering ? "Register" : "Login"}</h1>
410
+ {error && <p className="text-red-600 mb-3">{error}</p>}
411
+ <form onSubmit={handleSubmit} className="flex flex-col gap-3">
412
+ <input className="border p-2 rounded" placeholder="Username" value={form.username}
413
+ onChange={(e) => setForm({ ...form, username: e.target.value })} required />
414
+ <input className="border p-2 rounded" type="password" placeholder="Password" value={form.password}
415
+ onChange={(e) => setForm({ ...form, password: e.target.value })} required />
416
+ <button type="submit" disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50">
417
+ {loading ? "Processing..." : isRegistering ? "Register" : "Login"}
418
+ </button>
419
+ </form>
420
+ <button type="button" onClick={() => setIsRegistering(!isRegistering)}
421
+ className="text-blue-600 hover:underline mt-3 w-full text-sm">
422
+ {isRegistering ? "Have an account? Login" : "Need an account? Register"}
423
+ </button>
424
+ </div>
425
+ </div>
426
+ );
427
+ }
428
+ `
429
+ );
430
+ }
431
+
432
+ // ── REPORTS SYSTEM ─────────────────────────────────────────────────────
433
+ // Only generated when needsReports is true AND at least one table opted in.
434
+ // Architecture:
435
+ // src/reports/index.js — exports all report configs as an array
436
+ // src/reports/<table>.report.js — one config per table (STEP 3)
437
+ // src/shared/MetricCards.jsx — reusable KPI card grid (STEP 7)
438
+ // src/shared/ReportTable.jsx — reusable data table
439
+ // src/pages/Reports.jsx — single page, loops over configs (STEP 4)
440
+
441
+ if (needsReports && reportTables.length > 0) {
442
+
443
+ // STEP 3 — One report config file per opted-in table
444
+ for (const table of reportTables) {
445
+ const rc = inferReportConfig(table);
446
+ await fs.outputFile(
447
+ path.join(root, "src", "reports", `${table.name}.report.js`),
448
+ `// Report configuration for ${toPascal(table.name)}.
449
+ // Edit metrics/dimensions/dateFields to change what the Reports page displays.
450
+ // The backend endpoint GET /reports/${table.name} returns data shaped to match this config.
451
+
452
+ const ${toPascal(table.name)}Report = {
453
+ title: "${toPascal(table.name)} Report",
454
+ table: "${table.name}",
455
+ endpoint: "/reports/${table.name}",
456
+
457
+ // Numeric fields displayed as SUM and AVG cards
458
+ metrics: ${JSON.stringify(rc.metrics)},
459
+
460
+ // Categorical fields — used for grouping rows in the table
461
+ dimensions: ${JSON.stringify(rc.dimensions)},
462
+
463
+ // Date fields — available for future trend/filter features
464
+ dateFields: ${JSON.stringify(rc.dateFields)},
465
+ };
466
+
467
+ module.exports = ${toPascal(table.name)}Report;
468
+ `
469
+ );
470
+ }
471
+
472
+ // STEP 5 — reports/index.js
473
+ const reportRequires = reportTables
474
+ .map((t) => `const ${toPascal(t.name)}Report = require("./${t.name}.report");`)
475
+ .join("\n");
476
+
477
+ const reportArray = reportTables.map((t) => ` ${toPascal(t.name)}Report`).join(",\n");
478
+
479
+ await fs.outputFile(
480
+ path.join(root, "src", "reports", "index.js"),
481
+ `// Central registry of all report configurations.
482
+ // Import this wherever you need the full list of reports.
483
+ ${reportRequires}
484
+
485
+ module.exports = [
486
+ ${reportArray},
487
+ ];
488
+ `
489
+ );
490
+
491
+ // STEP 7 — shared/MetricCards.jsx
492
+ // Renders one card per metric key found in the API response.
493
+ // Handles both aggregated rows (with _sum/_avg suffixes from the backend)
494
+ // and plain numeric fields gracefully.
495
+ await fs.outputFile(
496
+ path.join(root, "src", "shared", "MetricCards.jsx"),
497
+ `// MetricCards receives a metrics array (field names) and a data object
498
+ // (the first row returned by the report API) and renders a KPI card grid.
499
+ //
500
+ // Props:
501
+ // metrics string[] — field names from the report config
502
+ // data object — first row of the API response, e.g. { unit_price_sum: 500, ... }
503
+ // loading boolean — shows skeleton placeholders while fetching
504
+
505
+ export default function MetricCards({ metrics, data, loading }) {
506
+ if (!metrics || metrics.length === 0) return null;
507
+
508
+ // The backend returns _sum and _avg variants for each metric field.
509
+ // Build display cards for both variants when they exist.
510
+ const cards = [];
511
+
512
+ for (const metric of metrics) {
513
+ const sumKey = \`\${metric}_sum\`;
514
+ const avgKey = \`\${metric}_avg\`;
515
+ const label = metric.replace(/_/g, " ");
516
+
517
+ if (data && sumKey in data) {
518
+ cards.push({ label: \`Total \${label}\`, value: Number(data[sumKey]).toLocaleString() });
519
+ }
520
+ if (data && avgKey in data) {
521
+ cards.push({ label: \`Avg \${label}\`, value: Number(data[avgKey]).toFixed(2) });
522
+ }
523
+ // Fallback: plain field (no aggregation suffix)
524
+ if (data && metric in data && !(sumKey in data)) {
525
+ cards.push({ label, value: data[metric] });
526
+ }
527
+ }
528
+
529
+ // Always show total records if the backend includes it
530
+ if (data && "total_records" in data) {
531
+ cards.unshift({ label: "Total Records", value: data.total_records });
532
+ }
533
+
534
+ return (
535
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
536
+ {loading
537
+ ? Array.from({ length: metrics.length + 1 }).map((_, i) => (
538
+ <div key={i} className="border rounded-lg p-4 animate-pulse bg-gray-100 h-20" />
539
+ ))
540
+ : cards.map((card) => (
541
+ <div key={card.label} className="border rounded-lg p-4 bg-white shadow-sm">
542
+ <p className="text-gray-500 text-sm capitalize">{card.label}</p>
543
+ <p className="text-2xl font-bold mt-1">{card.value ?? "--"}</p>
544
+ </div>
545
+ ))}
546
+ </div>
547
+ );
548
+ }
549
+ `
550
+ );
551
+
552
+ // shared/ReportTable.jsx
553
+ await fs.outputFile(
554
+ path.join(root, "src", "shared", "ReportTable.jsx"),
555
+ `// ReportTable — renders a plain HTML table from an array of row objects.
556
+ // Automatically reads column names from the first row's keys.
557
+ //
558
+ // Props:
559
+ // rows object[] — array of row objects from the API
560
+ // loading boolean — shows a loading row while fetching
561
+
562
+ export default function ReportTable({ rows, loading }) {
563
+ if (loading) {
564
+ return <div className="h-24 bg-gray-100 rounded animate-pulse" />;
565
+ }
566
+
567
+ if (!rows || rows.length === 0) {
568
+ return <p className="text-gray-500 text-sm">No data available.</p>;
569
+ }
570
+
571
+ const columns = Object.keys(rows[0]);
572
+
573
+ return (
574
+ <div className="overflow-x-auto">
575
+ <table className="w-full border-collapse text-sm">
576
+ <thead className="bg-gray-100">
577
+ <tr>
578
+ {columns.map((col) => (
579
+ <th key={col} className="border px-4 py-2 text-left capitalize">
580
+ {col.replace(/_/g, " ")}
581
+ </th>
582
+ ))}
583
+ </tr>
584
+ </thead>
585
+ <tbody>
586
+ {rows.map((row, i) => (
587
+ <tr key={i} className="hover:bg-gray-50">
588
+ {columns.map((col) => (
589
+ <td key={col} className="border px-4 py-2">
590
+ {row[col] ?? "--"}
591
+ </td>
592
+ ))}
593
+ </tr>
594
+ ))}
595
+ </tbody>
596
+ </table>
597
+ </div>
598
+ );
599
+ }
600
+ `
601
+ );
602
+
603
+ // STEP 4 — Reports.jsx
604
+ // Fetches GET /reports (all tables in one request) on mount,
605
+ // then renders one section per report config using the shared components.
606
+ const reportImports = reportTables
607
+ .map((t) => `import ${toPascal(t.name)}Report from "../reports/${t.name}.report";`)
608
+ .join("\n");
609
+
610
+ const reportConfigArray = reportTables.map((t) => ` ${toPascal(t.name)}Report`).join(",\n");
611
+
612
+ await fs.outputFile(
613
+ path.join(root, "src", "pages", "Reports.jsx"),
614
+ `import { useState, useEffect } from "react";
615
+ import API from "../api/axios";
616
+ import MetricCards from "../shared/MetricCards";
617
+ import ReportTable from "../shared/ReportTable";
618
+ ${reportImports}
619
+
620
+ // All report configs in one array — add or remove configs here to control
621
+ // which reports appear on this page.
622
+ const REPORT_CONFIGS = [
623
+ ${reportConfigArray},
624
+ ];
625
+
626
+ export default function Reports() {
627
+ // reportData shape: { [tableName]: rowsArray }
628
+ const [reportData, setReportData] = useState({});
629
+ const [loading, setLoading] = useState(true);
630
+ const [error, setError] = useState("");
631
+
632
+ useEffect(() => {
633
+ const fetchReports = async () => {
634
+ try {
635
+ setLoading(true);
636
+ // Single request returns data for all tables at once
637
+ const res = await API.get("/reports");
638
+ setReportData(res.data);
639
+ } catch (err) {
640
+ setError(err.response?.data?.error || "Failed to load reports");
641
+ } finally {
642
+ setLoading(false);
643
+ }
644
+ };
645
+ fetchReports();
646
+ }, []);
647
+
648
+ return (
649
+ <div className="p-6 space-y-10">
650
+ <h1 className="text-3xl font-bold">Reports Dashboard</h1>
651
+
652
+ {error && (
653
+ <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded">
654
+ {error}
655
+ </div>
656
+ )}
657
+
658
+ {REPORT_CONFIGS.map((report) => {
659
+ const rows = reportData[report.table] || [];
660
+ // The aggregate query returns one row per dimension group.
661
+ // Pass the first row to MetricCards for overall KPIs.
662
+ const firstRow = rows[0] || null;
663
+
664
+ return (
665
+ <div key={report.table} className="bg-white rounded-xl shadow p-6">
666
+ <h2 className="text-xl font-semibold mb-4">{report.title}</h2>
667
+
668
+ {/* KPI metric cards — totals and averages */}
669
+ <MetricCards
670
+ metrics={report.metrics}
671
+ data={firstRow}
672
+ loading={loading}
673
+ />
674
+
675
+ {/* Dimension tags — fields used for grouping */}
676
+ {report.dimensions.length > 0 && (
677
+ <div className="mb-4">
678
+ <p className="text-sm text-gray-500 mb-1">Grouped by</p>
679
+ <div className="flex gap-2 flex-wrap">
680
+ {report.dimensions.map((dim) => (
681
+ <span key={dim} className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm">
682
+ {dim.replace(/_/g, " ")}
683
+ </span>
684
+ ))}
685
+ </div>
686
+ </div>
687
+ )}
688
+
689
+ {/* Data table — all rows from this report's endpoint */}
690
+ <ReportTable rows={rows} loading={loading} />
691
+ </div>
692
+ );
693
+ })}
694
+ </div>
695
+ );
696
+ }
697
+ `
698
+ );
699
+
700
+ console.log(" [✓] report configs, shared components, Reports.jsx");
701
+
702
+ } else if (needsReports) {
703
+ // needsReports true but no tables opted in — generate a placeholder
704
+ await fs.outputFile(
705
+ path.join(root, "src", "pages", "Reports.jsx"),
706
+ `export default function Reports() {
707
+ return (
708
+ <div className="p-6">
709
+ <h1 className="text-2xl font-bold mb-4">Reports</h1>
710
+ <p className="text-gray-500">
711
+ No tables were configured for reporting. Re-run the generator and answer
712
+ "Yes" to "Generate a report for this table?" for at least one table.
713
+ </p>
714
+ </div>
715
+ );
716
+ }
717
+ `
718
+ );
719
+ }
720
+
721
+ // ── App.jsx ────────────────────────────────────────────────────────────
722
+ const imports = tables
723
+ .map((t) => `import ${toPascal(t.name)} from "./pages/${toPascal(t.name)}";`)
724
+ .join("\n");
725
+
726
+ const routes = tables
727
+ .map((t) =>
728
+ needsAuth
729
+ ? ` <Route path="/${toRoute(t.name)}" element={<PrivateRoute><${toPascal(t.name)} /></PrivateRoute>} />`
730
+ : ` <Route path="/${toRoute(t.name)}" element={<${toPascal(t.name)} />} />`
731
+ )
732
+ .join("\n");
733
+
734
+ const navLinks = tables
735
+ .map((t) => ` <Link to="/${toRoute(t.name)}" className="hover:underline">${toPascal(t.name)}</Link>`)
736
+ .join("\n");
737
+
738
+ const app = `import { BrowserRouter, Routes, Route, Link, useNavigate } from "react-router-dom";
739
+ ${imports}
740
+ ${needsAuth ? 'import Login from "./pages/Login";\nimport PrivateRoute from "./components/PrivateRoute";\nimport { AuthContext, AuthProvider } from "./context/AuthContext";\nimport { useContext } from "react";' : ""}
741
+ ${needsReports ? 'import Reports from "./pages/Reports";' : ""}
742
+ import Home from "./pages/Home";
743
+ import API from "./api/axios";
744
+
745
+ function Navbar() {
746
+ const navigate = useNavigate();
747
+ ${needsAuth ? ' const { user } = useContext(AuthContext);' : ""}
748
+
749
+ const logout = async () => {
750
+ try {
751
+ await API.post("/auth/logout");
752
+ navigate("/login");
753
+ window.location.reload();
754
+ } catch (err) {
755
+ console.error("Logout failed:", err);
756
+ }
757
+ };
758
+
759
+ return (
760
+ <nav className="bg-blue-700 text-white px-6 py-3 flex gap-6 items-center">
761
+ <span className="font-bold text-lg"><Link to="/" className="hover:opacity-80">${projectName}</Link></span>
762
+ ${navLinks}
763
+ ${needsReports ? ' <Link to="/reports" className="hover:underline">Reports</Link>' : ""}
764
+ ${needsAuth ? ' {user && <button onClick={logout} className="ml-auto hover:underline">Logout ({user.username})</button>}' : ""}
765
+ </nav>
766
+ );
767
+ }
768
+
769
+ function AppRoutes() {
770
+ return (
771
+ <>
772
+ <Navbar />
773
+ <Routes>
774
+ ${needsAuth ? ' <Route path="/login" element={<Login />} />' : ""}
775
+ <Route path="/" element={<Home />} />
776
+ ${routes}
777
+ ${needsReports ? (needsAuth ? ' <Route path="/reports" element={<PrivateRoute><Reports /></PrivateRoute>} />' : ' <Route path="/reports" element={<Reports />} />') : ""}
778
+ </Routes>
779
+ </>
780
+ );
781
+ }
782
+
783
+ export default function App() {
784
+ return (
785
+ ${needsAuth ? ' <AuthProvider>\n <BrowserRouter>\n <AppRoutes />\n </BrowserRouter>\n </AuthProvider>' : ' <BrowserRouter>\n <AppRoutes />\n </BrowserRouter>'}
786
+ );
787
+ }
788
+ `;
789
+
790
+ await fs.outputFile(path.join(root, "src", "App.jsx"), app);
791
+ console.log(" [✓] App.jsx");
792
+ }
793
+
794
+ module.exports = { generateFrontend };