@dennisrongo/skills 0.1.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/LICENSE +21 -0
- package/README.md +230 -0
- package/bin/claude-skills.js +189 -0
- package/lib/install.js +111 -0
- package/lib/list.js +63 -0
- package/lib/paths.js +39 -0
- package/lib/remove.js +52 -0
- package/lib/skills.js +121 -0
- package/package.json +48 -0
- package/skills/_template/SKILL.md +34 -0
- package/skills/conventional-commits/SKILL.md +136 -0
- package/skills/diagnose/SKILL.md +140 -0
- package/skills/dotnet-onion-api/SKILL.md +267 -0
- package/skills/dotnet-onion-api/references/anti-patterns.md +155 -0
- package/skills/dotnet-onion-api/references/solution-layout.md +113 -0
- package/skills/dotnet-onion-api/references/templates/appsettings.md +75 -0
- package/skills/dotnet-onion-api/references/templates/base-controller.cs.md +90 -0
- package/skills/dotnet-onion-api/references/templates/csproj-files.md +178 -0
- package/skills/dotnet-onion-api/references/templates/dbcontext.cs.md +149 -0
- package/skills/dotnet-onion-api/references/templates/exception-middleware.cs.md +101 -0
- package/skills/dotnet-onion-api/references/templates/feature-slice.md +349 -0
- package/skills/dotnet-onion-api/references/templates/program-cs.md +171 -0
- package/skills/dotnet-onion-api/references/templates/worker-program.cs.md +166 -0
- package/skills/grill-with-docs/SKILL.md +203 -0
- package/skills/handoff/SKILL.md +155 -0
- package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/improve-codebase-architecture/SKILL.md +121 -0
- package/skills/nextjs-app-router/SKILL.md +328 -0
- package/skills/nextjs-app-router/references/anti-patterns.md +203 -0
- package/skills/nextjs-app-router/references/folder-layout.md +151 -0
- package/skills/nextjs-app-router/references/good-patterns.md +212 -0
- package/skills/nextjs-app-router/references/templates/api-base.md +62 -0
- package/skills/nextjs-app-router/references/templates/api-slice.md +75 -0
- package/skills/nextjs-app-router/references/templates/auth-slice.md +95 -0
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +94 -0
- package/skills/nextjs-app-router/references/templates/components-json.md +41 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +110 -0
- package/skills/nextjs-app-router/references/templates/eslint-prettier.md +82 -0
- package/skills/nextjs-app-router/references/templates/feature-slice.md +184 -0
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +192 -0
- package/skills/nextjs-app-router/references/templates/middleware.md +88 -0
- package/skills/nextjs-app-router/references/templates/next-config.md +42 -0
- package/skills/nextjs-app-router/references/templates/package.md +99 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +79 -0
- package/skills/nextjs-app-router/references/templates/root-layout.md +146 -0
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +90 -0
- package/skills/nextjs-app-router/references/templates/tailwind-config.md +89 -0
- package/skills/nextjs-app-router/references/templates/testing.md +137 -0
- package/skills/nextjs-app-router/references/templates/tsconfig.md +40 -0
- package/skills/plan-and-build/SKILL.md +214 -0
- package/skills/pr-review/SKILL.md +132 -0
- package/skills/write-a-skill/SKILL.md +191 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Form template (React Hook Form + Zod)
|
|
2
|
+
|
|
3
|
+
## `src/lib/zod-utils.ts`
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { z, type ZodTypeAny, ZodEffects, ZodObject, ZodOptional, ZodNullable, ZodString, ZodNumber, ZodBoolean, ZodArray, ZodDefault } from 'zod';
|
|
7
|
+
|
|
8
|
+
/** Peel `.refine()` / `.transform()` / `.superRefine()` layers to reach the underlying schema. */
|
|
9
|
+
export function unwrapZodEffects<T extends ZodTypeAny>(schema: T): ZodTypeAny {
|
|
10
|
+
let current: ZodTypeAny = schema;
|
|
11
|
+
while (current instanceof ZodEffects) {
|
|
12
|
+
current = current.innerType();
|
|
13
|
+
}
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Walk a Zod object schema and produce a defaults object matching its shape. */
|
|
18
|
+
export function getDefaultValuesFromSchema(schema: ZodTypeAny): Record<string, unknown> {
|
|
19
|
+
const inner = unwrapZodEffects(schema);
|
|
20
|
+
if (!(inner instanceof ZodObject)) return {};
|
|
21
|
+
|
|
22
|
+
const shape = inner.shape as Record<string, ZodTypeAny>;
|
|
23
|
+
const out: Record<string, unknown> = {};
|
|
24
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
25
|
+
out[key] = defaultFor(field);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function defaultFor(field: ZodTypeAny): unknown {
|
|
31
|
+
let current: ZodTypeAny = field;
|
|
32
|
+
while (current instanceof ZodEffects) current = current.innerType();
|
|
33
|
+
|
|
34
|
+
if (current instanceof ZodDefault) return current._def.defaultValue();
|
|
35
|
+
if (current instanceof ZodOptional) return undefined;
|
|
36
|
+
if (current instanceof ZodNullable) return null;
|
|
37
|
+
if (current instanceof ZodString) return '';
|
|
38
|
+
if (current instanceof ZodNumber) return undefined; // empty number input
|
|
39
|
+
if (current instanceof ZodBoolean) return false;
|
|
40
|
+
if (current instanceof ZodArray) return [];
|
|
41
|
+
if (current instanceof ZodObject) return getDefaultValuesFromSchema(current);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extract `.max(N)` constraints from string fields for `maxLength` attributes. */
|
|
46
|
+
export function getMaxLengthsFromSchema(schema: ZodTypeAny): Record<string, number> {
|
|
47
|
+
const inner = unwrapZodEffects(schema);
|
|
48
|
+
if (!(inner instanceof ZodObject)) return {};
|
|
49
|
+
|
|
50
|
+
const out: Record<string, number> = {};
|
|
51
|
+
for (const [key, field] of Object.entries(inner.shape as Record<string, ZodTypeAny>)) {
|
|
52
|
+
let current: ZodTypeAny = field;
|
|
53
|
+
while (current instanceof ZodEffects || current instanceof ZodOptional || current instanceof ZodNullable || current instanceof ZodDefault) {
|
|
54
|
+
current = current instanceof ZodDefault ? current._def.innerType : (current as ZodOptional<ZodTypeAny>).unwrap?.() ?? (current as ZodEffects<ZodTypeAny>).innerType?.();
|
|
55
|
+
}
|
|
56
|
+
if (current instanceof ZodString) {
|
|
57
|
+
const max = current._def.checks?.find((c) => c.kind === 'max');
|
|
58
|
+
if (max && 'value' in max) out[key] = max.value as number;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> The exact `_def` access details depend on the Zod major version. Verify against the version chosen at scaffold time (resolve via context7) and adapt — Zod's runtime introspection API has shifted between v3 and later versions.
|
|
66
|
+
|
|
67
|
+
## Composed form field — `src/components/forms/FormInputField.tsx`
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
'use client';
|
|
71
|
+
|
|
72
|
+
import type { ComponentProps } from 'react';
|
|
73
|
+
import type { FieldValues, Path, UseFormReturn } from 'react-hook-form';
|
|
74
|
+
import type { ZodTypeAny } from 'zod';
|
|
75
|
+
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
|
76
|
+
import { Input } from '@/components/ui/input';
|
|
77
|
+
import { getMaxLengthsFromSchema } from '@/lib/zod-utils';
|
|
78
|
+
|
|
79
|
+
interface FormInputFieldProps<TForm extends FieldValues> {
|
|
80
|
+
form: UseFormReturn<TForm>;
|
|
81
|
+
schema?: ZodTypeAny;
|
|
82
|
+
fieldName: Path<TForm>;
|
|
83
|
+
label: string;
|
|
84
|
+
placeholder?: string;
|
|
85
|
+
type?: ComponentProps<typeof Input>['type'];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function FormInputField<TForm extends FieldValues>({
|
|
89
|
+
form,
|
|
90
|
+
schema,
|
|
91
|
+
fieldName,
|
|
92
|
+
label,
|
|
93
|
+
placeholder,
|
|
94
|
+
type = 'text',
|
|
95
|
+
}: FormInputFieldProps<TForm>) {
|
|
96
|
+
const maxLengths = schema ? getMaxLengthsFromSchema(schema) : {};
|
|
97
|
+
return (
|
|
98
|
+
<FormField
|
|
99
|
+
control={form.control}
|
|
100
|
+
name={fieldName}
|
|
101
|
+
render={({ field }) => (
|
|
102
|
+
<FormItem>
|
|
103
|
+
<FormLabel>{label}</FormLabel>
|
|
104
|
+
<FormControl>
|
|
105
|
+
<Input
|
|
106
|
+
type={type}
|
|
107
|
+
placeholder={placeholder}
|
|
108
|
+
maxLength={maxLengths[String(fieldName)]}
|
|
109
|
+
{...field}
|
|
110
|
+
value={field.value ?? ''}
|
|
111
|
+
/>
|
|
112
|
+
</FormControl>
|
|
113
|
+
<FormMessage />
|
|
114
|
+
</FormItem>
|
|
115
|
+
)}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**No `dangerouslySetInnerHTML` for the label.** Labels are strings. If a future requirement is "render an inline `<strong>` in the label," accept a `ReactNode` instead of a string — never raw HTML.
|
|
122
|
+
|
|
123
|
+
## A real form — `src/app/(public)/auth/login/_components/LoginForm.tsx`
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
'use client';
|
|
127
|
+
|
|
128
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
129
|
+
import { useForm } from 'react-hook-form';
|
|
130
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
131
|
+
import { z } from 'zod';
|
|
132
|
+
|
|
133
|
+
import { Form } from '@/components/ui/form';
|
|
134
|
+
import { Button } from '@/components/ui/button';
|
|
135
|
+
import { FormInputField } from '@/components/forms/FormInputField';
|
|
136
|
+
import { useLoginMutation } from '@/redux/api/authApi';
|
|
137
|
+
import { useToast } from '@/components/ui/use-toast';
|
|
138
|
+
import { getDefaultValuesFromSchema, unwrapZodEffects } from '@/lib/zod-utils';
|
|
139
|
+
|
|
140
|
+
const loginSchema = z.object({
|
|
141
|
+
email: z.string().email(),
|
|
142
|
+
password: z.string().min(8).max(128),
|
|
143
|
+
});
|
|
144
|
+
type LoginValues = z.infer<typeof loginSchema>;
|
|
145
|
+
|
|
146
|
+
export function LoginForm() {
|
|
147
|
+
const router = useRouter();
|
|
148
|
+
const searchParams = useSearchParams();
|
|
149
|
+
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
|
|
150
|
+
const { toast } = useToast();
|
|
151
|
+
const [login, { isLoading }] = useLoginMutation();
|
|
152
|
+
|
|
153
|
+
const form = useForm<LoginValues>({
|
|
154
|
+
resolver: zodResolver(loginSchema),
|
|
155
|
+
defaultValues: getDefaultValuesFromSchema(unwrapZodEffects(loginSchema)) as LoginValues,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const onSubmit = async (values: LoginValues) => {
|
|
159
|
+
try {
|
|
160
|
+
await login(values).unwrap();
|
|
161
|
+
router.replace(returnTo);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
toast({ title: 'Sign in failed', description: String((err as { data?: { message?: string } }).data?.message ?? 'Try again.'), variant: 'destructive' });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Form {...form}>
|
|
169
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
170
|
+
<FormInputField form={form} schema={loginSchema} fieldName="email" label="Email" type="email" />
|
|
171
|
+
<FormInputField form={form} schema={loginSchema} fieldName="password" label="Password" type="password" />
|
|
172
|
+
<Button type="submit" disabled={isLoading} className="w-full">
|
|
173
|
+
{isLoading ? 'Signing in…' : 'Sign in'}
|
|
174
|
+
</Button>
|
|
175
|
+
</form>
|
|
176
|
+
</Form>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## `UnsavedChangesWarning` integration
|
|
182
|
+
|
|
183
|
+
For non-trivial forms, wrap the submit area with the guard:
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
import { UnsavedChangesWarning } from '@/components/UnsavedChangesWarning';
|
|
187
|
+
|
|
188
|
+
// inside the form:
|
|
189
|
+
<UnsavedChangesWarning when={form.formState.isDirty && !form.formState.isSubmitting} />
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`UnsavedChangesWarning` uses `window.beforeunload` + a Next router-events listener to prompt before navigating away.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# `middleware.ts` template
|
|
2
|
+
|
|
3
|
+
This is **the** auth gate. Place at the project root (not in `src/`). Next.js requires this exact filename and location.
|
|
4
|
+
|
|
5
|
+
## Variant A — `httpOnly` cookie set by the backend on login
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// middleware.ts
|
|
9
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
10
|
+
|
|
11
|
+
const PROTECTED_PREFIXES = ['/app', '/admin'];
|
|
12
|
+
const AUTH_PATH = '/auth/login';
|
|
13
|
+
|
|
14
|
+
export function middleware(req: NextRequest) {
|
|
15
|
+
const { pathname, search } = req.nextUrl;
|
|
16
|
+
const session = req.cookies.get('session')?.value;
|
|
17
|
+
const isProtected = PROTECTED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
18
|
+
|
|
19
|
+
// Already authenticated user hitting an auth page → bounce to /app.
|
|
20
|
+
if (session && pathname.startsWith('/auth')) {
|
|
21
|
+
const url = req.nextUrl.clone();
|
|
22
|
+
url.pathname = '/app/dashboard';
|
|
23
|
+
url.search = '';
|
|
24
|
+
return NextResponse.redirect(url);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Unauthenticated user hitting a protected route → redirect with returnTo.
|
|
28
|
+
if (!session && isProtected) {
|
|
29
|
+
const url = req.nextUrl.clone();
|
|
30
|
+
url.pathname = AUTH_PATH;
|
|
31
|
+
url.search = '';
|
|
32
|
+
url.searchParams.set('returnTo', pathname + search);
|
|
33
|
+
return NextResponse.redirect(url);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return NextResponse.next();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const config = {
|
|
40
|
+
// Run on everything except Next internals and static assets.
|
|
41
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
|
|
42
|
+
};
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Variant B — JS-readable cookie (decode the JWT here)
|
|
46
|
+
|
|
47
|
+
If the backend cannot set an `httpOnly` cookie and the project must use a JS-readable token, decode the JWT in middleware to check expiry — never trust the client to have done so:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
51
|
+
import { jwtVerify } from 'jose'; // Edge-compatible; do not use jwt-decode here.
|
|
52
|
+
|
|
53
|
+
const PROTECTED_PREFIXES = ['/app', '/admin'];
|
|
54
|
+
const SECRET = new TextEncoder().encode(process.env.JWT_VERIFY_KEY!);
|
|
55
|
+
|
|
56
|
+
export async function middleware(req: NextRequest) {
|
|
57
|
+
const token = req.cookies.get('token')?.value;
|
|
58
|
+
const { pathname, search } = req.nextUrl;
|
|
59
|
+
const isProtected = PROTECTED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
|
60
|
+
|
|
61
|
+
if (!isProtected) return NextResponse.next();
|
|
62
|
+
if (!token) return redirectToLogin(req);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await jwtVerify(token, SECRET);
|
|
66
|
+
return NextResponse.next();
|
|
67
|
+
} catch {
|
|
68
|
+
return redirectToLogin(req);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function redirectToLogin(req: NextRequest) {
|
|
73
|
+
const url = req.nextUrl.clone();
|
|
74
|
+
url.pathname = '/auth/login';
|
|
75
|
+
url.searchParams.set('returnTo', req.nextUrl.pathname + req.nextUrl.search);
|
|
76
|
+
return NextResponse.redirect(url);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const config = {
|
|
80
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
81
|
+
};
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Notes:**
|
|
85
|
+
- `jose` is Edge-runtime compatible. `jsonwebtoken` and `jwt-decode` are not — they assume Node.
|
|
86
|
+
- The matcher must exclude `_next/static`, `_next/image`, and `favicon.ico`. Add other public assets as needed.
|
|
87
|
+
- Do **not** add a `src/proxy.ts` or `src/middleware.ts` — Next.js only looks at `middleware.ts` at the project root.
|
|
88
|
+
- Role-gating for `/admin` happens here too: decode the role claim and 403 (or redirect) when the user is not allowed.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# `next.config.ts` template
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import type { NextConfig } from 'next';
|
|
5
|
+
|
|
6
|
+
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
7
|
+
|
|
8
|
+
const nextConfig: NextConfig = {
|
|
9
|
+
reactStrictMode: true,
|
|
10
|
+
poweredByHeader: false,
|
|
11
|
+
|
|
12
|
+
// Forward /api/* to the backend in production; in dev, hit the backend directly via NEXT_PUBLIC_API_BASE_URL.
|
|
13
|
+
// Configure only if the user opted into the rewrite pattern in Step 2.
|
|
14
|
+
async rewrites() {
|
|
15
|
+
if (!apiBase) return [];
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
source: '/api/:path*',
|
|
19
|
+
destination: `${apiBase}/:path*`,
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
images: {
|
|
25
|
+
remotePatterns: [
|
|
26
|
+
// Add allowed external image hosts here. Leave empty by default.
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
experimental: {
|
|
31
|
+
// Enable on a case-by-case basis only.
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default nextConfig;
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Notes:**
|
|
39
|
+
- Do not enable `output: 'standalone'` by default; only when the user is deploying to a container.
|
|
40
|
+
- Do not enable `output: 'export'` (static export) unless the user explicitly asks — it disables middleware, server components data fetching, and image optimization.
|
|
41
|
+
- `reactStrictMode: true` is non-negotiable for a new project.
|
|
42
|
+
- `poweredByHeader: false` removes the `X-Powered-By` header.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# `package.json` template
|
|
2
|
+
|
|
3
|
+
Resolve **every** version via context7 at scaffold time. Versions below are placeholders (`"^x.y.z"`) — replace with the latest stable resolved at scaffold time and quote the resolved versions back to the user.
|
|
4
|
+
|
|
5
|
+
```jsonc
|
|
6
|
+
{
|
|
7
|
+
"name": "{{project-name}}",
|
|
8
|
+
"version": "0.1.0",
|
|
9
|
+
"private": true,
|
|
10
|
+
"packageManager": "{{pnpm|npm|yarn}}@x.y.z",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "next dev",
|
|
13
|
+
"build": "next build",
|
|
14
|
+
"start": "next start",
|
|
15
|
+
"lint": "eslint .",
|
|
16
|
+
"lint:fix": "eslint . --fix",
|
|
17
|
+
"format": "prettier --write .",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"test:e2e": "playwright test",
|
|
22
|
+
"prepare": "husky"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"next": "^x.y.z",
|
|
26
|
+
"react": "^x.y.z",
|
|
27
|
+
"react-dom": "^x.y.z",
|
|
28
|
+
|
|
29
|
+
"@reduxjs/toolkit": "^x.y.z",
|
|
30
|
+
"react-redux": "^x.y.z",
|
|
31
|
+
|
|
32
|
+
"react-hook-form": "^x.y.z",
|
|
33
|
+
"@hookform/resolvers": "^x.y.z",
|
|
34
|
+
"zod": "^x.y.z",
|
|
35
|
+
|
|
36
|
+
"@radix-ui/react-dialog": "^x.y.z",
|
|
37
|
+
"@radix-ui/react-dropdown-menu": "^x.y.z",
|
|
38
|
+
"@radix-ui/react-label": "^x.y.z",
|
|
39
|
+
"@radix-ui/react-popover": "^x.y.z",
|
|
40
|
+
"@radix-ui/react-select": "^x.y.z",
|
|
41
|
+
"@radix-ui/react-slot": "^x.y.z",
|
|
42
|
+
"@radix-ui/react-toast": "^x.y.z",
|
|
43
|
+
"class-variance-authority": "^x.y.z",
|
|
44
|
+
"clsx": "^x.y.z",
|
|
45
|
+
"tailwind-merge": "^x.y.z",
|
|
46
|
+
"tailwindcss-animate": "^x.y.z",
|
|
47
|
+
"lucide-react": "^x.y.z",
|
|
48
|
+
|
|
49
|
+
"date-fns": "^x.y.z",
|
|
50
|
+
"next-nprogress-bar": "^x.y.z"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"typescript": "^x.y.z",
|
|
54
|
+
"@types/node": "^x.y.z",
|
|
55
|
+
"@types/react": "^x.y.z",
|
|
56
|
+
"@types/react-dom": "^x.y.z",
|
|
57
|
+
|
|
58
|
+
"tailwindcss": "^x.y.z",
|
|
59
|
+
"postcss": "^x.y.z",
|
|
60
|
+
"autoprefixer": "^x.y.z",
|
|
61
|
+
|
|
62
|
+
"eslint": "^x.y.z",
|
|
63
|
+
"eslint-config-next": "^x.y.z",
|
|
64
|
+
"@typescript-eslint/eslint-plugin": "^x.y.z",
|
|
65
|
+
"@typescript-eslint/parser": "^x.y.z",
|
|
66
|
+
"eslint-plugin-react-hooks": "^x.y.z",
|
|
67
|
+
"eslint-plugin-jsx-a11y": "^x.y.z",
|
|
68
|
+
"prettier": "^x.y.z",
|
|
69
|
+
"prettier-plugin-tailwindcss": "^x.y.z",
|
|
70
|
+
|
|
71
|
+
"husky": "^x.y.z",
|
|
72
|
+
"lint-staged": "^x.y.z",
|
|
73
|
+
|
|
74
|
+
"vitest": "^x.y.z",
|
|
75
|
+
"@vitejs/plugin-react": "^x.y.z",
|
|
76
|
+
"@testing-library/react": "^x.y.z",
|
|
77
|
+
"@testing-library/jest-dom": "^x.y.z",
|
|
78
|
+
"@testing-library/user-event": "^x.y.z",
|
|
79
|
+
"jsdom": "^x.y.z",
|
|
80
|
+
"@playwright/test": "^x.y.z"
|
|
81
|
+
},
|
|
82
|
+
"lint-staged": {
|
|
83
|
+
"*.{ts,tsx}": ["prettier --write", "eslint --fix"],
|
|
84
|
+
"*.{json,md,css,yml,yaml}": ["prettier --write"]
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Do NOT include:**
|
|
90
|
+
- `moment` (use `date-fns`)
|
|
91
|
+
- `styled-components` / `@emotion/*` (project is Tailwind-only)
|
|
92
|
+
- `nprogress` (use `next-nprogress-bar`)
|
|
93
|
+
- A `proxy` library at the application layer (use Next.js `rewrites()` in `next.config.ts`)
|
|
94
|
+
|
|
95
|
+
**Add only if the user opted in during Step 2:**
|
|
96
|
+
- `pusher-js` (real-time)
|
|
97
|
+
- Storybook packages
|
|
98
|
+
- `next-intl` / `next-i18next` (i18n)
|
|
99
|
+
- `@sentry/nextjs` (error tracking)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Providers, store, typed hooks
|
|
2
|
+
|
|
3
|
+
## `src/redux/providers.tsx` (CLIENT — this one must be client because of `<Provider store>`)
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { Provider } from 'react-redux';
|
|
9
|
+
import { AppProgressBar as ProgressBar } from 'next-nprogress-bar';
|
|
10
|
+
import { Toaster } from '@/components/ui/toaster';
|
|
11
|
+
import { store } from './store';
|
|
12
|
+
|
|
13
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
14
|
+
return (
|
|
15
|
+
<Provider store={store}>
|
|
16
|
+
{children}
|
|
17
|
+
<Toaster />
|
|
18
|
+
<ProgressBar
|
|
19
|
+
height="3px"
|
|
20
|
+
color="hsl(var(--primary))"
|
|
21
|
+
shallowRouting
|
|
22
|
+
disableSameURL
|
|
23
|
+
options={{ showSpinner: false }}
|
|
24
|
+
/>
|
|
25
|
+
</Provider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## `src/redux/store.ts`
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { combineReducers, configureStore, type Action } from '@reduxjs/toolkit';
|
|
34
|
+
import { api } from './api/api';
|
|
35
|
+
import { authApi } from './api/authApi';
|
|
36
|
+
import { authSlice } from './features/authSlice';
|
|
37
|
+
|
|
38
|
+
const combinedReducer = combineReducers({
|
|
39
|
+
[api.reducerPath]: api.reducer,
|
|
40
|
+
auth: authSlice.reducer,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type CombinedState = ReturnType<typeof combinedReducer>;
|
|
44
|
+
|
|
45
|
+
const rootReducer = (state: CombinedState | undefined, action: Action): CombinedState => {
|
|
46
|
+
// Wipe the entire store on logout. RTK Query cache, auth, everything.
|
|
47
|
+
if (authApi.endpoints.logout.matchFulfilled(action)) {
|
|
48
|
+
return combinedReducer(undefined, action);
|
|
49
|
+
}
|
|
50
|
+
return combinedReducer(state, action);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const store = configureStore({
|
|
54
|
+
reducer: rootReducer,
|
|
55
|
+
middleware: (getDefaultMiddleware) =>
|
|
56
|
+
getDefaultMiddleware({
|
|
57
|
+
// Keep serializableCheck ON. If a specific value is genuinely non-serializable,
|
|
58
|
+
// add it here with a comment explaining why.
|
|
59
|
+
// serializableCheck: { ignoredPaths: ['auth.expiresAt'] },
|
|
60
|
+
}).concat(api.middleware),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export type RootState = ReturnType<typeof store.getState>;
|
|
64
|
+
export type AppDispatch = typeof store.dispatch;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**No `@ts-ignore`. No `serializableCheck: false`. No commented-out reducers.**
|
|
68
|
+
|
|
69
|
+
## `src/redux/hooks.ts`
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
|
|
73
|
+
import type { AppDispatch, RootState } from './store';
|
|
74
|
+
|
|
75
|
+
export const useAppDispatch: () => AppDispatch = useDispatch;
|
|
76
|
+
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Components import `useAppDispatch` / `useAppSelector` — never the raw `useDispatch` / `useSelector` from `react-redux`. ESLint can enforce this with `no-restricted-imports` if desired.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Root layout, root page, error/not-found templates
|
|
2
|
+
|
|
3
|
+
## `src/app/layout.tsx` (SERVER component)
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import '@/app/globals.css';
|
|
7
|
+
import type { Metadata } from 'next';
|
|
8
|
+
import { Inter } from 'next/font/google';
|
|
9
|
+
import { Providers } from '@/redux/providers';
|
|
10
|
+
|
|
11
|
+
const inter = Inter({ subsets: ['latin'] });
|
|
12
|
+
|
|
13
|
+
export const metadata: Metadata = {
|
|
14
|
+
title: '{{Project Title}}',
|
|
15
|
+
description: '{{Project description}}',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
19
|
+
return (
|
|
20
|
+
<html lang="en" suppressHydrationWarning>
|
|
21
|
+
<body className={inter.className}>
|
|
22
|
+
<Providers>{children}</Providers>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**NO `'use client'`. NO `useEffect`. NO `useRouter`.** The root layout is a server component.
|
|
30
|
+
|
|
31
|
+
## `src/app/page.tsx` (SERVER component, redirect-only)
|
|
32
|
+
|
|
33
|
+
If the project has an authenticated landing route, the root page is a server `redirect`:
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { redirect } from 'next/navigation';
|
|
37
|
+
|
|
38
|
+
export default function Home() {
|
|
39
|
+
redirect('/dashboard');
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If the project has a marketing landing page, render content instead — still as a server component, no `'use client'`.
|
|
44
|
+
|
|
45
|
+
**Forbidden:**
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
// ❌ DO NOT generate this.
|
|
49
|
+
'use client';
|
|
50
|
+
import { useRouter } from 'next/navigation';
|
|
51
|
+
import { useEffect } from 'react';
|
|
52
|
+
export default function Home() {
|
|
53
|
+
const router = useRouter();
|
|
54
|
+
useEffect(() => router.push('/dashboard'));
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## `src/app/error.tsx` (CLIENT — Next requires it)
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
'use client';
|
|
62
|
+
|
|
63
|
+
import { useEffect } from 'react';
|
|
64
|
+
import { Button } from '@/components/ui/button';
|
|
65
|
+
|
|
66
|
+
export default function GlobalError({
|
|
67
|
+
error,
|
|
68
|
+
reset,
|
|
69
|
+
}: {
|
|
70
|
+
error: Error & { digest?: string };
|
|
71
|
+
reset: () => void;
|
|
72
|
+
}) {
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
// Wire to your error tracker (Sentry, etc.) if configured.
|
|
75
|
+
console.error(error);
|
|
76
|
+
}, [error]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
|
|
80
|
+
<h1 className="text-2xl font-semibold">Something went wrong</h1>
|
|
81
|
+
<p className="text-muted-foreground">{error.message}</p>
|
|
82
|
+
<Button onClick={reset}>Try again</Button>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## `src/app/not-found.tsx` (SERVER component)
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import Link from 'next/link';
|
|
92
|
+
import { Button } from '@/components/ui/button';
|
|
93
|
+
|
|
94
|
+
export default function NotFound() {
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
|
|
97
|
+
<h1 className="text-3xl font-semibold">404 — Page not found</h1>
|
|
98
|
+
<Button asChild>
|
|
99
|
+
<Link href="/">Go home</Link>
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## `src/app/globals.css` (Tailwind layer + shadcn CSS variables)
|
|
107
|
+
|
|
108
|
+
```css
|
|
109
|
+
@tailwind base;
|
|
110
|
+
@tailwind components;
|
|
111
|
+
@tailwind utilities;
|
|
112
|
+
|
|
113
|
+
@layer base {
|
|
114
|
+
:root {
|
|
115
|
+
--background: 0 0% 100%;
|
|
116
|
+
--foreground: 240 10% 3.9%;
|
|
117
|
+
--card: 0 0% 100%;
|
|
118
|
+
--card-foreground: 240 10% 3.9%;
|
|
119
|
+
--popover: 0 0% 100%;
|
|
120
|
+
--popover-foreground: 240 10% 3.9%;
|
|
121
|
+
--primary: 240 5.9% 10%;
|
|
122
|
+
--primary-foreground: 0 0% 98%;
|
|
123
|
+
--secondary: 240 4.8% 95.9%;
|
|
124
|
+
--secondary-foreground: 240 5.9% 10%;
|
|
125
|
+
--muted: 240 4.8% 95.9%;
|
|
126
|
+
--muted-foreground: 240 3.8% 46.1%;
|
|
127
|
+
--accent: 240 4.8% 95.9%;
|
|
128
|
+
--accent-foreground: 240 5.9% 10%;
|
|
129
|
+
--destructive: 0 84.2% 60.2%;
|
|
130
|
+
--destructive-foreground: 0 0% 98%;
|
|
131
|
+
--border: 240 5.9% 90%;
|
|
132
|
+
--input: 240 5.9% 90%;
|
|
133
|
+
--ring: 240 5.9% 10%;
|
|
134
|
+
--radius: 0.5rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.dark {
|
|
138
|
+
--background: 240 10% 3.9%;
|
|
139
|
+
--foreground: 0 0% 98%;
|
|
140
|
+
/* ... dark counterparts ... */
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
* { @apply border-border; }
|
|
144
|
+
body { @apply bg-background text-foreground; }
|
|
145
|
+
}
|
|
146
|
+
```
|