create-n8-app 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +10 -2
- package/package.json +3 -3
- package/template/_package.json +39 -30
- package/template/app/auth/signin/page.tsx +7 -2
- 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/lib/utils.ts +2 -2
- package/template/stores/example-store.ts +37 -2
- package/template/tsconfig.json +19 -5
- package/template/hooks/.gitkeep +0 -1
package/dist/index.js
CHANGED
|
@@ -122,7 +122,15 @@ async function runShadcnInit(targetDir) {
|
|
|
122
122
|
stdio: "inherit"
|
|
123
123
|
});
|
|
124
124
|
logger.info("Adding base Shadcn/ui components...");
|
|
125
|
-
const baseComponents = [
|
|
125
|
+
const baseComponents = [
|
|
126
|
+
"button",
|
|
127
|
+
"card",
|
|
128
|
+
"input",
|
|
129
|
+
"label",
|
|
130
|
+
"form",
|
|
131
|
+
"sonner",
|
|
132
|
+
"textarea"
|
|
133
|
+
];
|
|
126
134
|
await execa("npx", ["shadcn@latest", "add", ...baseComponents, "-y"], {
|
|
127
135
|
cwd: targetDir,
|
|
128
136
|
stdio: "inherit"
|
|
@@ -232,7 +240,7 @@ async function createProject(options) {
|
|
|
232
240
|
|
|
233
241
|
// src/index.ts
|
|
234
242
|
var program = new Command();
|
|
235
|
-
program.name("create-n8-app").description("Create a new Next.js app with the N8 stack").version("0.
|
|
243
|
+
program.name("create-n8-app").description("Create a new Next.js app with the N8 stack").version("0.3.1").argument("[project-name]", "Name of the project").option("--skip-install", "Skip installing dependencies").option("--skip-git", "Skip initializing git repository").action(async (projectName, options) => {
|
|
236
244
|
try {
|
|
237
245
|
await createProject({
|
|
238
246
|
projectName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-n8-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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,52 @@
|
|
|
17
17
|
"db:studio": "drizzle-kit studio"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@ai-sdk/openai": "^
|
|
21
|
-
"@auth/drizzle-adapter": "^1.11.
|
|
22
|
-
"@
|
|
23
|
-
"@
|
|
24
|
-
"@
|
|
25
|
-
"@
|
|
26
|
-
"@
|
|
27
|
-
"
|
|
20
|
+
"@ai-sdk/openai": "^3.0.21",
|
|
21
|
+
"@auth/drizzle-adapter": "^1.11.1",
|
|
22
|
+
"@hookform/resolvers": "^5.2.2",
|
|
23
|
+
"@neondatabase/serverless": "^1.0.2",
|
|
24
|
+
"@radix-ui/react-label": "^2.1.8",
|
|
25
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
26
|
+
"@tanstack/react-query": "^5.90.20",
|
|
27
|
+
"@trpc/client": "^11.8.1",
|
|
28
|
+
"@trpc/react-query": "^11.8.1",
|
|
29
|
+
"@trpc/server": "^11.8.1",
|
|
30
|
+
"ai": "^6.0.57",
|
|
31
|
+
"class-variance-authority": "^0.7.1",
|
|
28
32
|
"clsx": "^2.1.1",
|
|
29
|
-
"drizzle-orm": "^0.45.
|
|
33
|
+
"drizzle-orm": "^0.45.1",
|
|
30
34
|
"lucide-react": "^0.563.0",
|
|
31
|
-
"next": "^16.
|
|
32
|
-
"next-auth": "
|
|
33
|
-
"
|
|
34
|
-
"react
|
|
35
|
+
"next": "^16.1.5",
|
|
36
|
+
"next-auth": "5.0.0-beta.30",
|
|
37
|
+
"next-themes": "^0.4.6",
|
|
38
|
+
"react": "^19.2.4",
|
|
39
|
+
"react-dom": "^19.2.4",
|
|
40
|
+
"react-hook-form": "^7.71.1",
|
|
41
|
+
"sonner": "^2.0.7",
|
|
35
42
|
"superjson": "^2.2.6",
|
|
36
43
|
"tailwind-merge": "^3.4.0",
|
|
37
|
-
"zod": "^3.
|
|
44
|
+
"zod": "^4.3.6",
|
|
38
45
|
"zustand": "^5.0.10"
|
|
39
46
|
},
|
|
40
47
|
"devDependencies": {
|
|
41
|
-
"@tailwindcss/postcss": "^4.1.
|
|
42
|
-
"@
|
|
43
|
-
"@testing-library/
|
|
44
|
-
"@
|
|
45
|
-
"@types/
|
|
46
|
-
"@types/react
|
|
47
|
-
"@
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"eslint
|
|
48
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
49
|
+
"@tanstack/react-query-devtools": "^5.91.2",
|
|
50
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
51
|
+
"@testing-library/react": "^16.3.2",
|
|
52
|
+
"@types/node": "^25.0.10",
|
|
53
|
+
"@types/react": "^19.2.10",
|
|
54
|
+
"@types/react-dom": "^19.2.3",
|
|
55
|
+
"@vitejs/plugin-react": "^5.1.2",
|
|
56
|
+
"drizzle-kit": "^0.31.8",
|
|
57
|
+
"eslint": "^9.39.2",
|
|
58
|
+
"eslint-config-next": "^16.1.5",
|
|
51
59
|
"jsdom": "^27.4.0",
|
|
52
|
-
"postcss": "^8.5.
|
|
53
|
-
"prettier": "^3.8.
|
|
54
|
-
"prettier-plugin-tailwindcss": "^0.7.
|
|
55
|
-
"tailwindcss": "^4.1.
|
|
56
|
-
"
|
|
57
|
-
"
|
|
60
|
+
"postcss": "^8.5.6",
|
|
61
|
+
"prettier": "^3.8.1",
|
|
62
|
+
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
63
|
+
"tailwindcss": "^4.1.18",
|
|
64
|
+
"tw-animate-css": "^1.4.0",
|
|
65
|
+
"typescript": "^5.9.3",
|
|
66
|
+
"vitest": "^4.0.18"
|
|
58
67
|
}
|
|
59
68
|
}
|
|
@@ -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/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/sonner'
|
|
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
|
+
*/
|
package/template/lib/utils.ts
CHANGED
|
@@ -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/tsconfig.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "ES2017",
|
|
4
|
-
"lib": [
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
5
9
|
"allowJs": true,
|
|
6
10
|
"skipLibCheck": true,
|
|
7
11
|
"strict": true,
|
|
@@ -11,7 +15,7 @@
|
|
|
11
15
|
"moduleResolution": "bundler",
|
|
12
16
|
"resolveJsonModule": true,
|
|
13
17
|
"isolatedModules": true,
|
|
14
|
-
"jsx": "
|
|
18
|
+
"jsx": "react-jsx",
|
|
15
19
|
"incremental": true,
|
|
16
20
|
"plugins": [
|
|
17
21
|
{
|
|
@@ -19,9 +23,19 @@
|
|
|
19
23
|
}
|
|
20
24
|
],
|
|
21
25
|
"paths": {
|
|
22
|
-
"@/*": [
|
|
26
|
+
"@/*": [
|
|
27
|
+
"./*"
|
|
28
|
+
]
|
|
23
29
|
}
|
|
24
30
|
},
|
|
25
|
-
"include": [
|
|
26
|
-
|
|
31
|
+
"include": [
|
|
32
|
+
"next-env.d.ts",
|
|
33
|
+
"**/*.ts",
|
|
34
|
+
"**/*.tsx",
|
|
35
|
+
".next/types/**/*.ts",
|
|
36
|
+
".next/dev/types/**/*.ts"
|
|
37
|
+
],
|
|
38
|
+
"exclude": [
|
|
39
|
+
"node_modules"
|
|
40
|
+
]
|
|
27
41
|
}
|
package/template/hooks/.gitkeep
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# Custom React hooks go here
|