create-n8-app 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +39 -28
- package/package.json +3 -3
- package/template/_package.json +38 -35
- package/template/app/auth/signin/page.tsx +7 -2
- package/template/app/globals.css +112 -14
- package/template/app/layout.tsx +2 -0
- package/template/app/page.tsx +6 -2
- package/template/app/providers.tsx +11 -2
- package/template/components/auth/user-button.tsx +10 -4
- package/template/components/examples/contact-form.tsx +171 -0
- package/template/hooks/use-posts.ts +111 -0
- package/template/stores/example-store.ts +37 -2
- package/template/components.json +0 -21
- package/template/hooks/.gitkeep +0 -1
package/dist/index.js
CHANGED
|
@@ -44,6 +44,21 @@ async function copyTemplate(targetDir, projectName) {
|
|
|
44
44
|
// src/utils/install-deps.ts
|
|
45
45
|
import { execa } from "execa";
|
|
46
46
|
|
|
47
|
+
// src/helpers/get-package-manager.ts
|
|
48
|
+
function getPackageManager() {
|
|
49
|
+
const userAgent = process.env.npm_config_user_agent || "";
|
|
50
|
+
if (userAgent.startsWith("pnpm")) {
|
|
51
|
+
return "pnpm";
|
|
52
|
+
}
|
|
53
|
+
if (userAgent.startsWith("yarn")) {
|
|
54
|
+
return "yarn";
|
|
55
|
+
}
|
|
56
|
+
if (userAgent.startsWith("bun")) {
|
|
57
|
+
return "bun";
|
|
58
|
+
}
|
|
59
|
+
return "npm";
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
// src/utils/logger.ts
|
|
48
63
|
import chalk from "chalk";
|
|
49
64
|
var logger = {
|
|
@@ -74,22 +89,27 @@ var logger = {
|
|
|
74
89
|
|
|
75
90
|
// src/utils/install-deps.ts
|
|
76
91
|
async function installDependencies(targetDir) {
|
|
77
|
-
|
|
92
|
+
const pm = getPackageManager();
|
|
93
|
+
logger.info(`Installing dependencies with ${pm}...`);
|
|
78
94
|
try {
|
|
79
|
-
await execa(
|
|
95
|
+
await execa(pm, ["install"], {
|
|
80
96
|
cwd: targetDir,
|
|
81
97
|
stdio: "inherit"
|
|
82
98
|
});
|
|
83
99
|
logger.success("Dependencies installed successfully");
|
|
84
100
|
} catch (error) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
if (pm !== "npm") {
|
|
102
|
+
logger.warn(`${pm} failed, trying npm...`);
|
|
103
|
+
try {
|
|
104
|
+
await execa("npm", ["install"], {
|
|
105
|
+
cwd: targetDir,
|
|
106
|
+
stdio: "inherit"
|
|
107
|
+
});
|
|
108
|
+
logger.success("Dependencies installed successfully with npm");
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error("Failed to install dependencies");
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
93
113
|
throw new Error("Failed to install dependencies");
|
|
94
114
|
}
|
|
95
115
|
}
|
|
@@ -101,9 +121,15 @@ async function runShadcnInit(targetDir) {
|
|
|
101
121
|
cwd: targetDir,
|
|
102
122
|
stdio: "inherit"
|
|
103
123
|
});
|
|
104
|
-
logger.
|
|
124
|
+
logger.info("Adding base Shadcn/ui components...");
|
|
125
|
+
const baseComponents = ["button", "card", "input", "label", "form", "toast", "textarea"];
|
|
126
|
+
await execa("npx", ["shadcn@latest", "add", ...baseComponents, "-y"], {
|
|
127
|
+
cwd: targetDir,
|
|
128
|
+
stdio: "inherit"
|
|
129
|
+
});
|
|
130
|
+
logger.success("Shadcn/ui initialized with base components");
|
|
105
131
|
} catch (error) {
|
|
106
|
-
logger.warn('Shadcn/ui
|
|
132
|
+
logger.warn('Shadcn/ui setup skipped - run "npx shadcn@latest init" later');
|
|
107
133
|
}
|
|
108
134
|
}
|
|
109
135
|
|
|
@@ -130,21 +156,6 @@ async function initGit(targetDir) {
|
|
|
130
156
|
}
|
|
131
157
|
}
|
|
132
158
|
|
|
133
|
-
// src/helpers/get-package-manager.ts
|
|
134
|
-
function getPackageManager() {
|
|
135
|
-
const userAgent = process.env.npm_config_user_agent || "";
|
|
136
|
-
if (userAgent.startsWith("pnpm")) {
|
|
137
|
-
return "pnpm";
|
|
138
|
-
}
|
|
139
|
-
if (userAgent.startsWith("yarn")) {
|
|
140
|
-
return "yarn";
|
|
141
|
-
}
|
|
142
|
-
if (userAgent.startsWith("bun")) {
|
|
143
|
-
return "bun";
|
|
144
|
-
}
|
|
145
|
-
return "npm";
|
|
146
|
-
}
|
|
147
|
-
|
|
148
159
|
// src/create-project.ts
|
|
149
160
|
async function createProject(options) {
|
|
150
161
|
logger.title("\u{1F680} Create N8 App");
|
|
@@ -221,7 +232,7 @@ async function createProject(options) {
|
|
|
221
232
|
|
|
222
233
|
// src/index.ts
|
|
223
234
|
var program = new Command();
|
|
224
|
-
program.name("create-n8-app").description("Create a new Next.js app with the N8 stack").version("0.
|
|
235
|
+
program.name("create-n8-app").description("Create a new Next.js app with the N8 stack").version("0.3.0").argument("[project-name]", "Name of the project").option("--skip-install", "Skip installing dependencies").option("--skip-git", "Skip initializing git repository").action(async (projectName, options) => {
|
|
225
236
|
try {
|
|
226
237
|
await createProject({
|
|
227
238
|
projectName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-n8-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Create a Next.js app with the N8 stack - Next.js 16, Tailwind v4, Shadcn/ui, Drizzle, tRPC, TanStack Query, Zustand, NextAuth, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
"template",
|
|
33
33
|
"scaffold"
|
|
34
34
|
],
|
|
35
|
-
"author": "Nate
|
|
35
|
+
"author": "Nate McGrady",
|
|
36
36
|
"license": "MIT",
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/
|
|
39
|
+
"url": "https://github.com/nmcgrady/n8-stack"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/fs-extra": "^11.0.4",
|
package/template/_package.json
CHANGED
|
@@ -17,43 +17,46 @@
|
|
|
17
17
|
"db:studio": "drizzle-kit studio"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@
|
|
21
|
-
"@
|
|
22
|
-
"@
|
|
23
|
-
"@
|
|
24
|
-
"@
|
|
25
|
-
"@trpc/
|
|
26
|
-
"
|
|
27
|
-
"@
|
|
28
|
-
"
|
|
29
|
-
"next": "^15.1.0",
|
|
30
|
-
"next-auth": "^5.0.0-beta.25",
|
|
31
|
-
"react": "^19.0.0",
|
|
32
|
-
"react-dom": "^19.0.0",
|
|
33
|
-
"zod": "^3.23.8",
|
|
34
|
-
"zustand": "^5.0.2",
|
|
35
|
-
"superjson": "^2.2.2",
|
|
20
|
+
"@ai-sdk/openai": "^1.3.0",
|
|
21
|
+
"@auth/drizzle-adapter": "^1.11.0",
|
|
22
|
+
"@hookform/resolvers": "^3.9.0",
|
|
23
|
+
"@neondatabase/serverless": "^1.0.0",
|
|
24
|
+
"@tanstack/react-query": "^5.90.0",
|
|
25
|
+
"@trpc/client": "^11.8.0",
|
|
26
|
+
"@trpc/react-query": "^11.8.0",
|
|
27
|
+
"@trpc/server": "^11.8.0",
|
|
28
|
+
"ai": "^4.3.0",
|
|
36
29
|
"clsx": "^2.1.1",
|
|
37
|
-
"
|
|
38
|
-
"lucide-react": "^0.
|
|
30
|
+
"drizzle-orm": "^0.45.0",
|
|
31
|
+
"lucide-react": "^0.563.0",
|
|
32
|
+
"next": "^16.0.0",
|
|
33
|
+
"next-auth": "^5.0.0-beta.30",
|
|
34
|
+
"react": "^19.2.0",
|
|
35
|
+
"react-dom": "^19.2.0",
|
|
36
|
+
"react-hook-form": "^7.54.0",
|
|
37
|
+
"superjson": "^2.2.6",
|
|
38
|
+
"tailwind-merge": "^3.4.0",
|
|
39
|
+
"zod": "^3.25.0",
|
|
40
|
+
"zustand": "^5.0.10"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
41
|
-
"@
|
|
42
|
-
"@
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
45
|
-
"@
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
43
|
+
"@tanstack/react-query-devtools": "^5.90.0",
|
|
44
|
+
"@tailwindcss/postcss": "^4.1.0",
|
|
45
|
+
"@testing-library/jest-dom": "^6.9.0",
|
|
46
|
+
"@testing-library/react": "^16.3.0",
|
|
47
|
+
"@types/node": "^22.19.0",
|
|
48
|
+
"@types/react": "^19.2.0",
|
|
49
|
+
"@types/react-dom": "^19.2.0",
|
|
50
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
51
|
+
"drizzle-kit": "^0.31.0",
|
|
52
|
+
"eslint": "^9.39.0",
|
|
53
|
+
"eslint-config-next": "^16.0.0",
|
|
54
|
+
"jsdom": "^27.4.0",
|
|
55
|
+
"postcss": "^8.5.0",
|
|
56
|
+
"prettier": "^3.8.0",
|
|
57
|
+
"prettier-plugin-tailwindcss": "^0.7.0",
|
|
58
|
+
"tailwindcss": "^4.1.0",
|
|
59
|
+
"typescript": "^5.9.0",
|
|
60
|
+
"vitest": "^4.0.0"
|
|
58
61
|
}
|
|
59
62
|
}
|
|
@@ -15,7 +15,7 @@ export default function SignInPage() {
|
|
|
15
15
|
>
|
|
16
16
|
<button
|
|
17
17
|
type="submit"
|
|
18
|
-
className="flex items-center gap-3 rounded-lg bg-slate-800 px-6 py-3 text-white transition-colors hover:bg-slate-700"
|
|
18
|
+
className="flex items-center gap-3 rounded-lg bg-slate-800 px-6 py-3 text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
|
|
19
19
|
>
|
|
20
20
|
<GitHubIcon />
|
|
21
21
|
Sign in with GitHub
|
|
@@ -28,7 +28,12 @@ export default function SignInPage() {
|
|
|
28
28
|
|
|
29
29
|
function GitHubIcon() {
|
|
30
30
|
return (
|
|
31
|
-
<svg
|
|
31
|
+
<svg
|
|
32
|
+
className="h-5 w-5"
|
|
33
|
+
fill="currentColor"
|
|
34
|
+
viewBox="0 0 24 24"
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
>
|
|
32
37
|
<path
|
|
33
38
|
fillRule="evenodd"
|
|
34
39
|
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
package/template/app/globals.css
CHANGED
|
@@ -1,25 +1,123 @@
|
|
|
1
1
|
@import 'tailwindcss';
|
|
2
2
|
|
|
3
|
-
@
|
|
4
|
-
|
|
3
|
+
@custom-variant dark (&:is(.dark *));
|
|
4
|
+
|
|
5
|
+
@theme inline {
|
|
6
|
+
/* Fonts */
|
|
5
7
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
|
6
8
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
|
7
9
|
|
|
8
|
-
/*
|
|
9
|
-
--
|
|
10
|
-
--
|
|
11
|
-
--
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
--color-
|
|
15
|
-
--color-
|
|
16
|
-
--color-
|
|
17
|
-
--color-
|
|
18
|
-
--color-
|
|
19
|
-
--color-
|
|
10
|
+
/* Border radius */
|
|
11
|
+
--radius-lg: 0.5rem;
|
|
12
|
+
--radius-md: calc(var(--radius-lg) - 2px);
|
|
13
|
+
--radius-sm: calc(var(--radius-lg) - 4px);
|
|
14
|
+
|
|
15
|
+
/* Shadcn/ui color tokens */
|
|
16
|
+
--color-background: hsl(var(--background));
|
|
17
|
+
--color-foreground: hsl(var(--foreground));
|
|
18
|
+
--color-card: hsl(var(--card));
|
|
19
|
+
--color-card-foreground: hsl(var(--card-foreground));
|
|
20
|
+
--color-popover: hsl(var(--popover));
|
|
21
|
+
--color-popover-foreground: hsl(var(--popover-foreground));
|
|
22
|
+
--color-primary: hsl(var(--primary));
|
|
23
|
+
--color-primary-foreground: hsl(var(--primary-foreground));
|
|
24
|
+
--color-secondary: hsl(var(--secondary));
|
|
25
|
+
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
|
26
|
+
--color-muted: hsl(var(--muted));
|
|
27
|
+
--color-muted-foreground: hsl(var(--muted-foreground));
|
|
28
|
+
--color-accent: hsl(var(--accent));
|
|
29
|
+
--color-accent-foreground: hsl(var(--accent-foreground));
|
|
30
|
+
--color-destructive: hsl(var(--destructive));
|
|
31
|
+
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
|
32
|
+
--color-border: hsl(var(--border));
|
|
33
|
+
--color-input: hsl(var(--input));
|
|
34
|
+
--color-ring: hsl(var(--ring));
|
|
35
|
+
--color-chart-1: hsl(var(--chart-1));
|
|
36
|
+
--color-chart-2: hsl(var(--chart-2));
|
|
37
|
+
--color-chart-3: hsl(var(--chart-3));
|
|
38
|
+
--color-chart-4: hsl(var(--chart-4));
|
|
39
|
+
--color-chart-5: hsl(var(--chart-5));
|
|
40
|
+
--color-sidebar: hsl(var(--sidebar-background));
|
|
41
|
+
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
|
42
|
+
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
|
43
|
+
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
|
44
|
+
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
|
45
|
+
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
|
46
|
+
--color-sidebar-border: hsl(var(--sidebar-border));
|
|
47
|
+
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
|
20
48
|
}
|
|
21
49
|
|
|
22
50
|
@layer base {
|
|
51
|
+
:root {
|
|
52
|
+
--background: 0 0% 100%;
|
|
53
|
+
--foreground: 222.2 84% 4.9%;
|
|
54
|
+
--card: 0 0% 100%;
|
|
55
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
56
|
+
--popover: 0 0% 100%;
|
|
57
|
+
--popover-foreground: 222.2 84% 4.9%;
|
|
58
|
+
--primary: 222.2 47.4% 11.2%;
|
|
59
|
+
--primary-foreground: 210 40% 98%;
|
|
60
|
+
--secondary: 210 40% 96.1%;
|
|
61
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
62
|
+
--muted: 210 40% 96.1%;
|
|
63
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
64
|
+
--accent: 210 40% 96.1%;
|
|
65
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
66
|
+
--destructive: 0 84.2% 60.2%;
|
|
67
|
+
--destructive-foreground: 210 40% 98%;
|
|
68
|
+
--border: 214.3 31.8% 91.4%;
|
|
69
|
+
--input: 214.3 31.8% 91.4%;
|
|
70
|
+
--ring: 222.2 84% 4.9%;
|
|
71
|
+
--chart-1: 12 76% 61%;
|
|
72
|
+
--chart-2: 173 58% 39%;
|
|
73
|
+
--chart-3: 197 37% 24%;
|
|
74
|
+
--chart-4: 43 74% 66%;
|
|
75
|
+
--chart-5: 27 87% 67%;
|
|
76
|
+
--sidebar-background: 0 0% 98%;
|
|
77
|
+
--sidebar-foreground: 240 5.3% 26.1%;
|
|
78
|
+
--sidebar-primary: 240 5.9% 10%;
|
|
79
|
+
--sidebar-primary-foreground: 0 0% 98%;
|
|
80
|
+
--sidebar-accent: 240 4.8% 95.9%;
|
|
81
|
+
--sidebar-accent-foreground: 240 5.9% 10%;
|
|
82
|
+
--sidebar-border: 220 13% 91%;
|
|
83
|
+
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.dark {
|
|
87
|
+
--background: 222.2 84% 4.9%;
|
|
88
|
+
--foreground: 210 40% 98%;
|
|
89
|
+
--card: 222.2 84% 4.9%;
|
|
90
|
+
--card-foreground: 210 40% 98%;
|
|
91
|
+
--popover: 222.2 84% 4.9%;
|
|
92
|
+
--popover-foreground: 210 40% 98%;
|
|
93
|
+
--primary: 210 40% 98%;
|
|
94
|
+
--primary-foreground: 222.2 47.4% 11.2%;
|
|
95
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
96
|
+
--secondary-foreground: 210 40% 98%;
|
|
97
|
+
--muted: 217.2 32.6% 17.5%;
|
|
98
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
99
|
+
--accent: 217.2 32.6% 17.5%;
|
|
100
|
+
--accent-foreground: 210 40% 98%;
|
|
101
|
+
--destructive: 0 62.8% 30.6%;
|
|
102
|
+
--destructive-foreground: 210 40% 98%;
|
|
103
|
+
--border: 217.2 32.6% 17.5%;
|
|
104
|
+
--input: 217.2 32.6% 17.5%;
|
|
105
|
+
--ring: 212.7 26.8% 83.9%;
|
|
106
|
+
--chart-1: 220 70% 50%;
|
|
107
|
+
--chart-2: 160 60% 45%;
|
|
108
|
+
--chart-3: 30 80% 55%;
|
|
109
|
+
--chart-4: 280 65% 60%;
|
|
110
|
+
--chart-5: 340 75% 55%;
|
|
111
|
+
--sidebar-background: 240 5.9% 10%;
|
|
112
|
+
--sidebar-foreground: 240 4.8% 95.9%;
|
|
113
|
+
--sidebar-primary: 224.3 76.3% 48%;
|
|
114
|
+
--sidebar-primary-foreground: 0 0% 100%;
|
|
115
|
+
--sidebar-accent: 240 3.7% 15.9%;
|
|
116
|
+
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
|
117
|
+
--sidebar-border: 240 3.7% 15.9%;
|
|
118
|
+
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
119
|
+
}
|
|
120
|
+
|
|
23
121
|
* {
|
|
24
122
|
@apply border-border;
|
|
25
123
|
}
|
package/template/app/layout.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
|
|
|
2
2
|
import { Inter } from 'next/font/google'
|
|
3
3
|
import './globals.css'
|
|
4
4
|
import { Providers } from './providers'
|
|
5
|
+
import { Toaster } from '@/components/ui/toaster'
|
|
5
6
|
|
|
6
7
|
const inter = Inter({
|
|
7
8
|
subsets: ['latin'],
|
|
@@ -22,6 +23,7 @@ export default function RootLayout({
|
|
|
22
23
|
<html lang="en" suppressHydrationWarning>
|
|
23
24
|
<body className={`${inter.variable} font-sans antialiased`}>
|
|
24
25
|
<Providers>{children}</Providers>
|
|
26
|
+
<Toaster />
|
|
25
27
|
</body>
|
|
26
28
|
</html>
|
|
27
29
|
)
|
package/template/app/page.tsx
CHANGED
|
@@ -56,12 +56,16 @@ function FeatureCard({
|
|
|
56
56
|
}) {
|
|
57
57
|
return (
|
|
58
58
|
<Link
|
|
59
|
-
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20
|
|
59
|
+
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 transition-colors hover:bg-white/20"
|
|
60
60
|
href={href}
|
|
61
61
|
target="_blank"
|
|
62
62
|
rel="noopener noreferrer"
|
|
63
63
|
>
|
|
64
|
-
<h3 className="text-2xl font-bold">
|
|
64
|
+
<h3 className="text-2xl font-bold">
|
|
65
|
+
{title} →
|
|
66
|
+
{/* Screen reader text for external link */}
|
|
67
|
+
<span className="sr-only"> (opens in a new tab)</span>
|
|
68
|
+
</h3>
|
|
65
69
|
<p className="text-slate-300">{description}</p>
|
|
66
70
|
</Link>
|
|
67
71
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
4
|
+
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|
4
5
|
import { httpBatchLink } from '@trpc/client'
|
|
5
6
|
import { SessionProvider } from 'next-auth/react'
|
|
6
7
|
import { useState } from 'react'
|
|
@@ -14,12 +15,17 @@ function getBaseUrl() {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
18
|
+
// Lazy init QueryClient to avoid re-creating on every render
|
|
19
|
+
// See: https://tanstack.com/query/latest/docs/framework/react/guides/ssr
|
|
17
20
|
const [queryClient] = useState(
|
|
18
21
|
() =>
|
|
19
22
|
new QueryClient({
|
|
20
23
|
defaultOptions: {
|
|
21
24
|
queries: {
|
|
22
|
-
|
|
25
|
+
// 5 minutes - prevents excessive refetches
|
|
26
|
+
staleTime: 1000 * 60 * 5,
|
|
27
|
+
// 1 hour - garbage collection time for inactive queries
|
|
28
|
+
gcTime: 1000 * 60 * 60,
|
|
23
29
|
refetchOnWindowFocus: false,
|
|
24
30
|
},
|
|
25
31
|
},
|
|
@@ -40,7 +46,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|
|
40
46
|
return (
|
|
41
47
|
<SessionProvider>
|
|
42
48
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
|
43
|
-
<QueryClientProvider client={queryClient}>
|
|
49
|
+
<QueryClientProvider client={queryClient}>
|
|
50
|
+
{children}
|
|
51
|
+
<ReactQueryDevtools initialIsOpen={false} />
|
|
52
|
+
</QueryClientProvider>
|
|
44
53
|
</trpc.Provider>
|
|
45
54
|
</SessionProvider>
|
|
46
55
|
)
|
|
@@ -8,7 +8,13 @@ export function UserButton() {
|
|
|
8
8
|
|
|
9
9
|
if (status === 'loading') {
|
|
10
10
|
return (
|
|
11
|
-
<div
|
|
11
|
+
<div
|
|
12
|
+
className="h-8 w-8 animate-pulse rounded-full bg-slate-600"
|
|
13
|
+
role="status"
|
|
14
|
+
aria-label="Loading user information"
|
|
15
|
+
>
|
|
16
|
+
<span className="sr-only">Loading…</span>
|
|
17
|
+
</div>
|
|
12
18
|
)
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -16,7 +22,7 @@ export function UserButton() {
|
|
|
16
22
|
return (
|
|
17
23
|
<button
|
|
18
24
|
onClick={() => signIn('github')}
|
|
19
|
-
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20"
|
|
25
|
+
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
|
|
20
26
|
>
|
|
21
27
|
Sign In
|
|
22
28
|
</button>
|
|
@@ -28,7 +34,7 @@ export function UserButton() {
|
|
|
28
34
|
{session.user.image && (
|
|
29
35
|
<Image
|
|
30
36
|
src={session.user.image}
|
|
31
|
-
alt={session.user.name ?? 'User'}
|
|
37
|
+
alt={session.user.name ?? 'User avatar'}
|
|
32
38
|
width={32}
|
|
33
39
|
height={32}
|
|
34
40
|
className="rounded-full"
|
|
@@ -37,7 +43,7 @@ export function UserButton() {
|
|
|
37
43
|
<span className="text-sm text-white">{session.user.name}</span>
|
|
38
44
|
<button
|
|
39
45
|
onClick={() => signOut()}
|
|
40
|
-
className="rounded-lg bg-white/10 px-3 py-1 text-sm text-white transition-colors hover:bg-white/20"
|
|
46
|
+
className="rounded-lg bg-white/10 px-3 py-1 text-sm text-white transition-colors hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
|
|
41
47
|
>
|
|
42
48
|
Sign Out
|
|
43
49
|
</button>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example Contact Form
|
|
5
|
+
*
|
|
6
|
+
* Demonstrates best practices for React Hook Form + Zod + shadcn/ui:
|
|
7
|
+
* - Type inference with z.infer<typeof schema>
|
|
8
|
+
* - Always set defaultValues (prevents uncontrolled warnings)
|
|
9
|
+
* - zodResolver for validation
|
|
10
|
+
* - shadcn/ui FormField pattern
|
|
11
|
+
* - Error handling with FormMessage
|
|
12
|
+
* - Toast feedback on submit
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: Always validate on BOTH client and server (security!)
|
|
15
|
+
* Client validation can be bypassed - server validation is required.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
19
|
+
import { useForm } from 'react-hook-form'
|
|
20
|
+
import { z } from 'zod'
|
|
21
|
+
|
|
22
|
+
import { Button } from '@/components/ui/button'
|
|
23
|
+
import {
|
|
24
|
+
Form,
|
|
25
|
+
FormControl,
|
|
26
|
+
FormDescription,
|
|
27
|
+
FormField,
|
|
28
|
+
FormItem,
|
|
29
|
+
FormLabel,
|
|
30
|
+
FormMessage,
|
|
31
|
+
} from '@/components/ui/form'
|
|
32
|
+
import { Input } from '@/components/ui/input'
|
|
33
|
+
import { Textarea } from '@/components/ui/textarea'
|
|
34
|
+
import { useToast } from '@/hooks/use-toast'
|
|
35
|
+
|
|
36
|
+
// Define schema with Zod - single source of truth for types
|
|
37
|
+
const contactFormSchema = z.object({
|
|
38
|
+
name: z
|
|
39
|
+
.string()
|
|
40
|
+
.min(2, { message: 'Name must be at least 2 characters' })
|
|
41
|
+
.max(50, { message: 'Name must be less than 50 characters' }),
|
|
42
|
+
email: z.string().email({ message: 'Please enter a valid email address' }),
|
|
43
|
+
message: z
|
|
44
|
+
.string()
|
|
45
|
+
.min(10, { message: 'Message must be at least 10 characters' })
|
|
46
|
+
.max(500, { message: 'Message must be less than 500 characters' }),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Infer TypeScript type from schema
|
|
50
|
+
type ContactFormData = z.infer<typeof contactFormSchema>
|
|
51
|
+
|
|
52
|
+
export function ContactForm() {
|
|
53
|
+
const { toast } = useToast()
|
|
54
|
+
|
|
55
|
+
// Initialize form with zodResolver and defaultValues
|
|
56
|
+
// CRITICAL: Always set defaultValues to prevent uncontrolled->controlled warnings
|
|
57
|
+
const form = useForm<ContactFormData>({
|
|
58
|
+
resolver: zodResolver(contactFormSchema),
|
|
59
|
+
defaultValues: {
|
|
60
|
+
name: '',
|
|
61
|
+
email: '',
|
|
62
|
+
message: '',
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
async function onSubmit(data: ContactFormData) {
|
|
67
|
+
try {
|
|
68
|
+
// Example: Send to API (always validate server-side too!)
|
|
69
|
+
// const response = await fetch('/api/contact', {
|
|
70
|
+
// method: 'POST',
|
|
71
|
+
// headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
// body: JSON.stringify(data),
|
|
73
|
+
// })
|
|
74
|
+
//
|
|
75
|
+
// if (!response.ok) {
|
|
76
|
+
// const { errors } = await response.json()
|
|
77
|
+
// // Map server errors to form fields
|
|
78
|
+
// Object.entries(errors).forEach(([field, message]) => {
|
|
79
|
+
// form.setError(field as keyof ContactFormData, { message: message as string })
|
|
80
|
+
// })
|
|
81
|
+
// return
|
|
82
|
+
// }
|
|
83
|
+
|
|
84
|
+
console.log('Form submitted:', data)
|
|
85
|
+
|
|
86
|
+
toast({
|
|
87
|
+
title: 'Message sent!',
|
|
88
|
+
description: 'We\'ll get back to you as soon as possible.',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
form.reset()
|
|
92
|
+
} catch (error) {
|
|
93
|
+
toast({
|
|
94
|
+
variant: 'destructive',
|
|
95
|
+
title: 'Something went wrong',
|
|
96
|
+
description: 'Please try again later.',
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Form {...form}>
|
|
103
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
104
|
+
<FormField
|
|
105
|
+
control={form.control}
|
|
106
|
+
name="name"
|
|
107
|
+
render={({ field }) => (
|
|
108
|
+
<FormItem>
|
|
109
|
+
<FormLabel>Name</FormLabel>
|
|
110
|
+
<FormControl>
|
|
111
|
+
{/* Always spread {...field} in FormControl */}
|
|
112
|
+
<Input placeholder="Your name…" {...field} />
|
|
113
|
+
</FormControl>
|
|
114
|
+
<FormDescription>How should we address you?</FormDescription>
|
|
115
|
+
<FormMessage />
|
|
116
|
+
</FormItem>
|
|
117
|
+
)}
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<FormField
|
|
121
|
+
control={form.control}
|
|
122
|
+
name="email"
|
|
123
|
+
render={({ field }) => (
|
|
124
|
+
<FormItem>
|
|
125
|
+
<FormLabel>Email</FormLabel>
|
|
126
|
+
<FormControl>
|
|
127
|
+
<Input
|
|
128
|
+
type="email"
|
|
129
|
+
placeholder="you@example.com"
|
|
130
|
+
autoComplete="email"
|
|
131
|
+
spellCheck={false}
|
|
132
|
+
{...field}
|
|
133
|
+
/>
|
|
134
|
+
</FormControl>
|
|
135
|
+
<FormMessage />
|
|
136
|
+
</FormItem>
|
|
137
|
+
)}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
<FormField
|
|
141
|
+
control={form.control}
|
|
142
|
+
name="message"
|
|
143
|
+
render={({ field }) => (
|
|
144
|
+
<FormItem>
|
|
145
|
+
<FormLabel>Message</FormLabel>
|
|
146
|
+
<FormControl>
|
|
147
|
+
<Textarea
|
|
148
|
+
placeholder="Tell us what's on your mind…"
|
|
149
|
+
className="min-h-[120px] resize-none"
|
|
150
|
+
{...field}
|
|
151
|
+
/>
|
|
152
|
+
</FormControl>
|
|
153
|
+
<FormDescription>
|
|
154
|
+
{field.value.length}/500 characters
|
|
155
|
+
</FormDescription>
|
|
156
|
+
<FormMessage />
|
|
157
|
+
</FormItem>
|
|
158
|
+
)}
|
|
159
|
+
/>
|
|
160
|
+
|
|
161
|
+
<Button
|
|
162
|
+
type="submit"
|
|
163
|
+
className="w-full"
|
|
164
|
+
disabled={form.formState.isSubmitting}
|
|
165
|
+
>
|
|
166
|
+
{form.formState.isSubmitting ? 'Sending…' : 'Send Message'}
|
|
167
|
+
</Button>
|
|
168
|
+
</form>
|
|
169
|
+
</Form>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example TanStack Query v5 Hooks
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates best practices:
|
|
5
|
+
* - queryOptions factory for reusable query definitions
|
|
6
|
+
* - Object syntax (required in v5)
|
|
7
|
+
* - useMutation with onSuccess invalidation
|
|
8
|
+
* - isPending for loading states (not deprecated isLoading)
|
|
9
|
+
* - Proper typing with TypeScript
|
|
10
|
+
*
|
|
11
|
+
* These hooks wrap tRPC queries but the patterns apply to any data fetching.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
15
|
+
import { trpc } from '@/lib/trpc'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hook to fetch all posts
|
|
19
|
+
*
|
|
20
|
+
* Uses tRPC's built-in React Query integration.
|
|
21
|
+
* The query key is automatically managed by tRPC.
|
|
22
|
+
*/
|
|
23
|
+
export function usePosts() {
|
|
24
|
+
return trpc.example.getPosts.useQuery(undefined, {
|
|
25
|
+
// v5: staleTime and gcTime are set globally in providers.tsx
|
|
26
|
+
// Override here if needed for specific queries
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hook to fetch a single post by ID
|
|
32
|
+
*
|
|
33
|
+
* Note: For conditional queries, use the `enabled` option.
|
|
34
|
+
* Don't use enabled with useSuspenseQuery - use conditional rendering instead.
|
|
35
|
+
*/
|
|
36
|
+
export function usePost(id: number | undefined) {
|
|
37
|
+
return trpc.example.getPost.useQuery(
|
|
38
|
+
{ id: id! },
|
|
39
|
+
{
|
|
40
|
+
// Only fetch when id is defined
|
|
41
|
+
enabled: !!id,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hook to create a new post
|
|
48
|
+
*
|
|
49
|
+
* Demonstrates:
|
|
50
|
+
* - useMutation pattern
|
|
51
|
+
* - Invalidating queries on success
|
|
52
|
+
* - Proper error handling
|
|
53
|
+
*/
|
|
54
|
+
export function useCreatePost() {
|
|
55
|
+
const queryClient = useQueryClient()
|
|
56
|
+
const utils = trpc.useUtils()
|
|
57
|
+
|
|
58
|
+
return trpc.example.createPost.useMutation({
|
|
59
|
+
onSuccess: () => {
|
|
60
|
+
// Invalidate posts query to refetch the list
|
|
61
|
+
// This triggers a refetch of all queries matching this key
|
|
62
|
+
utils.example.getPosts.invalidate()
|
|
63
|
+
},
|
|
64
|
+
// onError is still available on mutations (removed from queries in v5)
|
|
65
|
+
onError: (error) => {
|
|
66
|
+
console.error('Failed to create post:', error.message)
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Hook to delete a post
|
|
73
|
+
*/
|
|
74
|
+
export function useDeletePost() {
|
|
75
|
+
const utils = trpc.useUtils()
|
|
76
|
+
|
|
77
|
+
return trpc.example.deletePost.useMutation({
|
|
78
|
+
onSuccess: () => {
|
|
79
|
+
utils.example.getPosts.invalidate()
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Example: Using the hooks in a component
|
|
86
|
+
*
|
|
87
|
+
* ```tsx
|
|
88
|
+
* function PostsList() {
|
|
89
|
+
* const { data: posts, isPending, isError, error } = usePosts()
|
|
90
|
+
* const createPost = useCreatePost()
|
|
91
|
+
*
|
|
92
|
+
* // v5: Use isPending for initial loading state
|
|
93
|
+
* if (isPending) return <div>Loading...</div>
|
|
94
|
+
* if (isError) return <div>Error: {error.message}</div>
|
|
95
|
+
*
|
|
96
|
+
* return (
|
|
97
|
+
* <div>
|
|
98
|
+
* <button
|
|
99
|
+
* onClick={() => createPost.mutate({ title: 'New Post' })}
|
|
100
|
+
* disabled={createPost.isPending}
|
|
101
|
+
* >
|
|
102
|
+
* {createPost.isPending ? 'Creating...' : 'Create Post'}
|
|
103
|
+
* </button>
|
|
104
|
+
* <ul>
|
|
105
|
+
* {posts.map(post => <li key={post.id}>{post.title}</li>)}
|
|
106
|
+
* </ul>
|
|
107
|
+
* </div>
|
|
108
|
+
* )
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { create } from 'zustand'
|
|
2
|
-
import { persist } from 'zustand/middleware'
|
|
2
|
+
import { persist, type PersistStorage } from 'zustand/middleware'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Example Zustand store for managing global UI state
|
|
@@ -14,6 +14,10 @@ import { persist } from 'zustand/middleware'
|
|
|
14
14
|
* - Server data (use React Query instead)
|
|
15
15
|
* - Form state (use React Hook Form or local state)
|
|
16
16
|
* - Local component state (use useState)
|
|
17
|
+
*
|
|
18
|
+
* IMPORTANT: Always use version + migrate for localStorage persistence
|
|
19
|
+
* to handle schema changes gracefully. Otherwise, users with old data
|
|
20
|
+
* in localStorage may experience errors or unexpected behavior.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
interface UIState {
|
|
@@ -51,8 +55,24 @@ export const useUIStore = create<UIState>()(
|
|
|
51
55
|
}),
|
|
52
56
|
{
|
|
53
57
|
name: 'ui-storage', // localStorage key
|
|
58
|
+
// Version your schema to handle migrations
|
|
59
|
+
version: 1,
|
|
60
|
+
// Migrate from previous versions
|
|
61
|
+
migrate: (persistedState: unknown, version: number) => {
|
|
62
|
+
const state = persistedState as Partial<UIState>
|
|
63
|
+
if (version === 0) {
|
|
64
|
+
// Example migration: v0 -> v1
|
|
65
|
+
// Add default values for new fields
|
|
66
|
+
return {
|
|
67
|
+
...state,
|
|
68
|
+
sidebarOpen: state.sidebarOpen ?? true,
|
|
69
|
+
theme: state.theme ?? 'system',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return state as UIState
|
|
73
|
+
},
|
|
54
74
|
partialize: (state) => ({
|
|
55
|
-
// Only persist these fields
|
|
75
|
+
// Only persist these fields (exclude functions and transient state)
|
|
56
76
|
sidebarOpen: state.sidebarOpen,
|
|
57
77
|
theme: state.theme,
|
|
58
78
|
}),
|
|
@@ -116,6 +136,21 @@ export const useCartStore = create<CartState>()(
|
|
|
116
136
|
}),
|
|
117
137
|
{
|
|
118
138
|
name: 'cart-storage',
|
|
139
|
+
// Version your schema to handle migrations
|
|
140
|
+
version: 1,
|
|
141
|
+
// Migrate from previous versions
|
|
142
|
+
migrate: (persistedState: unknown, version: number) => {
|
|
143
|
+
const state = persistedState as Partial<CartState>
|
|
144
|
+
if (version === 0) {
|
|
145
|
+
// Example migration: v0 -> v1
|
|
146
|
+
// Ensure items array exists
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
items: state.items ?? [],
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return state as CartState
|
|
153
|
+
},
|
|
119
154
|
}
|
|
120
155
|
)
|
|
121
156
|
)
|
package/template/components.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
-
"style": "new-york",
|
|
4
|
-
"rsc": true,
|
|
5
|
-
"tsx": true,
|
|
6
|
-
"tailwind": {
|
|
7
|
-
"config": "",
|
|
8
|
-
"css": "app/globals.css",
|
|
9
|
-
"baseColor": "slate",
|
|
10
|
-
"cssVariables": true,
|
|
11
|
-
"prefix": ""
|
|
12
|
-
},
|
|
13
|
-
"aliases": {
|
|
14
|
-
"components": "@/components",
|
|
15
|
-
"utils": "@/lib/utils",
|
|
16
|
-
"ui": "@/components/ui",
|
|
17
|
-
"lib": "@/lib",
|
|
18
|
-
"hooks": "@/hooks"
|
|
19
|
-
},
|
|
20
|
-
"iconLibrary": "lucide"
|
|
21
|
-
}
|
package/template/hooks/.gitkeep
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# Custom React hooks go here
|