create-tigra 2.7.1 → 2.8.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/bin/create-tigra.js +445 -445
- package/package.json +1 -1
- package/template/_claude/rules/client/03-data-and-state.md +44 -5
- package/template/_claude/rules/client/05-security.md +1 -1
- package/template/_claude/skills/clean-ui/SKILL.md +63 -0
- package/template/_claude/skills/theme/SKILL.md +109 -0
- package/template/client/next.config.ts +4 -1
- package/template/client/package.json +47 -47
- package/template/client/src/app/globals.css +8 -0
- package/template/client/src/app/layout.tsx +7 -1
- package/template/client/src/app/providers.tsx +5 -4
- package/template/client/src/components/common/Pagination.tsx +10 -2
- package/template/client/src/features/admin/hooks/useAdminSessions.ts +3 -0
- package/template/client/src/features/admin/hooks/useAdminUsers.ts +5 -0
- package/template/client/src/features/auth/components/AuthInitializer.tsx +27 -44
- package/template/client/src/features/auth/components/LoginForm.tsx +15 -2
- package/template/client/src/features/auth/hooks/useAuth.ts +17 -10
- package/template/client/src/features/auth/hooks/useCurrentUser.ts +50 -0
- package/template/client/src/styles/themes/default.css +92 -92
- package/template/server/.env.example +8 -2
- package/template/server/package.json +1 -1
- package/template/server/src/app.ts +2 -7
- package/template/server/src/config/rate-limit.config.ts +1 -0
- package/template/server/src/libs/auth.ts +9 -2
- package/template/server/src/modules/auth/auth.controller.ts +16 -5
package/package.json
CHANGED
|
@@ -25,12 +25,15 @@
|
|
|
25
25
|
Defaults in `app/providers.tsx`:
|
|
26
26
|
|
|
27
27
|
```typescript
|
|
28
|
-
staleTime:
|
|
29
|
-
gcTime:
|
|
30
|
-
refetchOnWindowFocus:
|
|
28
|
+
staleTime: 30 * 1000 // 30s — short enough that back-navigation refetches
|
|
29
|
+
gcTime: 5 * 60 * 1000 // 5 min
|
|
30
|
+
refetchOnWindowFocus: true // catch cross-tab edits
|
|
31
|
+
refetchOnMount: true // always refetch stale data on mount
|
|
31
32
|
retry: 1
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
**Why these values matter**: With a long `staleTime` (e.g. 5 min) and `refetchOnWindowFocus: false`, navigating back to a list page after editing a record on another page will show stale data until the user hard-refreshes. Keep `staleTime` short and let invalidation + remount refetch do their job.
|
|
36
|
+
|
|
34
37
|
### Query Key Factory Pattern
|
|
35
38
|
|
|
36
39
|
```typescript
|
|
@@ -45,9 +48,35 @@ export const itemKeys = {
|
|
|
45
48
|
|
|
46
49
|
### Mutations
|
|
47
50
|
|
|
48
|
-
On success:
|
|
51
|
+
On success:
|
|
52
|
+
1. **`queryClient.invalidateQueries({ queryKey: ... })`** — refreshes any client-fetched data (React Query).
|
|
53
|
+
2. **`router.refresh()`** — refreshes any Server-Component-rendered data on the next route the user navigates to. Without this, the Next.js Router Cache will serve stale RSC payloads on back-navigation, and your edit will not appear until a hard refresh.
|
|
54
|
+
3. Show a toast.
|
|
55
|
+
4. Navigate if needed.
|
|
56
|
+
|
|
49
57
|
On error: `toast.error(getErrorMessage(error))`.
|
|
50
58
|
|
|
59
|
+
**Always call both `invalidateQueries` AND `router.refresh()`** unless you are 100% certain no Server Component on any reachable route reads the mutated data. The two caches are independent — invalidating one does not touch the other.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const queryClient = useQueryClient();
|
|
64
|
+
|
|
65
|
+
const mutation = useMutation({
|
|
66
|
+
mutationFn: (data) => itemService.updateItem(id, data),
|
|
67
|
+
onSuccess: () => {
|
|
68
|
+
queryClient.invalidateQueries({ queryKey: itemKeys.all });
|
|
69
|
+
router.refresh();
|
|
70
|
+
toast.success('Item updated');
|
|
71
|
+
},
|
|
72
|
+
onError: (error) => toast.error(getErrorMessage(error)),
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Next.js Router Cache
|
|
77
|
+
|
|
78
|
+
`next.config.ts` sets `experimental.staleTimes: { dynamic: 0, static: 0 }` to disable client-side Router Cache reuse. **Never raise these values** — doing so reintroduces the back-navigation stale-data bug across every page that uses Server Components for data fetching.
|
|
79
|
+
|
|
51
80
|
---
|
|
52
81
|
|
|
53
82
|
## Redux
|
|
@@ -61,7 +90,17 @@ State shape:
|
|
|
61
90
|
{ user: IUser | null; isAuthenticated: boolean; isInitializing: boolean; isLoggingOut: boolean }
|
|
62
91
|
```
|
|
63
92
|
|
|
64
|
-
**Not persisted to localStorage** — auth state is hydrated
|
|
93
|
+
**Not persisted to localStorage** — auth state is hydrated by `AuthInitializer`, which calls the `useCurrentUser()` hook. `useCurrentUser()` is a React Query wrapper around `authService.getMe()` that syncs the result into Redux via a side effect. Tokens are stored in httpOnly cookies (not accessible from JS).
|
|
94
|
+
|
|
95
|
+
**Refreshing the current user**: any mutation that changes the logged-in user's own data (profile update, role change, avatar upload, email verification, subscription change, etc.) MUST invalidate the auth query so Redux picks up the new values:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { authKeys } from '@/features/auth/hooks/useCurrentUser';
|
|
99
|
+
|
|
100
|
+
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Without this, Redux will hold the stale snapshot from initial page load until the next window-focus refetch (30s staleTime), or until logout/hard refresh. Never write directly to the auth slice from outside the auth feature — always go through invalidation.
|
|
65
104
|
|
|
66
105
|
---
|
|
67
106
|
|
|
@@ -37,7 +37,7 @@ X-XSS-Protection: 1; mode=block
|
|
|
37
37
|
default-src 'self';
|
|
38
38
|
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
|
39
39
|
style-src 'self' 'unsafe-inline';
|
|
40
|
-
img-src 'self' blob: data: https
|
|
40
|
+
img-src 'self' blob: data: https: ${apiOrigin};
|
|
41
41
|
font-src 'self';
|
|
42
42
|
object-src 'none';
|
|
43
43
|
base-uri 'self';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clean-ui
|
|
3
|
+
description: Remove the starter welcome page UI and replace with a blank canvas, preserving all client-side functionality (auth, hooks, services, store, middleware, utils)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The user wants to remove the starter/welcome UI from the scaffolded client and start with a blank page.
|
|
7
|
+
|
|
8
|
+
## What this skill does
|
|
9
|
+
|
|
10
|
+
Replaces the demo welcome page (`src/app/page.tsx`) with the absolute bare minimum — just a centered "Ready to build." text. **Nothing else is touched** — all functional infrastructure remains intact.
|
|
11
|
+
|
|
12
|
+
## What gets replaced
|
|
13
|
+
|
|
14
|
+
| File | Action | Reason |
|
|
15
|
+
|------|--------|--------|
|
|
16
|
+
| `src/app/page.tsx` | **Replace** with blank canvas | Remove ALL demo content — hero, ambient glow, GitHub links, buttons, everything |
|
|
17
|
+
|
|
18
|
+
## What is NOT touched (preserved as-is)
|
|
19
|
+
|
|
20
|
+
- `src/components/layout/` — Header, Footer, MainLayout
|
|
21
|
+
- `src/app/layout.tsx` — root layout with providers
|
|
22
|
+
- `src/app/providers.tsx` — Redux, React Query, themes, AuthInitializer
|
|
23
|
+
- `src/app/error.tsx`, `src/app/not-found.tsx`, `src/app/loading.tsx`
|
|
24
|
+
- `src/middleware.ts` — route protection
|
|
25
|
+
- `src/features/auth/**` — entire auth system
|
|
26
|
+
- `src/components/common/**` — ThemeToggle, EmptyState, LoadingSpinner, etc.
|
|
27
|
+
- `src/components/ui/**` — all shadcn/ui components
|
|
28
|
+
- `src/hooks/**`, `src/store/**`, `src/lib/**`, `src/styles/**`
|
|
29
|
+
- All config files
|
|
30
|
+
|
|
31
|
+
## Steps
|
|
32
|
+
|
|
33
|
+
1. Read `src/app/page.tsx` to confirm it exists.
|
|
34
|
+
2. Replace its contents with the clean page below. Use the **exact** template — do not add anything.
|
|
35
|
+
3. Confirm to the user what was done.
|
|
36
|
+
|
|
37
|
+
## Clean page template
|
|
38
|
+
|
|
39
|
+
Replace `src/app/page.tsx` with **exactly** this — no additions, no modifications:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import type React from 'react';
|
|
43
|
+
|
|
44
|
+
export default function HomePage(): React.ReactElement {
|
|
45
|
+
return (
|
|
46
|
+
<main className="flex min-h-dvh items-center justify-center">
|
|
47
|
+
<p className="text-sm text-muted-foreground">Ready to build.</p>
|
|
48
|
+
</main>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**CRITICAL**: Do NOT add anything beyond what is in the template above. No heading, no links, no buttons, no metadata, no imports beyond React. The entire point is a blank canvas.
|
|
54
|
+
|
|
55
|
+
## Response format
|
|
56
|
+
|
|
57
|
+
After completing, respond with:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Starter UI cleaned. `src/app/page.tsx` is now a blank canvas.
|
|
61
|
+
|
|
62
|
+
Everything else is untouched — auth, hooks, services, store, middleware, components, and design system are all intact.
|
|
63
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: theme
|
|
3
|
+
description: Change the color palette in the client theme file. Only swaps HEX values — never touches variable names, file structure, or conventions.
|
|
4
|
+
argument-hint: "[color description or specific HEX values]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Theme Palette Changer
|
|
8
|
+
|
|
9
|
+
The user wants to change the color palette. Their input: **$ARGUMENTS**
|
|
10
|
+
|
|
11
|
+
## Your ONLY job
|
|
12
|
+
|
|
13
|
+
Replace HEX color values in `src/styles/themes/default.css` (or the equivalent theme file in the active project's client directory). That's it.
|
|
14
|
+
|
|
15
|
+
## What you MUST NOT do
|
|
16
|
+
|
|
17
|
+
- **DO NOT** rename, add, or remove any CSS variable names (e.g., `--primary`, `--background`, `--muted`)
|
|
18
|
+
- **DO NOT** change the file structure, move files, or create new files
|
|
19
|
+
- **DO NOT** touch `globals.css`, components, or any other file
|
|
20
|
+
- **DO NOT** change `--radius` or any non-color value
|
|
21
|
+
- **DO NOT** convert HEX to OKLCH, HSL, or any other format — stay in HEX
|
|
22
|
+
- **DO NOT** remove the `.dark` selector or the `:root` selector
|
|
23
|
+
- **DO NOT** remove or rewrite comments unless updating the palette description at the top
|
|
24
|
+
- **DO NOT** change `rgba()` values to HEX — keep `rgba()` where it already exists (e.g., dark mode `--border`, `--input`)
|
|
25
|
+
- **DO NOT** touch any code outside the theme file — no components, no Tailwind config, no globals
|
|
26
|
+
|
|
27
|
+
## Before making changes — ask these questions if not answered
|
|
28
|
+
|
|
29
|
+
You MUST gather enough information before editing. If the user's input doesn't cover these, ask BEFORE making any changes:
|
|
30
|
+
|
|
31
|
+
### Required information
|
|
32
|
+
|
|
33
|
+
1. **Primary/brand color** — What is the main accent color? (at minimum, you need this)
|
|
34
|
+
|
|
35
|
+
### Clarifying questions (ask if not addressed)
|
|
36
|
+
|
|
37
|
+
2. **Dark mode** — one of:
|
|
38
|
+
- "Will you provide dark mode colors yourself, or should I generate them from your palette?"
|
|
39
|
+
- Skip if user explicitly says "light mode only" or provides both sets
|
|
40
|
+
|
|
41
|
+
3. **Palette vibe/mood** — if the user only gave a single color or vague description:
|
|
42
|
+
- "What's the mood? (e.g., warm, cool, corporate, playful, minimal, luxury)"
|
|
43
|
+
- This helps you pick complementary background, muted, secondary, and accent colors
|
|
44
|
+
|
|
45
|
+
4. **Background preference** — if not obvious from context:
|
|
46
|
+
- "Do you want a light cream/warm background, a cool/gray background, or pure white?"
|
|
47
|
+
|
|
48
|
+
5. **Destructive/success/warning/info** — if the user provides a full custom palette but skips these:
|
|
49
|
+
- "Should I keep the current red/green/amber/blue for status colors, or adjust them to match your new palette?"
|
|
50
|
+
|
|
51
|
+
### When you have enough
|
|
52
|
+
|
|
53
|
+
- User gives a full palette with explicit HEX values for most tokens → just apply them, generate any missing ones
|
|
54
|
+
- User gives a brand color + mood → generate a cohesive palette and present it for approval before applying
|
|
55
|
+
- User gives a full set of colors for both light and dark → apply directly
|
|
56
|
+
|
|
57
|
+
## How to apply changes
|
|
58
|
+
|
|
59
|
+
1. **Read** the current `default.css` theme file first
|
|
60
|
+
2. **Locate** the correct theme file:
|
|
61
|
+
- In a scaffolded project: `client/src/styles/themes/default.css`
|
|
62
|
+
- In the template: `template/client/src/styles/themes/default.css`
|
|
63
|
+
- Check which one exists in the current working directory
|
|
64
|
+
3. **Present** your proposed palette to the user in a readable table BEFORE editing:
|
|
65
|
+
|
|
66
|
+
| Token | Current | New (Light) | New (Dark) |
|
|
67
|
+
|-------|---------|-------------|------------|
|
|
68
|
+
| --background | #f4f3ee | #... | #... |
|
|
69
|
+
| --primary | #c15f3c | #... | #... |
|
|
70
|
+
| ... | ... | ... | ... |
|
|
71
|
+
|
|
72
|
+
4. **Wait for user approval** — do not edit until they confirm
|
|
73
|
+
5. **Edit** the file using the Edit tool — only change HEX values
|
|
74
|
+
6. **Update** the comment block at the top to reflect the new palette name/vibe (e.g., "Ocean blue palette" instead of "Claude-inspired warm palette")
|
|
75
|
+
7. **Confirm** what was changed in a brief summary
|
|
76
|
+
|
|
77
|
+
## Palette generation guidelines
|
|
78
|
+
|
|
79
|
+
When generating colors from a brand color, follow these principles:
|
|
80
|
+
|
|
81
|
+
- **Background**: Very desaturated, light tint of the brand hue (light mode) / very dark shade (dark mode)
|
|
82
|
+
- **Foreground**: Near-black with a hint of the brand hue (light mode) / near-white (dark mode)
|
|
83
|
+
- **Primary**: The brand color itself (light) / slightly lighter/more vibrant version (dark)
|
|
84
|
+
- **Primary-foreground**: White or near-white for contrast against primary
|
|
85
|
+
- **Secondary/muted/accent**: Desaturated, low-contrast versions of the brand palette
|
|
86
|
+
- **Card/popover**: White or very slight tint (light) / slightly elevated dark shade (dark)
|
|
87
|
+
- **Border/input**: Very subtle, low-contrast separator colors
|
|
88
|
+
- **Ring**: Same as primary (focus ring should match brand)
|
|
89
|
+
- **Destructive**: Red family (#e7000b light / #ff6467 dark) — adjust warmth/coolness to match palette
|
|
90
|
+
- **Success**: Green family — adjust to match palette temperature
|
|
91
|
+
- **Warning**: Amber/yellow family — adjust to match palette temperature
|
|
92
|
+
- **Info**: Blue family — adjust to match palette temperature
|
|
93
|
+
- **Chart colors**: 5 distinct, harmonious colors for data visualization
|
|
94
|
+
- **Sidebar**: Slightly different shade than main background for visual separation
|
|
95
|
+
|
|
96
|
+
### Dark mode rules
|
|
97
|
+
|
|
98
|
+
- Increase brightness of the primary color (not just invert)
|
|
99
|
+
- Background should be very dark (not pure black) with a hint of the brand hue
|
|
100
|
+
- Borders use `rgba()` for subtle transparency — keep this pattern
|
|
101
|
+
- Foreground colors should be off-white, not pure #ffffff
|
|
102
|
+
- Reduce contrast slightly compared to light mode to reduce eye strain
|
|
103
|
+
|
|
104
|
+
## Edge cases
|
|
105
|
+
|
|
106
|
+
- If user says "make it blue" → ask for a specific shade or suggest 3 options (e.g., ocean #0066cc, royal #4169e1, navy #1a237e)
|
|
107
|
+
- If user provides only RGB or HSL → convert to HEX yourself, don't ask them to convert
|
|
108
|
+
- If user wants to keep some colors and change others → only change the ones they specified
|
|
109
|
+
- If the theme file doesn't exist → tell the user to scaffold the project first, don't create the file
|
|
@@ -11,6 +11,9 @@ const apiOrigin = (() => {
|
|
|
11
11
|
|
|
12
12
|
const nextConfig: NextConfig = {
|
|
13
13
|
output: "standalone",
|
|
14
|
+
experimental: {
|
|
15
|
+
staleTimes: { dynamic: 0, static: 0 },
|
|
16
|
+
},
|
|
14
17
|
async headers() {
|
|
15
18
|
return [
|
|
16
19
|
{
|
|
@@ -26,7 +29,7 @@ const nextConfig: NextConfig = {
|
|
|
26
29
|
"default-src 'self'",
|
|
27
30
|
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
|
28
31
|
"style-src 'self' 'unsafe-inline'",
|
|
29
|
-
|
|
32
|
+
`img-src 'self' blob: data: https: ${apiOrigin}`,
|
|
30
33
|
"font-src 'self'",
|
|
31
34
|
"object-src 'none'",
|
|
32
35
|
"base-uri 'self'",
|
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "{{PROJECT_NAME}}-client",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"scripts": {
|
|
6
|
-
"dev": "next dev",
|
|
7
|
-
"build": "next build",
|
|
8
|
-
"start": "next start",
|
|
9
|
-
"lint": "eslint src/",
|
|
10
|
-
"generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
|
|
11
|
-
},
|
|
12
|
-
"dependencies": {
|
|
13
|
-
"@hookform/resolvers": "^5.2.2",
|
|
14
|
-
"@reduxjs/toolkit": "^2.11.2",
|
|
15
|
-
"@tanstack/react-query": "^5.90.21",
|
|
16
|
-
"axios": "^1.13.5",
|
|
17
|
-
"class-variance-authority": "^0.7.1",
|
|
18
|
-
"clsx": "^2.1.1",
|
|
19
|
-
"lucide-react": "^0.563.0",
|
|
20
|
-
"next": "16.1.6",
|
|
21
|
-
"next-themes": "^0.4.6",
|
|
22
|
-
"radix-ui": "^1.4.3",
|
|
23
|
-
"react": "19.2.3",
|
|
24
|
-
"react-dom": "19.2.3",
|
|
25
|
-
"react-hook-form": "^7.71.1",
|
|
26
|
-
"react-redux": "^9.2.0",
|
|
27
|
-
"sonner": "^2.0.7",
|
|
28
|
-
"tailwind-merge": "^3.4.0",
|
|
29
|
-
"tailwindcss-animate": "^1.0.7",
|
|
30
|
-
"zod": "^4.3.6"
|
|
31
|
-
},
|
|
32
|
-
"overrides": {
|
|
33
|
-
"minimatch": ">=10.2.1"
|
|
34
|
-
},
|
|
35
|
-
"devDependencies": {
|
|
36
|
-
"@tailwindcss/postcss": "^4",
|
|
37
|
-
"@types/node": "^20",
|
|
38
|
-
"@types/react": "^19",
|
|
39
|
-
"@types/react-dom": "^19",
|
|
40
|
-
"eslint": "^9",
|
|
41
|
-
"eslint-config-next": "16.1.6",
|
|
42
|
-
"shadcn": "^3.8.4",
|
|
43
|
-
"tailwindcss": "^4",
|
|
44
|
-
"tw-animate-css": "^1.4.0",
|
|
45
|
-
"typescript": "^5"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "eslint src/",
|
|
10
|
+
"generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@hookform/resolvers": "^5.2.2",
|
|
14
|
+
"@reduxjs/toolkit": "^2.11.2",
|
|
15
|
+
"@tanstack/react-query": "^5.90.21",
|
|
16
|
+
"axios": "^1.13.5",
|
|
17
|
+
"class-variance-authority": "^0.7.1",
|
|
18
|
+
"clsx": "^2.1.1",
|
|
19
|
+
"lucide-react": "^0.563.0",
|
|
20
|
+
"next": "16.1.6",
|
|
21
|
+
"next-themes": "^0.4.6",
|
|
22
|
+
"radix-ui": "^1.4.3",
|
|
23
|
+
"react": "19.2.3",
|
|
24
|
+
"react-dom": "19.2.3",
|
|
25
|
+
"react-hook-form": "^7.71.1",
|
|
26
|
+
"react-redux": "^9.2.0",
|
|
27
|
+
"sonner": "^2.0.7",
|
|
28
|
+
"tailwind-merge": "^3.4.0",
|
|
29
|
+
"tailwindcss-animate": "^1.0.7",
|
|
30
|
+
"zod": "^4.3.6"
|
|
31
|
+
},
|
|
32
|
+
"overrides": {
|
|
33
|
+
"minimatch": ">=10.2.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@tailwindcss/postcss": "^4",
|
|
37
|
+
"@types/node": "^20",
|
|
38
|
+
"@types/react": "^19",
|
|
39
|
+
"@types/react-dom": "^19",
|
|
40
|
+
"eslint": "^9",
|
|
41
|
+
"eslint-config-next": "16.1.6",
|
|
42
|
+
"shadcn": "^3.8.4",
|
|
43
|
+
"tailwindcss": "^4",
|
|
44
|
+
"tw-animate-css": "^1.4.0",
|
|
45
|
+
"typescript": "^5"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -88,8 +88,16 @@
|
|
|
88
88
|
transition-duration: 200ms;
|
|
89
89
|
transition-timing-function: ease-out;
|
|
90
90
|
}
|
|
91
|
+
html {
|
|
92
|
+
@apply bg-background;
|
|
93
|
+
overflow-x: hidden;
|
|
94
|
+
-webkit-text-size-adjust: 100%;
|
|
95
|
+
text-size-adjust: 100%;
|
|
96
|
+
}
|
|
91
97
|
body {
|
|
92
98
|
@apply bg-background text-foreground;
|
|
93
99
|
font-feature-settings: "rlig" 1, "calt" 1;
|
|
100
|
+
overflow-x: hidden;
|
|
101
|
+
touch-action: manipulation;
|
|
94
102
|
}
|
|
95
103
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
1
|
+
import type { Metadata, Viewport } from 'next';
|
|
2
2
|
import type React from 'react';
|
|
3
3
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
|
4
4
|
|
|
@@ -21,6 +21,12 @@ export const metadata: Metadata = {
|
|
|
21
21
|
description: 'A full-stack application built with Next.js and Fastify',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export const viewport: Viewport = {
|
|
25
|
+
width: 'device-width',
|
|
26
|
+
initialScale: 1,
|
|
27
|
+
viewportFit: 'cover',
|
|
28
|
+
};
|
|
29
|
+
|
|
24
30
|
export default function RootLayout({
|
|
25
31
|
children,
|
|
26
32
|
}: Readonly<{
|
|
@@ -17,9 +17,10 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
|
|
|
17
17
|
new QueryClient({
|
|
18
18
|
defaultOptions: {
|
|
19
19
|
queries: {
|
|
20
|
-
staleTime:
|
|
21
|
-
gcTime:
|
|
22
|
-
refetchOnWindowFocus:
|
|
20
|
+
staleTime: 30 * 1000,
|
|
21
|
+
gcTime: 5 * 60 * 1000,
|
|
22
|
+
refetchOnWindowFocus: true,
|
|
23
|
+
refetchOnMount: true,
|
|
23
24
|
retry: 1,
|
|
24
25
|
},
|
|
25
26
|
},
|
|
@@ -29,7 +30,7 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
|
|
|
29
30
|
return (
|
|
30
31
|
<ReduxProvider store={store}>
|
|
31
32
|
<QueryClientProvider client={queryClient}>
|
|
32
|
-
<ThemeProvider attribute="class" defaultTheme="light">
|
|
33
|
+
<ThemeProvider attribute="class" defaultTheme="light" enableColorScheme={false}>
|
|
33
34
|
<AuthInitializer>
|
|
34
35
|
{children}
|
|
35
36
|
</AuthInitializer>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type React from 'react';
|
|
4
|
-
import { useCallback } from 'react';
|
|
4
|
+
import { Suspense, useCallback } from 'react';
|
|
5
5
|
|
|
6
6
|
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
|
|
7
7
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
@@ -13,7 +13,7 @@ interface PaginationProps {
|
|
|
13
13
|
totalPages: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const PaginationInner = ({ page, totalPages }: PaginationProps): React.ReactElement => {
|
|
17
17
|
const router = useRouter();
|
|
18
18
|
const pathname = usePathname();
|
|
19
19
|
const searchParams = useSearchParams();
|
|
@@ -53,3 +53,11 @@ export const Pagination = ({ page, totalPages }: PaginationProps): React.ReactEl
|
|
|
53
53
|
</div>
|
|
54
54
|
);
|
|
55
55
|
};
|
|
56
|
+
|
|
57
|
+
export const Pagination = ({ page, totalPages }: PaginationProps): React.ReactElement => {
|
|
58
|
+
return (
|
|
59
|
+
<Suspense>
|
|
60
|
+
<PaginationInner page={page} totalPages={totalPages} />
|
|
61
|
+
</Suspense>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
4
5
|
import { toast } from 'sonner';
|
|
5
6
|
|
|
6
7
|
import { getErrorMessage } from '@/lib/utils/error';
|
|
@@ -48,6 +49,7 @@ interface UseForceExpireSessionReturn {
|
|
|
48
49
|
|
|
49
50
|
export const useForceExpireSession = (): UseForceExpireSessionReturn => {
|
|
50
51
|
const queryClient = useQueryClient();
|
|
52
|
+
const router = useRouter();
|
|
51
53
|
|
|
52
54
|
const mutation = useMutation({
|
|
53
55
|
mutationFn: (sessionId: string) => adminService.deleteSession(sessionId),
|
|
@@ -55,6 +57,7 @@ export const useForceExpireSession = (): UseForceExpireSessionReturn => {
|
|
|
55
57
|
toast.success('Session expired successfully');
|
|
56
58
|
queryClient.invalidateQueries({ queryKey: adminKeys.sessions() });
|
|
57
59
|
queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
|
60
|
+
router.refresh();
|
|
58
61
|
},
|
|
59
62
|
onError: (error) => {
|
|
60
63
|
toast.error(getErrorMessage(error));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
4
5
|
import { toast } from 'sonner';
|
|
5
6
|
|
|
6
7
|
import { getErrorMessage } from '@/lib/utils/error';
|
|
@@ -81,6 +82,7 @@ interface UseUpdateUserStatusReturn {
|
|
|
81
82
|
|
|
82
83
|
export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
|
|
83
84
|
const queryClient = useQueryClient();
|
|
85
|
+
const router = useRouter();
|
|
84
86
|
|
|
85
87
|
const mutation = useMutation({
|
|
86
88
|
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
|
@@ -90,6 +92,7 @@ export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
|
|
|
90
92
|
toast.success(`User ${action} successfully`);
|
|
91
93
|
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
|
92
94
|
queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
|
95
|
+
router.refresh();
|
|
93
96
|
},
|
|
94
97
|
onError: (error) => {
|
|
95
98
|
toast.error(getErrorMessage(error));
|
|
@@ -111,6 +114,7 @@ interface UseUpdateUserRoleReturn {
|
|
|
111
114
|
|
|
112
115
|
export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
|
|
113
116
|
const queryClient = useQueryClient();
|
|
117
|
+
const router = useRouter();
|
|
114
118
|
|
|
115
119
|
const mutation = useMutation({
|
|
116
120
|
mutationFn: ({ userId, role }: { userId: string; role: 'USER' | 'ADMIN' }) =>
|
|
@@ -119,6 +123,7 @@ export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
|
|
|
119
123
|
toast.success('User role updated successfully');
|
|
120
124
|
queryClient.invalidateQueries({ queryKey: adminKeys.users() });
|
|
121
125
|
queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
|
|
126
|
+
router.refresh();
|
|
122
127
|
},
|
|
123
128
|
onError: (error) => {
|
|
124
129
|
toast.error(getErrorMessage(error));
|
|
@@ -7,11 +7,10 @@ import { usePathname, useRouter } from 'next/navigation';
|
|
|
7
7
|
|
|
8
8
|
import { toast } from 'sonner';
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { useAppSelector } from '@/store/hooks';
|
|
11
11
|
import { ROUTES } from '@/lib/constants/routes';
|
|
12
12
|
import { isErrorCode, ERROR_CODES } from '@/lib/utils/error';
|
|
13
|
-
import {
|
|
14
|
-
import { setUser, setInitialized } from '../store/authSlice';
|
|
13
|
+
import { useCurrentUser } from '../hooks/useCurrentUser';
|
|
15
14
|
|
|
16
15
|
const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE, '/admin'];
|
|
17
16
|
|
|
@@ -32,53 +31,37 @@ function isAuthPage(pathname: string): boolean {
|
|
|
32
31
|
return AUTH_PATHS.some((path) => pathname.startsWith(path));
|
|
33
32
|
}
|
|
34
33
|
|
|
34
|
+
interface HttpLikeError {
|
|
35
|
+
response?: { status?: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
35
38
|
export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
|
|
36
|
-
const dispatch = useAppDispatch();
|
|
37
39
|
const pathname = usePathname();
|
|
38
40
|
const router = useRouter();
|
|
39
|
-
const {
|
|
41
|
+
const { isLoggingOut } = useAppSelector((state) => state.auth);
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
43
|
+
// Skip getMe() on auth pages and during logout.
|
|
44
|
+
// React Query handles deduping, refetch-on-focus, and invalidation —
|
|
45
|
+
// any mutation that touches the current user (profile update, role change,
|
|
46
|
+
// avatar upload, email verification) should call:
|
|
47
|
+
// queryClient.invalidateQueries({ queryKey: authKeys.me() })
|
|
48
|
+
// to refresh Redux state automatically.
|
|
49
|
+
const enabled = !isAuthPage(pathname) && !isLoggingOut;
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
if (!isProtectedPath(pathname)) {
|
|
52
|
-
dispatch(setInitialized());
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
51
|
+
const { error } = useCurrentUser({ enabled });
|
|
55
52
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Only redirect on auth errors (401/403), not network failures
|
|
69
|
-
const status = error?.response?.status;
|
|
70
|
-
if (status === 401 || status === 403) {
|
|
71
|
-
if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
|
|
72
|
-
toast.error('Your account is not yet activated. Please verify your account.');
|
|
73
|
-
}
|
|
74
|
-
router.push(ROUTES.LOGIN);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
return (): void => {
|
|
79
|
-
cancelled = true;
|
|
80
|
-
};
|
|
81
|
-
}, [dispatch, pathname, isAuthenticated, isLoggingOut, router]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!error) return;
|
|
55
|
+
if (!isProtectedPath(pathname)) return;
|
|
56
|
+
|
|
57
|
+
const status = (error as HttpLikeError)?.response?.status;
|
|
58
|
+
if (status === 401 || status === 403) {
|
|
59
|
+
if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
|
|
60
|
+
toast.error('Your account is not yet activated. Please verify your account.');
|
|
61
|
+
}
|
|
62
|
+
router.push(ROUTES.LOGIN);
|
|
63
|
+
}
|
|
64
|
+
}, [error, pathname, router]);
|
|
82
65
|
|
|
83
66
|
return <>{children}</>;
|
|
84
67
|
};
|