create-stackflow 1.0.4 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -6
- package/cli.js +1 -1
- package/package.json +1 -1
- package/src/commands/create.js +9 -8
- package/src/generators/backend.js +264 -192
- package/src/generators/chat.js +661 -0
- package/src/generators/frontend.js +227 -118
- package/src/generators/root.js +6 -58
- package/src/utils/install.js +42 -0
- package/src/utils/prompts.js +26 -1
|
@@ -3,6 +3,7 @@ import fs from "fs-extra";
|
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { execa } from "execa";
|
|
5
5
|
import { jsxExt, writeTemplate } from "../utils/template.js";
|
|
6
|
+
import { chatFrontendFiles, chatNextPages } from "./chat.js";
|
|
6
7
|
|
|
7
8
|
export async function createFrontend(context) {
|
|
8
9
|
await scaffoldOfficialFrontend(context);
|
|
@@ -43,30 +44,6 @@ async function scaffoldOfficialFrontend(context) {
|
|
|
43
44
|
|
|
44
45
|
async function installFrontendDependencies(context) {
|
|
45
46
|
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
47
|
}
|
|
71
48
|
|
|
72
49
|
async function updateFrontendPackage(context) {
|
|
@@ -85,6 +62,7 @@ async function updateFrontendPackage(context) {
|
|
|
85
62
|
if (context.frontend === "react" && context.router) deps.push("react-router-dom");
|
|
86
63
|
if (context.state === "zustand") deps.push("zustand");
|
|
87
64
|
if (context.state === "redux-toolkit") deps.push("@reduxjs/toolkit", "react-redux");
|
|
65
|
+
if (context.socketio) deps.push("socket.io-client");
|
|
88
66
|
if (context.form && context.form !== "none") deps.push("react-hook-form", "@hookform/resolvers");
|
|
89
67
|
if (context.validation === "zod") deps.push("zod");
|
|
90
68
|
if (context.validation === "yup") deps.push("yup");
|
|
@@ -95,8 +73,21 @@ async function updateFrontendPackage(context) {
|
|
|
95
73
|
pkg.devDependencies["@tailwindcss/vite"] = "latest";
|
|
96
74
|
}
|
|
97
75
|
|
|
76
|
+
const backendName = path.basename(context.backendDir);
|
|
77
|
+
pkg.devDependencies = pkg.devDependencies || {};
|
|
78
|
+
pkg.devDependencies.concurrently = "latest";
|
|
79
|
+
|
|
98
80
|
if (context.frontend === "react") {
|
|
99
|
-
pkg.scripts = {
|
|
81
|
+
pkg.scripts = {
|
|
82
|
+
...(pkg.scripts || {}),
|
|
83
|
+
build: "vite build",
|
|
84
|
+
dev: `concurrently -n backend,frontend -c blue,green "npm run dev --prefix ../${backendName}" "vite"`,
|
|
85
|
+
};
|
|
86
|
+
} else {
|
|
87
|
+
pkg.scripts = {
|
|
88
|
+
...(pkg.scripts || {}),
|
|
89
|
+
dev: `concurrently -n backend,frontend -c blue,green "npm run dev --prefix ../${backendName}" "next dev"`,
|
|
90
|
+
};
|
|
100
91
|
}
|
|
101
92
|
|
|
102
93
|
await fs.writeJson(packageFile, pkg, { spaces: 2 });
|
|
@@ -122,13 +113,21 @@ async function writeReactOverlay(context) {
|
|
|
122
113
|
const src = path.join(context.frontendDir, "src");
|
|
123
114
|
await fs.ensureDir(path.join(src, "components", "ui"));
|
|
124
115
|
await fs.ensureDir(path.join(src, "features", "auth"));
|
|
125
|
-
await fs.ensureDir(path.join(src, "features", "
|
|
116
|
+
await fs.ensureDir(path.join(src, "features", "todos"));
|
|
117
|
+
if (context.socketio) await fs.ensureDir(path.join(src, "features", "chat"));
|
|
126
118
|
await fs.ensureDir(path.join(src, "lib"));
|
|
127
119
|
await fs.ensureDir(path.join(src, "pages"));
|
|
128
120
|
await fs.ensureDir(path.join(src, "routes"));
|
|
129
|
-
|
|
121
|
+
if (context.state === "redux-toolkit") {
|
|
122
|
+
await fs.ensureDir(path.join(src, "redux", "slices"));
|
|
123
|
+
} else if (context.state !== "none") {
|
|
124
|
+
await fs.ensureDir(path.join(src, "store"));
|
|
125
|
+
}
|
|
130
126
|
|
|
131
|
-
await writeTemplate(
|
|
127
|
+
await writeTemplate(
|
|
128
|
+
path.join(context.frontendDir, ".env"),
|
|
129
|
+
`VITE_API_URL=http://localhost:5000/api\n${context.socketio ? "VITE_SOCKET_URL=http://localhost:5000\n" : ""}`,
|
|
130
|
+
);
|
|
132
131
|
await relaxTypescriptConfig(context, "react");
|
|
133
132
|
if (context.tailwind) {
|
|
134
133
|
await writeTemplate(path.join(context.frontendDir, "vite.config." + (context.language === "typescript" ? "ts" : "js")), `import { defineConfig } from "vite";
|
|
@@ -145,6 +144,8 @@ export default defineConfig({
|
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
const files = reactTemplates(context, x);
|
|
147
|
+
pruneUnusedFrontendFiles(files, context);
|
|
148
|
+
|
|
148
149
|
await Promise.all(Object.entries(files).map(([file, content]) => writeTemplate(path.join(src, file), content, context)));
|
|
149
150
|
if (context.language === "typescript") {
|
|
150
151
|
await writeTemplate(path.join(src, "vite-env.d.ts"), `/// <reference types="vite/client" />
|
|
@@ -160,17 +161,59 @@ async function writeNextOverlay(context) {
|
|
|
160
161
|
await fs.ensureDir(path.join(src, "app", "register"));
|
|
161
162
|
await fs.ensureDir(path.join(src, "components", "ui"));
|
|
162
163
|
await fs.ensureDir(path.join(src, "features", "auth"));
|
|
163
|
-
await fs.ensureDir(path.join(src, "features", "
|
|
164
|
+
await fs.ensureDir(path.join(src, "features", "todos"));
|
|
165
|
+
if (context.socketio) await fs.ensureDir(path.join(src, "features", "chat"));
|
|
164
166
|
await fs.ensureDir(path.join(src, "lib"));
|
|
165
|
-
|
|
167
|
+
if (context.state === "redux-toolkit") {
|
|
168
|
+
await fs.ensureDir(path.join(src, "redux", "slices"));
|
|
169
|
+
} else if (context.state !== "none") {
|
|
170
|
+
await fs.ensureDir(path.join(src, "store"));
|
|
171
|
+
}
|
|
166
172
|
|
|
167
173
|
await removeNextGeneratedDuplicates(context, x);
|
|
168
|
-
await writeTemplate(
|
|
174
|
+
await writeTemplate(
|
|
175
|
+
path.join(context.frontendDir, ".env.local"),
|
|
176
|
+
`NEXT_PUBLIC_API_URL=http://localhost:5000/api\n${context.socketio ? "NEXT_PUBLIC_SOCKET_URL=http://localhost:5000\n" : ""}`,
|
|
177
|
+
);
|
|
169
178
|
await relaxTypescriptConfig(context, "next");
|
|
170
179
|
const files = nextTemplates(context, x);
|
|
180
|
+
pruneUnusedFrontendFiles(files, context);
|
|
181
|
+
|
|
171
182
|
await Promise.all(Object.entries(files).map(([file, content]) => writeTemplate(path.join(src, file), content, context)));
|
|
172
183
|
}
|
|
173
184
|
|
|
185
|
+
function pruneUnusedFrontendFiles(files, context) {
|
|
186
|
+
const x = jsxExt(context);
|
|
187
|
+
const isTs = context.language === "typescript";
|
|
188
|
+
|
|
189
|
+
if (context.state === "none") {
|
|
190
|
+
delete files[`store/auth.${x}`];
|
|
191
|
+
delete files[`redux/store.${isTs ? "ts" : "js"}`];
|
|
192
|
+
delete files[`redux/slices/authSlice.${isTs ? "ts" : "js"}`];
|
|
193
|
+
delete files[`redux/slices/todoSlices.${isTs ? "ts" : "js"}`];
|
|
194
|
+
} else if (context.state === "redux-toolkit") {
|
|
195
|
+
delete files[`store/auth.${x}`];
|
|
196
|
+
} else {
|
|
197
|
+
delete files[`redux/store.${isTs ? "ts" : "js"}`];
|
|
198
|
+
delete files[`redux/slices/authSlice.${isTs ? "ts" : "js"}`];
|
|
199
|
+
delete files[`redux/slices/todoSlices.${isTs ? "ts" : "js"}`];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!isTs) delete files["lib/types.js"];
|
|
203
|
+
if (context.validation === "none") {
|
|
204
|
+
delete files[`lib/validation.${isTs ? "ts" : "js"}`];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!context.socketio) {
|
|
208
|
+
delete files[`lib/socket.${isTs ? "ts" : "js"}`];
|
|
209
|
+
delete files[`features/chat/chatService.${isTs ? "ts" : "js"}`];
|
|
210
|
+
delete files[`pages/Chat.${x}`];
|
|
211
|
+
for (const key of Object.keys(files)) {
|
|
212
|
+
if (key.startsWith("app/chat/")) delete files[key];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
174
217
|
async function removeNextGeneratedDuplicates(context, x) {
|
|
175
218
|
const appDir = path.join(context.frontendDir, "src", "app");
|
|
176
219
|
const generated = ["js", "jsx", "ts", "tsx"].filter((candidate) => candidate !== x);
|
|
@@ -241,11 +284,11 @@ async function relaxTypescriptConfig(context, framework) {
|
|
|
241
284
|
function commonTemplates(context, x) {
|
|
242
285
|
const isTs = context.language === "typescript";
|
|
243
286
|
const typeBlock = isTs ? `export type User = { id: string; name: string; email: string };
|
|
244
|
-
export type
|
|
287
|
+
export type Todo = { _id: string; title: string; description?: string; imageUrl?: string; status: "todo" | "in-progress" | "done" };
|
|
245
288
|
` : "";
|
|
246
|
-
const schema = context.validation === "none"
|
|
289
|
+
const schema = context.validation === "none"
|
|
247
290
|
? `export const authSchema = null;
|
|
248
|
-
export const
|
|
291
|
+
export const todoSchema = null;
|
|
249
292
|
`
|
|
250
293
|
: context.validation === "zod"
|
|
251
294
|
? `import { z } from "zod";
|
|
@@ -256,7 +299,7 @@ export const authSchema = z.object({
|
|
|
256
299
|
password: z.string().min(6)
|
|
257
300
|
});
|
|
258
301
|
|
|
259
|
-
export const
|
|
302
|
+
export const todoSchema = z.object({
|
|
260
303
|
title: z.string().min(2),
|
|
261
304
|
description: z.string().optional(),
|
|
262
305
|
status: z.enum(["todo", "in-progress", "done"]).default("todo")
|
|
@@ -270,7 +313,7 @@ export const authSchema = yup.object({
|
|
|
270
313
|
password: yup.string().min(6).required()
|
|
271
314
|
});
|
|
272
315
|
|
|
273
|
-
export const
|
|
316
|
+
export const todoSchema = yup.object({
|
|
274
317
|
title: yup.string().min(2).required(),
|
|
275
318
|
description: yup.string(),
|
|
276
319
|
status: yup.string().oneOf(["todo", "in-progress", "done"]).default("todo")
|
|
@@ -295,7 +338,43 @@ export const useAuthStore = create((set) => ({
|
|
|
295
338
|
}));
|
|
296
339
|
`
|
|
297
340
|
: context.state === "redux-toolkit"
|
|
298
|
-
?
|
|
341
|
+
? ""
|
|
342
|
+
: `import { createContext, useContext, useState } from "react";
|
|
343
|
+
|
|
344
|
+
const AuthContext = createContext(null);
|
|
345
|
+
|
|
346
|
+
export function AuthProvider({ children }) {
|
|
347
|
+
const [session, setSessionState] = useState({ user: null, token: typeof window !== "undefined" ? localStorage.getItem("stackflow_token") : null });
|
|
348
|
+
const setSession = ({ user, token }) => {
|
|
349
|
+
if (token && typeof window !== "undefined") localStorage.setItem("stackflow_token", token);
|
|
350
|
+
setSessionState({ user, token });
|
|
351
|
+
};
|
|
352
|
+
const logout = () => {
|
|
353
|
+
if (typeof window !== "undefined") localStorage.removeItem("stackflow_token");
|
|
354
|
+
setSessionState({ user: null, token: null });
|
|
355
|
+
};
|
|
356
|
+
return <AuthContext.Provider value={{ ...session, setSession, logout }}>{children}</AuthContext.Provider>;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export const useAuthContext = () => useContext(AuthContext);
|
|
360
|
+
`;
|
|
361
|
+
|
|
362
|
+
const reduxExt = isTs ? "ts" : "js";
|
|
363
|
+
const reduxFiles =
|
|
364
|
+
context.state === "redux-toolkit"
|
|
365
|
+
? {
|
|
366
|
+
[`redux/store.${reduxExt}`]: `import { configureStore } from "@reduxjs/toolkit";
|
|
367
|
+
import authReducer from "./slices/authSlice";
|
|
368
|
+
import todoReducer from "./slices/todoSlices";
|
|
369
|
+
|
|
370
|
+
export const store = configureStore({
|
|
371
|
+
reducer: {
|
|
372
|
+
auth: authReducer,
|
|
373
|
+
todos: todoReducer
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
`,
|
|
377
|
+
[`redux/slices/authSlice.${reduxExt}`]: `import { createSlice } from "@reduxjs/toolkit";
|
|
299
378
|
|
|
300
379
|
const authSlice = createSlice({
|
|
301
380
|
name: "auth",
|
|
@@ -315,35 +394,44 @@ const authSlice = createSlice({
|
|
|
315
394
|
});
|
|
316
395
|
|
|
317
396
|
export const { setSession, logout } = authSlice.actions;
|
|
318
|
-
export
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const AuthContext = createContext(null);
|
|
397
|
+
export default authSlice.reducer;
|
|
398
|
+
`,
|
|
399
|
+
[`redux/slices/todoSlices.${reduxExt}`]: `import { createSlice } from "@reduxjs/toolkit";
|
|
323
400
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
401
|
+
const todoSlices = createSlice({
|
|
402
|
+
name: "todos",
|
|
403
|
+
initialState: { items: [] },
|
|
404
|
+
reducers: {
|
|
405
|
+
setTodos: (state, action) => {
|
|
406
|
+
state.items = action.payload;
|
|
407
|
+
},
|
|
408
|
+
addTodo: (state, action) => {
|
|
409
|
+
state.items.unshift(action.payload);
|
|
410
|
+
},
|
|
411
|
+
updateTodo: (state, action) => {
|
|
412
|
+
const index = state.items.findIndex((todo) => todo._id === action.payload._id);
|
|
413
|
+
if (index !== -1) state.items[index] = action.payload;
|
|
414
|
+
},
|
|
415
|
+
removeTodo: (state, action) => {
|
|
416
|
+
state.items = state.items.filter((todo) => todo._id !== action.payload);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
});
|
|
336
420
|
|
|
337
|
-
export const
|
|
338
|
-
|
|
421
|
+
export const { setTodos, addTodo, updateTodo, removeTodo } = todoSlices.actions;
|
|
422
|
+
export default todoSlices.reducer;
|
|
423
|
+
`,
|
|
424
|
+
}
|
|
425
|
+
: {};
|
|
339
426
|
|
|
340
427
|
return {
|
|
341
|
-
|
|
428
|
+
...(isTs ? { "lib/types.ts": typeBlock } : {}),
|
|
429
|
+
...reduxFiles,
|
|
430
|
+
...chatFrontendFiles(context, x),
|
|
342
431
|
[`lib/api.${isTs ? "ts" : "js"}`]: `import axios from "axios";
|
|
343
432
|
|
|
344
433
|
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
|
|
434
|
+
baseURL: ${context.frontend === "next" ? "process.env.NEXT_PUBLIC_API_URL" : "import.meta.env.VITE_API_URL"} || "http://localhost:5000/api"
|
|
347
435
|
});
|
|
348
436
|
|
|
349
437
|
api.interceptors.request.use((config) => {
|
|
@@ -352,24 +440,26 @@ api.interceptors.request.use((config) => {
|
|
|
352
440
|
return config;
|
|
353
441
|
});
|
|
354
442
|
`,
|
|
355
|
-
[`lib/validation.${isTs ? "ts" : "js"}`]: schema,
|
|
356
|
-
|
|
357
|
-
|
|
443
|
+
...(context.validation !== "none" ? { [`lib/validation.${isTs ? "ts" : "js"}`]: schema } : {}),
|
|
444
|
+
...(context.state !== "none" && context.state !== "redux-toolkit"
|
|
445
|
+
? { [`store/auth.${x}`]: authStore }
|
|
446
|
+
: {}),
|
|
447
|
+
[`features/auth/authService.${isTs ? "ts" : "js"}`]: `import { api } from "../../lib/api";
|
|
358
448
|
|
|
359
449
|
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),
|
|
450
|
+
register: (payload) => api.post("/auth/register", payload).then((res) => res.data.data),
|
|
451
|
+
login: (payload) => api.post("/auth/login", payload).then((res) => res.data.data),
|
|
452
|
+
me: () => api.get("/auth/me").then((res) => res.data.data),
|
|
363
453
|
logout: () => api.post("/auth/logout").then((res) => res.data)
|
|
364
454
|
};
|
|
365
455
|
`,
|
|
366
|
-
[`features/
|
|
456
|
+
[`features/todos/todoService.${isTs ? "ts" : "js"}`]: `import { api } from "../../lib/api";
|
|
367
457
|
|
|
368
|
-
export const
|
|
369
|
-
list: () => api.get("/
|
|
370
|
-
create: (payload) => api.post("/
|
|
371
|
-
update: (id, payload) => api.
|
|
372
|
-
remove: (id) => api.delete(\`/
|
|
458
|
+
export const todoService = {
|
|
459
|
+
list: () => api.get("/todos").then((res) => res.data.data),
|
|
460
|
+
create: (payload) => api.post("/todos", payload, multipartConfig(payload)).then((res) => res.data.data),
|
|
461
|
+
update: (id, payload) => api.put(\`/todos/\${id}\`, payload, multipartConfig(payload)).then((res) => res.data.data),
|
|
462
|
+
remove: (id) => api.delete(\`/todos/\${id}\`).then((res) => res.data)
|
|
373
463
|
};
|
|
374
464
|
|
|
375
465
|
function multipartConfig(payload) {
|
|
@@ -415,7 +505,7 @@ body { margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, B
|
|
|
415
505
|
[`main.${x}`]: `import React from "react";
|
|
416
506
|
import ReactDOM from "react-dom/client";
|
|
417
507
|
${context.router ? "import { BrowserRouter } from \"react-router-dom\";" : ""}
|
|
418
|
-
${context.state === "redux-toolkit" ? "import { Provider } from \"react-redux\";\nimport { store } from \"./store
|
|
508
|
+
${context.state === "redux-toolkit" ? "import { Provider } from \"react-redux\";\nimport { store } from \"./redux/store\";" : ""}
|
|
419
509
|
${context.state === "context-api" ? "import { AuthProvider } from \"./store/auth\";" : ""}
|
|
420
510
|
import { Toaster } from "sonner";
|
|
421
511
|
import App from "./App";
|
|
@@ -439,14 +529,17 @@ import { Dashboard } from "./pages/Dashboard";
|
|
|
439
529
|
import { Login } from "./pages/Login";
|
|
440
530
|
import { Register } from "./pages/Register";
|
|
441
531
|
import { ProtectedRoute } from "./routes/ProtectedRoute";
|
|
532
|
+
${context.socketio ? 'import { Chat } from "./pages/Chat";' : ""}
|
|
442
533
|
|
|
443
534
|
export default function App() {
|
|
444
535
|
return (
|
|
445
536
|
<Routes>
|
|
446
|
-
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
537
|
+
<Route path="/" element={<Navigate to="${context.socketio ? "/chat" : "/dashboard"}" replace />} />
|
|
447
538
|
<Route path="/login" element={<Login />} />
|
|
448
539
|
<Route path="/register" element={<Register />} />
|
|
449
540
|
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
|
541
|
+
${context.socketio ? `<Route path="/chat" element={<ProtectedRoute><Chat /></ProtectedRoute>} />
|
|
542
|
+
<Route path="/chat/:conversationId" element={<ProtectedRoute><Chat /></ProtectedRoute>} />` : ""}
|
|
450
543
|
</Routes>
|
|
451
544
|
);
|
|
452
545
|
}
|
|
@@ -457,13 +550,10 @@ export default function App() {
|
|
|
457
550
|
}
|
|
458
551
|
`}`,
|
|
459
552
|
[`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
553
|
|
|
464
554
|
export function ProtectedRoute({ children }) {
|
|
465
|
-
const
|
|
466
|
-
return
|
|
555
|
+
const isAuthenticated = Boolean(localStorage.getItem("stackflow_token"));
|
|
556
|
+
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
|
467
557
|
}
|
|
468
558
|
`,
|
|
469
559
|
[`pages/Login.${x}`]: authPage("login", context),
|
|
@@ -475,6 +565,7 @@ export function ProtectedRoute({ children }) {
|
|
|
475
565
|
function nextTemplates(context, x) {
|
|
476
566
|
return {
|
|
477
567
|
...commonTemplates(context, x),
|
|
568
|
+
...chatNextPages(context, x),
|
|
478
569
|
"app/globals.css": `@import "tailwindcss";
|
|
479
570
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
480
571
|
|
|
@@ -499,7 +590,7 @@ export default function RootLayout({ children }) {
|
|
|
499
590
|
[`app/page.${x}`]: `import { redirect } from "next/navigation";
|
|
500
591
|
|
|
501
592
|
export default function Home() {
|
|
502
|
-
redirect("/dashboard");
|
|
593
|
+
redirect("${context.socketio ? "/chat" : "/dashboard"}");
|
|
503
594
|
}
|
|
504
595
|
`,
|
|
505
596
|
[`app/login/page.${x}`]: `"use client";
|
|
@@ -521,7 +612,9 @@ function authPage(mode, context, next = false) {
|
|
|
521
612
|
const isRegister = mode === "register";
|
|
522
613
|
const navImport = next ? "import { useRouter } from \"next/navigation\";" : "import { Link, useNavigate } from \"react-router-dom\";";
|
|
523
614
|
const navHook = next ? "const router = useRouter();" : "const navigate = useNavigate();";
|
|
524
|
-
const redirect = next
|
|
615
|
+
const redirect = next
|
|
616
|
+
? `router.push("${context.socketio ? "/chat" : "/dashboard"}");`
|
|
617
|
+
: `navigate("${context.socketio ? "/chat" : "/dashboard"}");`;
|
|
525
618
|
const link = next
|
|
526
619
|
? `<button className="text-sm text-cyan-700" onClick={() => router.push("${isRegister ? "/login" : "/register"}")}>${isRegister ? "Sign in" : "Create account"}</button>`
|
|
527
620
|
: `<Link className="text-sm text-cyan-700" to="${isRegister ? "/login" : "/register"}">${isRegister ? "Sign in" : "Create account"}</Link>`;
|
|
@@ -530,12 +623,20 @@ ${navImport}
|
|
|
530
623
|
import { toast } from "sonner";
|
|
531
624
|
import { Button } from "${next ? "../../components/ui/button" : "../components/ui/button"}";
|
|
532
625
|
import { Input } from "${next ? "../../components/ui/input" : "../components/ui/input"}";
|
|
533
|
-
import { authService } from "${next ? "../../features/auth/
|
|
626
|
+
import { authService } from "${next ? "../../features/auth/authService" : "../features/auth/authService"}";
|
|
534
627
|
${context.state === "zustand" ? `import { useAuthStore } from "${next ? "../../store/auth" : "../store/auth"}";` : ""}
|
|
628
|
+
${context.state === "context-api" ? `import { useAuthContext } from "${next ? "../../store/auth" : "../store/auth"}";` : ""}
|
|
629
|
+
${context.state === "redux-toolkit" ? `import { useDispatch } from "react-redux";\nimport { setSession as setReduxSession } from "${next ? "../../redux/slices/authSlice" : "../redux/slices/authSlice"}";` : ""}
|
|
535
630
|
|
|
536
631
|
export function ${isRegister ? "Register" : "Login"}() {
|
|
537
632
|
${navHook}
|
|
538
|
-
|
|
633
|
+
${context.state === "redux-toolkit" ? "const dispatch = useDispatch();" : ""}
|
|
634
|
+
const setSession = ${
|
|
635
|
+
context.state === "zustand" ? "useAuthStore((state) => state.setSession)" :
|
|
636
|
+
context.state === "context-api" ? "useAuthContext().setSession" :
|
|
637
|
+
context.state === "redux-toolkit" ? "(data) => dispatch(setReduxSession(data))" :
|
|
638
|
+
"({ user, token }) => localStorage.setItem(\"stackflow_token\", token)"
|
|
639
|
+
};
|
|
539
640
|
const [loading, setLoading] = useState(false);
|
|
540
641
|
const [form, setForm] = useState({ name: "", email: "", password: "" });
|
|
541
642
|
|
|
@@ -583,15 +684,23 @@ import { Edit3, ImagePlus, LogOut, Moon, Plus, Save, Sun, Trash2, X } from "luci
|
|
|
583
684
|
import { toast } from "sonner";
|
|
584
685
|
import { Button } from "${next ? "../../components/ui/button" : "../components/ui/button"}";
|
|
585
686
|
import { Input } from "${next ? "../../components/ui/input" : "../components/ui/input"}";
|
|
586
|
-
import {
|
|
687
|
+
import { todoService } from "${next ? "../../features/todos/todoService" : "../features/todos/todoService"}";
|
|
587
688
|
${context.state === "zustand" ? `import { useAuthStore } from "${next ? "../../store/auth" : "../store/auth"}";` : ""}
|
|
689
|
+
${context.state === "context-api" ? `import { useAuthContext } from "${next ? "../../store/auth" : "../store/auth"}";` : ""}
|
|
690
|
+
${context.state === "redux-toolkit" ? `import { useDispatch } from "react-redux";\nimport { logout as reduxLogout } from "${next ? "../../redux/slices/authSlice" : "../redux/slices/authSlice"}";` : ""}
|
|
588
691
|
|
|
589
692
|
${componentName} {
|
|
590
693
|
const ${next ? "router" : "navigate"} = ${next ? "useRouter()" : "useNavigate()"};
|
|
591
|
-
|
|
694
|
+
${context.state === "redux-toolkit" ? "const dispatch = useDispatch();" : ""}
|
|
695
|
+
const logoutStore = ${
|
|
696
|
+
context.state === "zustand" ? "useAuthStore((state) => state.logout)" :
|
|
697
|
+
context.state === "context-api" ? "useAuthContext().logout" :
|
|
698
|
+
context.state === "redux-toolkit" ? "() => dispatch(reduxLogout())" :
|
|
699
|
+
"() => localStorage.removeItem(\"stackflow_token\")"
|
|
700
|
+
};
|
|
592
701
|
const [dark, setDark] = useState(() => typeof window !== "undefined" && localStorage.getItem("stackflow_theme") === "dark");
|
|
593
702
|
const [loading, setLoading] = useState(true);
|
|
594
|
-
const [
|
|
703
|
+
const [todos, setTodos] = useState([]);
|
|
595
704
|
const [form, setForm] = useState({ title: "", description: "", status: "todo", image: null });
|
|
596
705
|
const [editingId, setEditingId] = useState(null);
|
|
597
706
|
|
|
@@ -601,30 +710,30 @@ ${componentName} {
|
|
|
601
710
|
}, [dark]);
|
|
602
711
|
|
|
603
712
|
useEffect(() => {
|
|
604
|
-
|
|
605
|
-
.then(
|
|
606
|
-
.catch(() => toast.error("Could not load
|
|
713
|
+
todoService.list()
|
|
714
|
+
.then(setTodos)
|
|
715
|
+
.catch(() => toast.error("Could not load todos"))
|
|
607
716
|
.finally(() => setLoading(false));
|
|
608
717
|
}, []);
|
|
609
718
|
|
|
610
|
-
async function
|
|
719
|
+
async function createTodo(event) {
|
|
611
720
|
event.preventDefault();
|
|
612
721
|
if (!form.title.trim()) return;
|
|
613
722
|
const payload = toPayload(form);
|
|
614
723
|
const saved = editingId
|
|
615
|
-
? await
|
|
616
|
-
: await
|
|
617
|
-
|
|
724
|
+
? await todoService.update(editingId, payload)
|
|
725
|
+
: await todoService.create(payload);
|
|
726
|
+
setTodos(editingId ? todos.map((todo) => todo._id === editingId ? saved : todo) : [saved, ...todos]);
|
|
618
727
|
resetForm();
|
|
619
|
-
toast.success(editingId ? "
|
|
728
|
+
toast.success(editingId ? "Todo updated" : "Todo created");
|
|
620
729
|
}
|
|
621
730
|
|
|
622
|
-
function startEdit(
|
|
623
|
-
setEditingId(
|
|
731
|
+
function startEdit(todo) {
|
|
732
|
+
setEditingId(todo._id);
|
|
624
733
|
setForm({
|
|
625
|
-
title:
|
|
626
|
-
description:
|
|
627
|
-
status:
|
|
734
|
+
title: todo.title || "",
|
|
735
|
+
description: todo.description || "",
|
|
736
|
+
status: todo.status || "todo",
|
|
628
737
|
image: null
|
|
629
738
|
});
|
|
630
739
|
}
|
|
@@ -634,10 +743,10 @@ ${componentName} {
|
|
|
634
743
|
setForm({ title: "", description: "", status: "todo", image: null });
|
|
635
744
|
}
|
|
636
745
|
|
|
637
|
-
async function
|
|
638
|
-
await
|
|
639
|
-
|
|
640
|
-
toast.success("
|
|
746
|
+
async function removeTodo(id) {
|
|
747
|
+
await todoService.remove(id);
|
|
748
|
+
setTodos(todos.filter((todo) => todo._id !== id));
|
|
749
|
+
toast.success("Todo deleted");
|
|
641
750
|
}
|
|
642
751
|
|
|
643
752
|
function logout() {
|
|
@@ -675,37 +784,37 @@ ${componentName} {
|
|
|
675
784
|
<section className="mx-auto grid max-w-6xl gap-6 px-4 py-8 md:grid-cols-[1fr_320px]">
|
|
676
785
|
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
|
677
786
|
<div className="mb-4 flex items-center justify-between">
|
|
678
|
-
<h2 className="text-lg font-semibold">
|
|
679
|
-
<span className="text-sm text-slate-500">{
|
|
787
|
+
<h2 className="text-lg font-semibold">Todos</h2>
|
|
788
|
+
<span className="text-sm text-slate-500">{todos.length} total</span>
|
|
680
789
|
</div>
|
|
681
|
-
{loading ? <p className="text-sm text-slate-500">Loading
|
|
790
|
+
{loading ? <p className="text-sm text-slate-500">Loading todos...</p> : (
|
|
682
791
|
<div className="space-y-3">
|
|
683
|
-
{
|
|
684
|
-
<div key={
|
|
792
|
+
{todos.map((todo) => (
|
|
793
|
+
<div key={todo._id} className="grid gap-3 rounded-md border border-slate-200 p-3 dark:border-slate-800 sm:grid-cols-[96px_1fr_auto]">
|
|
685
794
|
<div className="h-24 overflow-hidden rounded-md bg-slate-100 dark:bg-slate-800">
|
|
686
|
-
{
|
|
795
|
+
{todo.imageUrl ? <img className="h-full w-full object-cover" src={\`http://localhost:5000\${todo.imageUrl}\`} alt={todo.title} /> : <div className="grid h-full place-items-center text-slate-400"><ImagePlus size={20} /></div>}
|
|
687
796
|
</div>
|
|
688
797
|
<div>
|
|
689
|
-
<p className="font-medium">{
|
|
690
|
-
<p className="mt-1 text-sm text-slate-500">{
|
|
691
|
-
<p className="mt-2 text-xs uppercase tracking-wide text-slate-500">{
|
|
798
|
+
<p className="font-medium">{todo.title}</p>
|
|
799
|
+
<p className="mt-1 text-sm text-slate-500">{todo.description || "No description"}</p>
|
|
800
|
+
<p className="mt-2 text-xs uppercase tracking-wide text-slate-500">{todo.status}</p>
|
|
692
801
|
</div>
|
|
693
802
|
<div className="flex items-start gap-2">
|
|
694
|
-
<Button variant="ghost" onClick={() => startEdit(
|
|
695
|
-
<Button variant="ghost" onClick={() =>
|
|
803
|
+
<Button variant="ghost" onClick={() => startEdit(todo)} aria-label="Edit todo"><Edit3 size={16} /></Button>
|
|
804
|
+
<Button variant="ghost" onClick={() => removeTodo(todo._id)} aria-label="Delete todo"><Trash2 size={16} /></Button>
|
|
696
805
|
</div>
|
|
697
806
|
</div>
|
|
698
807
|
))}
|
|
699
|
-
{!
|
|
808
|
+
{!todos.length && <p className="text-sm text-slate-500">No todos yet.</p>}
|
|
700
809
|
</div>
|
|
701
810
|
)}
|
|
702
811
|
</div>
|
|
703
|
-
<form onSubmit={
|
|
812
|
+
<form onSubmit={createTodo} 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
813
|
<div className="flex items-center justify-between">
|
|
705
|
-
<h2 className="text-lg font-semibold">{editingId ? "Edit
|
|
814
|
+
<h2 className="text-lg font-semibold">{editingId ? "Edit todo" : "Create todo"}</h2>
|
|
706
815
|
{editingId && <Button type="button" variant="ghost" onClick={resetForm} aria-label="Cancel edit"><X size={18} /></Button>}
|
|
707
816
|
</div>
|
|
708
|
-
<Input value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} placeholder="
|
|
817
|
+
<Input value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} placeholder="Todo title" />
|
|
709
818
|
<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="Description" />
|
|
710
819
|
<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
820
|
<option value="todo">Todo</option>
|
|
@@ -713,7 +822,7 @@ ${componentName} {
|
|
|
713
822
|
<option value="done">Done</option>
|
|
714
823
|
</select>
|
|
715
824
|
${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
|
|
825
|
+
<Button className="w-full" aria-label={editingId ? "Save todo" : "Add todo"}>{editingId ? <Save size={18} /> : <Plus size={18} />}<span className="ml-2">{editingId ? "Save changes" : "Add todo"}</span></Button>
|
|
717
826
|
</form>
|
|
718
827
|
</section>
|
|
719
828
|
</main>
|