create-stackflow 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.
@@ -0,0 +1,723 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import ora from "ora";
4
+ import { execa } from "execa";
5
+ import { jsxExt, writeTemplate } from "../utils/template.js";
6
+
7
+ export async function createFrontend(context) {
8
+ await scaffoldOfficialFrontend(context);
9
+ await installFrontendDependencies(context);
10
+ await writeFrontendOverlay(context);
11
+ }
12
+
13
+ async function scaffoldOfficialFrontend(context) {
14
+ const spinner = ora(`Creating ${context.frontend === "next" ? "Next.js" : "React"} frontend with official generator`).start();
15
+ try {
16
+ if (context.frontend === "next") {
17
+ const args = [
18
+ "create-next-app@latest",
19
+ path.basename(context.frontendDir),
20
+ context.language === "typescript" ? "--ts" : "--js",
21
+ context.tailwind ? "--tailwind" : "--no-tailwind",
22
+ "--eslint",
23
+ "--app",
24
+ "--src-dir",
25
+ "--use-npm",
26
+ "--import-alias",
27
+ "@/*"
28
+ ];
29
+ await execa("npx", args, { cwd: context.projectDir, stdio: "ignore" });
30
+ } else {
31
+ const template = context.language === "typescript" ? "react-ts" : "react";
32
+ await execa("npm", ["create", "vite@latest", path.basename(context.frontendDir), "--", "--template", template], {
33
+ cwd: context.projectDir,
34
+ stdio: "ignore"
35
+ });
36
+ }
37
+ spinner.succeed("Creating frontend with official generator");
38
+ } catch (error) {
39
+ spinner.fail("Creating frontend with official generator");
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ async function installFrontendDependencies(context) {
45
+ await updateFrontendPackage(context);
46
+ if (context.skipInstall) return;
47
+ const spinner = ora("Installing frontend dependencies").start();
48
+ try {
49
+ const deps = [
50
+ "axios@latest",
51
+ "sonner@latest",
52
+ "lucide-react@latest",
53
+ "clsx@latest",
54
+ "tailwind-merge@latest"
55
+ ];
56
+ if (context.frontend === "react" && context.router) deps.push("react-router-dom@latest");
57
+ if (context.state === "zustand") deps.push("zustand@latest");
58
+ if (context.state === "redux-toolkit") deps.push("@reduxjs/toolkit@latest", "react-redux@latest");
59
+ if (context.form && context.form !== "none") deps.push("react-hook-form@latest", "@hookform/resolvers@latest");
60
+ if (context.validation === "zod") deps.push("zod@latest");
61
+ if (context.validation === "yup") deps.push("yup@latest");
62
+ if (context.tailwind && context.frontend === "react") deps.push("tailwindcss@latest", "@tailwindcss/vite@latest");
63
+
64
+ await execa("npm", ["install", ...deps], { cwd: context.frontendDir, stdio: "ignore" });
65
+ spinner.succeed("Installing frontend dependencies");
66
+ } catch (error) {
67
+ spinner.fail("Installing frontend dependencies");
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async function updateFrontendPackage(context) {
73
+ const packageFile = path.join(context.frontendDir, "package.json");
74
+ const pkg = await fs.readJson(packageFile);
75
+ pkg.dependencies = { ...(pkg.dependencies || {}) };
76
+ pkg.devDependencies = { ...(pkg.devDependencies || {}) };
77
+
78
+ const deps = [
79
+ "axios",
80
+ "sonner",
81
+ "lucide-react",
82
+ "clsx",
83
+ "tailwind-merge"
84
+ ];
85
+ if (context.frontend === "react" && context.router) deps.push("react-router-dom");
86
+ if (context.state === "zustand") deps.push("zustand");
87
+ if (context.state === "redux-toolkit") deps.push("@reduxjs/toolkit", "react-redux");
88
+ if (context.form && context.form !== "none") deps.push("react-hook-form", "@hookform/resolvers");
89
+ if (context.validation === "zod") deps.push("zod");
90
+ if (context.validation === "yup") deps.push("yup");
91
+
92
+ for (const dep of deps) pkg.dependencies[dep] = "latest";
93
+ if (context.tailwind && context.frontend === "react") {
94
+ pkg.devDependencies.tailwindcss = "latest";
95
+ pkg.devDependencies["@tailwindcss/vite"] = "latest";
96
+ }
97
+
98
+ if (context.frontend === "react") {
99
+ pkg.scripts = { ...(pkg.scripts || {}), build: "vite build" };
100
+ }
101
+
102
+ await fs.writeJson(packageFile, pkg, { spaces: 2 });
103
+ }
104
+
105
+ async function writeFrontendOverlay(context) {
106
+ const spinner = ora("Writing StackFlow frontend features").start();
107
+ try {
108
+ if (context.frontend === "next") {
109
+ await writeNextOverlay(context);
110
+ } else {
111
+ await writeReactOverlay(context);
112
+ }
113
+ spinner.succeed("Writing StackFlow frontend features");
114
+ } catch (error) {
115
+ spinner.fail("Writing StackFlow frontend features");
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ async function writeReactOverlay(context) {
121
+ const x = jsxExt(context);
122
+ const src = path.join(context.frontendDir, "src");
123
+ await fs.ensureDir(path.join(src, "components", "ui"));
124
+ await fs.ensureDir(path.join(src, "features", "auth"));
125
+ await fs.ensureDir(path.join(src, "features", "tasks"));
126
+ await fs.ensureDir(path.join(src, "lib"));
127
+ await fs.ensureDir(path.join(src, "pages"));
128
+ await fs.ensureDir(path.join(src, "routes"));
129
+ await fs.ensureDir(path.join(src, "store"));
130
+
131
+ await writeTemplate(path.join(context.frontendDir, ".env"), "VITE_API_URL=http://localhost:5000/api\n");
132
+ await relaxTypescriptConfig(context, "react");
133
+ if (context.tailwind) {
134
+ await writeTemplate(path.join(context.frontendDir, "vite.config." + (context.language === "typescript" ? "ts" : "js")), `import { defineConfig } from "vite";
135
+ import react from "@vitejs/plugin-react";
136
+ import tailwindcss from "@tailwindcss/vite";
137
+
138
+ export default defineConfig({
139
+ plugins: [react(), tailwindcss()],
140
+ server: {
141
+ port: 5173
142
+ }
143
+ });
144
+ `);
145
+ }
146
+
147
+ const files = reactTemplates(context, x);
148
+ await Promise.all(Object.entries(files).map(([file, content]) => writeTemplate(path.join(src, file), content, context)));
149
+ if (context.language === "typescript") {
150
+ await writeTemplate(path.join(src, "vite-env.d.ts"), `/// <reference types="vite/client" />
151
+ `);
152
+ }
153
+ }
154
+
155
+ async function writeNextOverlay(context) {
156
+ const x = jsxExt(context);
157
+ const src = path.join(context.frontendDir, "src");
158
+ await fs.ensureDir(path.join(src, "app", "dashboard"));
159
+ await fs.ensureDir(path.join(src, "app", "login"));
160
+ await fs.ensureDir(path.join(src, "app", "register"));
161
+ await fs.ensureDir(path.join(src, "components", "ui"));
162
+ await fs.ensureDir(path.join(src, "features", "auth"));
163
+ await fs.ensureDir(path.join(src, "features", "tasks"));
164
+ await fs.ensureDir(path.join(src, "lib"));
165
+ await fs.ensureDir(path.join(src, "store"));
166
+
167
+ await removeNextGeneratedDuplicates(context, x);
168
+ await writeTemplate(path.join(context.frontendDir, ".env.local"), "NEXT_PUBLIC_API_URL=http://localhost:5000/api\n");
169
+ await relaxTypescriptConfig(context, "next");
170
+ const files = nextTemplates(context, x);
171
+ await Promise.all(Object.entries(files).map(([file, content]) => writeTemplate(path.join(src, file), content, context)));
172
+ }
173
+
174
+ async function removeNextGeneratedDuplicates(context, x) {
175
+ const appDir = path.join(context.frontendDir, "src", "app");
176
+ const generated = ["js", "jsx", "ts", "tsx"].filter((candidate) => candidate !== x);
177
+ await Promise.all(generated.flatMap((candidate) => [
178
+ fs.remove(path.join(appDir, `page.${candidate}`)),
179
+ fs.remove(path.join(appDir, `layout.${candidate}`)),
180
+ fs.remove(path.join(appDir, "login", `page.${candidate}`)),
181
+ fs.remove(path.join(appDir, "register", `page.${candidate}`)),
182
+ fs.remove(path.join(appDir, "dashboard", `page.${candidate}`))
183
+ ]));
184
+ }
185
+
186
+ async function relaxTypescriptConfig(context, framework) {
187
+ if (context.language !== "typescript") return;
188
+ if (framework === "react") {
189
+ await writeTemplate(path.join(context.frontendDir, "tsconfig.app.json"), `{
190
+ "compilerOptions": {
191
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
192
+ "target": "ES2022",
193
+ "useDefineForClassFields": true,
194
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
195
+ "allowJs": false,
196
+ "skipLibCheck": true,
197
+ "esModuleInterop": true,
198
+ "allowSyntheticDefaultImports": true,
199
+ "strict": false,
200
+ "noImplicitAny": false,
201
+ "noUnusedLocals": false,
202
+ "noUnusedParameters": false,
203
+ "module": "ESNext",
204
+ "moduleResolution": "Bundler",
205
+ "isolatedModules": true,
206
+ "noEmit": true,
207
+ "jsx": "react-jsx"
208
+ },
209
+ "include": ["src"]
210
+ }
211
+ `);
212
+ } else {
213
+ await writeTemplate(path.join(context.frontendDir, "tsconfig.json"), `{
214
+ "compilerOptions": {
215
+ "target": "ES2017",
216
+ "lib": ["dom", "dom.iterable", "esnext"],
217
+ "allowJs": true,
218
+ "skipLibCheck": true,
219
+ "strict": false,
220
+ "noImplicitAny": false,
221
+ "noUnusedLocals": false,
222
+ "noUnusedParameters": false,
223
+ "noEmit": true,
224
+ "esModuleInterop": true,
225
+ "module": "esnext",
226
+ "moduleResolution": "bundler",
227
+ "resolveJsonModule": true,
228
+ "isolatedModules": true,
229
+ "jsx": "preserve",
230
+ "incremental": true,
231
+ "plugins": [{ "name": "next" }],
232
+ "paths": { "@/*": ["./src/*"] }
233
+ },
234
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
235
+ "exclude": ["node_modules"]
236
+ }
237
+ `);
238
+ }
239
+ }
240
+
241
+ function commonTemplates(context, x) {
242
+ const isTs = context.language === "typescript";
243
+ const typeBlock = isTs ? `export type User = { id: string; name: string; email: string };
244
+ export type Task = { _id: string; title: string; description?: string; imageUrl?: string; status: "todo" | "in-progress" | "done" };
245
+ ` : "";
246
+ const schema = context.validation === "none" || context.validation === "joi"
247
+ ? `export const authSchema = null;
248
+ export const taskSchema = null;
249
+ `
250
+ : context.validation === "zod"
251
+ ? `import { z } from "zod";
252
+
253
+ export const authSchema = z.object({
254
+ name: z.string().min(2).optional(),
255
+ email: z.string().email(),
256
+ password: z.string().min(6)
257
+ });
258
+
259
+ export const taskSchema = z.object({
260
+ title: z.string().min(2),
261
+ description: z.string().optional(),
262
+ status: z.enum(["todo", "in-progress", "done"]).default("todo")
263
+ });
264
+ `
265
+ : `import * as yup from "yup";
266
+
267
+ export const authSchema = yup.object({
268
+ name: yup.string().min(2),
269
+ email: yup.string().email().required(),
270
+ password: yup.string().min(6).required()
271
+ });
272
+
273
+ export const taskSchema = yup.object({
274
+ title: yup.string().min(2).required(),
275
+ description: yup.string(),
276
+ status: yup.string().oneOf(["todo", "in-progress", "done"]).default("todo")
277
+ });
278
+ `;
279
+
280
+ const authStore = context.state === "zustand"
281
+ ? `import { create } from "zustand";
282
+ ${isTs ? "import type { User } from \"../lib/types\";\n" : ""}
283
+
284
+ export const useAuthStore = create((set) => ({
285
+ user: null,
286
+ token: typeof window !== "undefined" ? localStorage.getItem("stackflow_token") : null,
287
+ setSession: ({ user, token }) => {
288
+ if (token && typeof window !== "undefined") localStorage.setItem("stackflow_token", token);
289
+ set({ user, token });
290
+ },
291
+ logout: () => {
292
+ if (typeof window !== "undefined") localStorage.removeItem("stackflow_token");
293
+ set({ user: null, token: null });
294
+ }
295
+ }));
296
+ `
297
+ : context.state === "redux-toolkit"
298
+ ? `import { configureStore, createSlice } from "@reduxjs/toolkit";
299
+
300
+ const authSlice = createSlice({
301
+ name: "auth",
302
+ initialState: { user: null, token: typeof window !== "undefined" ? localStorage.getItem("stackflow_token") : null },
303
+ reducers: {
304
+ setSession: (state, action) => {
305
+ state.user = action.payload.user;
306
+ state.token = action.payload.token;
307
+ if (action.payload.token && typeof window !== "undefined") localStorage.setItem("stackflow_token", action.payload.token);
308
+ },
309
+ logout: (state) => {
310
+ state.user = null;
311
+ state.token = null;
312
+ if (typeof window !== "undefined") localStorage.removeItem("stackflow_token");
313
+ }
314
+ }
315
+ });
316
+
317
+ export const { setSession, logout } = authSlice.actions;
318
+ export const store = configureStore({ reducer: { auth: authSlice.reducer } });
319
+ `
320
+ : `import { createContext, useContext, useState } from "react";
321
+
322
+ const AuthContext = createContext(null);
323
+
324
+ export function AuthProvider({ children }) {
325
+ const [session, setSessionState] = useState({ user: null, token: typeof window !== "undefined" ? localStorage.getItem("stackflow_token") : null });
326
+ const setSession = ({ user, token }) => {
327
+ if (token && typeof window !== "undefined") localStorage.setItem("stackflow_token", token);
328
+ setSessionState({ user, token });
329
+ };
330
+ const logout = () => {
331
+ if (typeof window !== "undefined") localStorage.removeItem("stackflow_token");
332
+ setSessionState({ user: null, token: null });
333
+ };
334
+ return <AuthContext.Provider value={{ ...session, setSession, logout }}>{children}</AuthContext.Provider>;
335
+ }
336
+
337
+ export const useAuthContext = () => useContext(AuthContext);
338
+ `;
339
+
340
+ return {
341
+ [`lib/types.${isTs ? "ts" : "js"}`]: typeBlock || "export {};\n",
342
+ [`lib/api.${isTs ? "ts" : "js"}`]: `import axios from "axios";
343
+
344
+ export const api = axios.create({
345
+ baseURL: ${context.frontend === "next" ? "process.env.NEXT_PUBLIC_API_URL" : "import.meta.env.VITE_API_URL"} || "http://localhost:5000/api",
346
+ withCredentials: true
347
+ });
348
+
349
+ api.interceptors.request.use((config) => {
350
+ const token = typeof window !== "undefined" ? localStorage.getItem("stackflow_token") : null;
351
+ if (token) config.headers.Authorization = \`Bearer \${token}\`;
352
+ return config;
353
+ });
354
+ `,
355
+ [`lib/validation.${isTs ? "ts" : "js"}`]: schema,
356
+ [`store/auth.${x}`]: authStore,
357
+ [`features/auth/auth.service.${isTs ? "ts" : "js"}`]: `import { api } from "../../lib/api";
358
+
359
+ export const authService = {
360
+ register: (payload) => api.post("/auth/register", payload).then((res) => res.data),
361
+ login: (payload) => api.post("/auth/login", payload).then((res) => res.data),
362
+ me: () => api.get("/auth/me").then((res) => res.data),
363
+ logout: () => api.post("/auth/logout").then((res) => res.data)
364
+ };
365
+ `,
366
+ [`features/tasks/task.service.${isTs ? "ts" : "js"}`]: `import { api } from "../../lib/api";
367
+
368
+ export const taskService = {
369
+ list: () => api.get("/tasks").then((res) => res.data.tasks),
370
+ create: (payload) => api.post("/tasks", payload, multipartConfig(payload)).then((res) => res.data.task),
371
+ update: (id, payload) => api.patch(\`/tasks/\${id}\`, payload, multipartConfig(payload)).then((res) => res.data.task),
372
+ remove: (id) => api.delete(\`/tasks/\${id}\`).then((res) => res.data)
373
+ };
374
+
375
+ function multipartConfig(payload) {
376
+ return payload instanceof FormData ? { headers: { "Content-Type": "multipart/form-data" } } : undefined;
377
+ }
378
+ `,
379
+ [`components/ui/button.${x}`]: `import { clsx } from "clsx";
380
+
381
+ export function Button({ className = "", variant = "primary", ...props }) {
382
+ return (
383
+ <button
384
+ className={clsx(
385
+ "inline-flex h-10 items-center justify-center rounded-md px-4 text-sm font-medium transition disabled:opacity-50",
386
+ variant === "primary" && "bg-slate-950 text-white hover:bg-slate-800 dark:bg-white dark:text-slate-950",
387
+ variant === "ghost" && "hover:bg-slate-100 dark:hover:bg-slate-800",
388
+ className
389
+ )}
390
+ {...props}
391
+ />
392
+ );
393
+ }
394
+ `,
395
+ [`components/ui/input.${x}`]: `import { clsx } from "clsx";
396
+
397
+ export function Input(props) {
398
+ return <input className={clsx("h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm outline-none focus:ring-2 focus:ring-cyan-500 dark:border-slate-700 dark:bg-slate-900", props.className)} {...props} />;
399
+ }
400
+ `
401
+ };
402
+ }
403
+
404
+ function reactTemplates(context, x) {
405
+ const isTs = context.language === "typescript";
406
+ return {
407
+ ...commonTemplates(context, x),
408
+ "index.css": `@import "tailwindcss";
409
+ @custom-variant dark (&:where(.dark, .dark *));
410
+
411
+ :root { color-scheme: light; }
412
+ .dark { color-scheme: dark; }
413
+ body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
414
+ `,
415
+ [`main.${x}`]: `import React from "react";
416
+ import ReactDOM from "react-dom/client";
417
+ ${context.router ? "import { BrowserRouter } from \"react-router-dom\";" : ""}
418
+ ${context.state === "redux-toolkit" ? "import { Provider } from \"react-redux\";\nimport { store } from \"./store/auth\";" : ""}
419
+ ${context.state === "context-api" ? "import { AuthProvider } from \"./store/auth\";" : ""}
420
+ import { Toaster } from "sonner";
421
+ import App from "./App";
422
+ import "./index.css";
423
+
424
+ ReactDOM.createRoot(document.getElementById("root")).render(
425
+ <React.StrictMode>
426
+ ${context.router ? "<BrowserRouter>" : ""}
427
+ ${context.state === "redux-toolkit" ? "<Provider store={store}>" : ""}
428
+ ${context.state === "context-api" ? "<AuthProvider>" : ""}
429
+ <App />
430
+ <Toaster richColors position="top-right" />
431
+ ${context.state === "context-api" ? "</AuthProvider>" : ""}
432
+ ${context.state === "redux-toolkit" ? "</Provider>" : ""}
433
+ ${context.router ? "</BrowserRouter>" : ""}
434
+ </React.StrictMode>
435
+ );
436
+ `,
437
+ [`App.${x}`]: `${context.router ? `import { Navigate, Route, Routes } from "react-router-dom";
438
+ import { Dashboard } from "./pages/Dashboard";
439
+ import { Login } from "./pages/Login";
440
+ import { Register } from "./pages/Register";
441
+ import { ProtectedRoute } from "./routes/ProtectedRoute";
442
+
443
+ export default function App() {
444
+ return (
445
+ <Routes>
446
+ <Route path="/" element={<Navigate to="/dashboard" replace />} />
447
+ <Route path="/login" element={<Login />} />
448
+ <Route path="/register" element={<Register />} />
449
+ <Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
450
+ </Routes>
451
+ );
452
+ }
453
+ ` : `import { Dashboard } from "./pages/Dashboard";
454
+
455
+ export default function App() {
456
+ return <Dashboard />;
457
+ }
458
+ `}`,
459
+ [`routes/ProtectedRoute.${x}`]: `import { Navigate } from "react-router-dom";
460
+ ${context.state === "zustand" ? "import { useAuthStore } from \"../store/auth\";" : ""}
461
+ ${context.state === "context-api" ? "import { useAuthContext } from \"../store/auth\";" : ""}
462
+ ${context.state === "redux-toolkit" ? "import { useSelector } from \"react-redux\";" : ""}
463
+
464
+ export function ProtectedRoute({ children }) {
465
+ const token = ${context.state === "zustand" ? "useAuthStore((state) => state.token)" : context.state === "context-api" ? "useAuthContext()?.token" : context.state === "redux-toolkit" ? "useSelector((state) => state.auth.token)" : "localStorage.getItem(\"stackflow_token\")"};
466
+ return token ? children : <Navigate to="/login" replace />;
467
+ }
468
+ `,
469
+ [`pages/Login.${x}`]: authPage("login", context),
470
+ [`pages/Register.${x}`]: authPage("register", context),
471
+ [`pages/Dashboard.${x}`]: dashboardPage(context)
472
+ };
473
+ }
474
+
475
+ function nextTemplates(context, x) {
476
+ return {
477
+ ...commonTemplates(context, x),
478
+ "app/globals.css": `@import "tailwindcss";
479
+ @custom-variant dark (&:where(.dark, .dark *));
480
+
481
+ body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
482
+ `,
483
+ [`app/layout.${x}`]: `import { Toaster } from "sonner";
484
+ import "./globals.css";
485
+
486
+ export const metadata = { title: "StackFlow", description: "Generated full-stack app" };
487
+
488
+ export default function RootLayout({ children }) {
489
+ return (
490
+ <html lang="en">
491
+ <body>
492
+ {children}
493
+ <Toaster richColors position="top-right" />
494
+ </body>
495
+ </html>
496
+ );
497
+ }
498
+ `,
499
+ [`app/page.${x}`]: `import { redirect } from "next/navigation";
500
+
501
+ export default function Home() {
502
+ redirect("/dashboard");
503
+ }
504
+ `,
505
+ [`app/login/page.${x}`]: `"use client";
506
+ ${authPage("login", context, true)}
507
+ export default Login;
508
+ `,
509
+ [`app/register/page.${x}`]: `"use client";
510
+ ${authPage("register", context, true)}
511
+ export default Register;
512
+ `,
513
+ [`app/dashboard/page.${x}`]: `"use client";
514
+ ${dashboardPage(context, true)}
515
+ export default Dashboard;
516
+ `
517
+ };
518
+ }
519
+
520
+ function authPage(mode, context, next = false) {
521
+ const isRegister = mode === "register";
522
+ const navImport = next ? "import { useRouter } from \"next/navigation\";" : "import { Link, useNavigate } from \"react-router-dom\";";
523
+ const navHook = next ? "const router = useRouter();" : "const navigate = useNavigate();";
524
+ const redirect = next ? "router.push(\"/dashboard\");" : "navigate(\"/dashboard\");";
525
+ const link = next
526
+ ? `<button className="text-sm text-cyan-700" onClick={() => router.push("${isRegister ? "/login" : "/register"}")}>${isRegister ? "Sign in" : "Create account"}</button>`
527
+ : `<Link className="text-sm text-cyan-700" to="${isRegister ? "/login" : "/register"}">${isRegister ? "Sign in" : "Create account"}</Link>`;
528
+ return `import { useState } from "react";
529
+ ${navImport}
530
+ import { toast } from "sonner";
531
+ import { Button } from "${next ? "../../components/ui/button" : "../components/ui/button"}";
532
+ import { Input } from "${next ? "../../components/ui/input" : "../components/ui/input"}";
533
+ import { authService } from "${next ? "../../features/auth/auth.service" : "../features/auth/auth.service"}";
534
+ ${context.state === "zustand" ? `import { useAuthStore } from "${next ? "../../store/auth" : "../store/auth"}";` : ""}
535
+
536
+ export function ${isRegister ? "Register" : "Login"}() {
537
+ ${navHook}
538
+ const setSession = ${context.state === "zustand" ? "useAuthStore((state) => state.setSession)" : "({ user, token }) => localStorage.setItem(\"stackflow_token\", token)"};
539
+ const [loading, setLoading] = useState(false);
540
+ const [form, setForm] = useState({ name: "", email: "", password: "" });
541
+
542
+ async function onSubmit(event) {
543
+ event.preventDefault();
544
+ setLoading(true);
545
+ try {
546
+ const result = await authService.${mode}(form);
547
+ setSession(result);
548
+ toast.success("${isRegister ? "Account created" : "Welcome back"}");
549
+ ${redirect}
550
+ } catch (error) {
551
+ toast.error(error?.response?.data?.message || "Authentication failed");
552
+ } finally {
553
+ setLoading(false);
554
+ }
555
+ }
556
+
557
+ return (
558
+ <main className="grid min-h-screen place-items-center bg-slate-50 px-4 text-slate-950 dark:bg-slate-950 dark:text-white">
559
+ <form onSubmit={onSubmit} className="w-full max-w-md space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
560
+ <div>
561
+ <h1 className="text-2xl font-semibold">${isRegister ? "Create your account" : "Sign in to StackFlow"}</h1>
562
+ <p className="mt-1 text-sm text-slate-500">${isRegister ? "Start managing your dashboard tasks." : "Continue to your protected dashboard."}</p>
563
+ </div>
564
+ ${isRegister ? `<Input placeholder="Name" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />` : ""}
565
+ <Input placeholder="Email" type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
566
+ <Input placeholder="Password" type="password" value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
567
+ <Button className="w-full" disabled={loading}>{loading ? "Please wait..." : "${isRegister ? "Create account" : "Sign in"}"}</Button>
568
+ <div className="text-center">${link}</div>
569
+ </form>
570
+ </main>
571
+ );
572
+ }
573
+ `;
574
+ }
575
+
576
+ function dashboardPage(context, next = false) {
577
+ const logoutNav = next ? "router.push(\"/login\");" : "navigate(\"/login\");";
578
+ const navImports = next ? "import { useRouter } from \"next/navigation\";" : "import { useNavigate } from \"react-router-dom\";";
579
+ const componentName = next ? "function Dashboard()" : "export function Dashboard()";
580
+ return `import { useEffect, useState } from "react";
581
+ ${navImports}
582
+ import { Edit3, ImagePlus, LogOut, Moon, Plus, Save, Sun, Trash2, X } from "lucide-react";
583
+ import { toast } from "sonner";
584
+ import { Button } from "${next ? "../../components/ui/button" : "../components/ui/button"}";
585
+ import { Input } from "${next ? "../../components/ui/input" : "../components/ui/input"}";
586
+ import { taskService } from "${next ? "../../features/tasks/task.service" : "../features/tasks/task.service"}";
587
+ ${context.state === "zustand" ? `import { useAuthStore } from "${next ? "../../store/auth" : "../store/auth"}";` : ""}
588
+
589
+ ${componentName} {
590
+ const ${next ? "router" : "navigate"} = ${next ? "useRouter()" : "useNavigate()"};
591
+ const logoutStore = ${context.state === "zustand" ? "useAuthStore((state) => state.logout)" : "() => localStorage.removeItem(\"stackflow_token\")"};
592
+ const [dark, setDark] = useState(() => typeof window !== "undefined" && localStorage.getItem("stackflow_theme") === "dark");
593
+ const [loading, setLoading] = useState(true);
594
+ const [tasks, setTasks] = useState([]);
595
+ const [form, setForm] = useState({ title: "", description: "", status: "todo", image: null });
596
+ const [editingId, setEditingId] = useState(null);
597
+
598
+ useEffect(() => {
599
+ document.documentElement.classList.toggle("dark", dark);
600
+ if (typeof window !== "undefined") localStorage.setItem("stackflow_theme", dark ? "dark" : "light");
601
+ }, [dark]);
602
+
603
+ useEffect(() => {
604
+ taskService.list()
605
+ .then(setTasks)
606
+ .catch(() => toast.error("Could not load tasks"))
607
+ .finally(() => setLoading(false));
608
+ }, []);
609
+
610
+ async function createTask(event) {
611
+ event.preventDefault();
612
+ if (!form.title.trim()) return;
613
+ const payload = toPayload(form);
614
+ const saved = editingId
615
+ ? await taskService.update(editingId, payload)
616
+ : await taskService.create(payload);
617
+ setTasks(editingId ? tasks.map((task) => task._id === editingId ? saved : task) : [saved, ...tasks]);
618
+ resetForm();
619
+ toast.success(editingId ? "Task updated" : "Task created");
620
+ }
621
+
622
+ function startEdit(task) {
623
+ setEditingId(task._id);
624
+ setForm({
625
+ title: task.title || "",
626
+ description: task.description || "",
627
+ status: task.status || "todo",
628
+ image: null
629
+ });
630
+ }
631
+
632
+ function resetForm() {
633
+ setEditingId(null);
634
+ setForm({ title: "", description: "", status: "todo", image: null });
635
+ }
636
+
637
+ async function removeTask(id) {
638
+ await taskService.remove(id);
639
+ setTasks(tasks.filter((task) => task._id !== id));
640
+ toast.success("Task deleted");
641
+ }
642
+
643
+ function logout() {
644
+ logoutStore();
645
+ ${logoutNav}
646
+ }
647
+
648
+ function toPayload(values) {
649
+ ${context.multer ? `const data = new FormData();
650
+ data.append("title", values.title);
651
+ data.append("description", values.description);
652
+ data.append("status", values.status);
653
+ if (values.image) data.append("image", values.image);
654
+ return data;` : `return {
655
+ title: values.title,
656
+ description: values.description,
657
+ status: values.status
658
+ };`}
659
+ }
660
+
661
+ return (
662
+ <main className="min-h-screen bg-slate-50 text-slate-950 dark:bg-slate-950 dark:text-white">
663
+ <header className="border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
664
+ <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
665
+ <div>
666
+ <h1 className="text-xl font-semibold">StackFlow Dashboard</h1>
667
+ <p className="text-sm text-slate-500">Protected MERN CRUD workspace</p>
668
+ </div>
669
+ <div className="flex gap-2">
670
+ <Button variant="ghost" onClick={() => setDark(!dark)} aria-label="Toggle dark mode">{dark ? <Sun size={18} /> : <Moon size={18} />}</Button>
671
+ <Button variant="ghost" onClick={logout} aria-label="Logout"><LogOut size={18} /></Button>
672
+ </div>
673
+ </div>
674
+ </header>
675
+ <section className="mx-auto grid max-w-6xl gap-6 px-4 py-8 md:grid-cols-[1fr_320px]">
676
+ <div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
677
+ <div className="mb-4 flex items-center justify-between">
678
+ <h2 className="text-lg font-semibold">Tasks</h2>
679
+ <span className="text-sm text-slate-500">{tasks.length} total</span>
680
+ </div>
681
+ {loading ? <p className="text-sm text-slate-500">Loading tasks...</p> : (
682
+ <div className="space-y-3">
683
+ {tasks.map((task) => (
684
+ <div key={task._id} className="grid gap-3 rounded-md border border-slate-200 p-3 dark:border-slate-800 sm:grid-cols-[96px_1fr_auto]">
685
+ <div className="h-24 overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800">
686
+ {task.imageUrl ? <img className="h-full w-full object-cover" src={\`http://localhost:5000\${task.imageUrl}\`} alt={task.title} /> : <div className="grid h-full place-items-center text-slate-400"><ImagePlus size={20} /></div>}
687
+ </div>
688
+ <div>
689
+ <p className="font-medium">{task.title}</p>
690
+ <p className="mt-1 text-sm text-slate-500">{task.description || "No description"}</p>
691
+ <p className="mt-2 text-xs uppercase tracking-wide text-slate-500">{task.status}</p>
692
+ </div>
693
+ <div className="flex items-start gap-2">
694
+ <Button variant="ghost" onClick={() => startEdit(task)} aria-label="Edit task"><Edit3 size={16} /></Button>
695
+ <Button variant="ghost" onClick={() => removeTask(task._id)} aria-label="Delete task"><Trash2 size={16} /></Button>
696
+ </div>
697
+ </div>
698
+ ))}
699
+ {!tasks.length && <p className="text-sm text-slate-500">No tasks yet.</p>}
700
+ </div>
701
+ )}
702
+ </div>
703
+ <form onSubmit={createTask} className="h-fit space-y-3 rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
704
+ <div className="flex items-center justify-between">
705
+ <h2 className="text-lg font-semibold">{editingId ? "Edit task" : "Create task"}</h2>
706
+ {editingId && <Button type="button" variant="ghost" onClick={resetForm} aria-label="Cancel edit"><X size={18} /></Button>}
707
+ </div>
708
+ <Input value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} placeholder="Task title" />
709
+ <Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="Description" />
710
+ <select className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-900" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
711
+ <option value="todo">Todo</option>
712
+ <option value="in-progress">In progress</option>
713
+ <option value="done">Done</option>
714
+ </select>
715
+ ${context.multer ? `<Input type="file" accept="image/*" onChange={(event) => setForm({ ...form, image: event.target.files?.[0] || null })} />` : ""}
716
+ <Button className="w-full" aria-label={editingId ? "Save task" : "Add task"}>{editingId ? <Save size={18} /> : <Plus size={18} />}<span className="ml-2">{editingId ? "Save changes" : "Add task"}</span></Button>
717
+ </form>
718
+ </section>
719
+ </main>
720
+ );
721
+ }
722
+ `;
723
+ }