create-tigra 3.0.3 → 3.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/README.md +8 -2
- package/bin/create-tigra.js +5 -19
- package/modules/email-verification/client/hooks/useVerification.ts +3 -3
- package/package.json +1 -1
- package/template/.agents/skills/security-audit/AI-AND-LLM.md +83 -0
- package/template/.agents/skills/security-audit/ATTACK-CLASSES.md +130 -0
- package/template/.agents/skills/security-audit/CLIENT-SIDE.md +83 -0
- package/template/.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
- package/template/.agents/skills/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
- package/template/.agents/skills/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
- package/template/.agents/skills/security-audit/HUNTING.md +251 -0
- package/template/.agents/skills/security-audit/LICENSE +21 -0
- package/template/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
- package/template/.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
- package/template/.agents/skills/security-audit/RECONNAISSANCE.md +156 -0
- package/template/.agents/skills/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
- package/template/.agents/skills/security-audit/SKILL.md +192 -0
- package/template/.agents/skills/security-audit/SOURCE.md +5 -0
- package/template/.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
- package/template/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md +186 -0
- package/template/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
- package/template/.agents/skills/security-audit/report-schema.json +461 -0
- package/template/.agents/skills/security-audit/validate-coverage-ledger.cjs +872 -0
- package/template/.agents/skills/security-audit/validate-coverage-ledger.test.cjs +740 -0
- package/template/.agents/skills/security-audit/validate-findings.cjs +773 -0
- package/template/.agents/skills/security-audit/validate-findings.test.cjs +652 -0
- package/template/AGENTS.md +45 -0
- package/template/client/AGENTS.md +22 -0
- package/template/client/Dockerfile +11 -1
- package/template/client/package-lock.json +410 -324
- package/template/client/package.json +4 -4
- package/template/client/scripts/next-dev.cjs +47 -0
- package/template/client/src/app/(auth)/layout.tsx +9 -0
- package/template/client/src/app/(auth)/loading.tsx +7 -0
- package/template/client/src/app/(main)/layout.tsx +11 -0
- package/template/client/src/app/(main)/loading.tsx +7 -0
- package/template/client/src/app/globals.css +4 -0
- package/template/client/src/app/loading.tsx +2 -6
- package/template/client/src/app/not-found.tsx +2 -3
- package/template/client/src/app/providers.tsx +6 -3
- package/template/client/src/components/common/AppLink.tsx +84 -0
- package/template/client/src/components/common/EmptyState.tsx +2 -2
- package/template/client/src/components/common/Pagination.tsx +3 -2
- package/template/client/src/components/common/RouteLoadingShell.tsx +21 -0
- package/template/client/src/components/common/SmoothNavigationProvider.tsx +151 -0
- package/template/client/src/components/layout/Header.tsx +12 -12
- package/template/client/src/features/admin/hooks/useAdminSessions.ts +2 -2
- package/template/client/src/features/admin/hooks/useAdminUsers.ts +3 -3
- package/template/client/src/features/auth/components/AuthInitializer.tsx +3 -2
- package/template/client/src/features/auth/components/LoginForm.tsx +3 -3
- package/template/client/src/features/auth/components/RegisterForm.tsx +3 -3
- package/template/client/src/features/auth/hooks/useAuth.ts +2 -2
- package/template/client/src/features/auth/hooks/usePasswordReset.ts +2 -2
- package/template/client/src/hooks/useAppRouter.ts +40 -0
- package/template/client/src/instrumentation-client.ts +29 -21
- package/template/client/src/instrumentation.ts +21 -17
- package/template/client/src/styles/themes/default.css +1 -1
- package/template/gitignore +0 -6
- package/template/server/AGENTS.md +28 -0
- package/template/server/Dockerfile +9 -3
- package/template/server/package-lock.json +671 -522
- package/template/server/package.json +8 -8
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +25 -41
- package/template/server/src/modules/auth/auth.repo.ts +0 -12
- package/template/server/src/modules/auth/auth.service.ts +29 -57
- package/template/_claude/QUICK_REFERENCE.md +0 -193
- package/template/_claude/README.md +0 -53
- package/template/_claude/commands/create-client.md +0 -878
- package/template/_claude/commands/create-server.md +0 -388
- package/template/_claude/hooks/restrict-paths.sh +0 -51
- package/template/_claude/rules/client/01-project-structure.md +0 -147
- package/template/_claude/rules/client/02-components-and-types.md +0 -146
- package/template/_claude/rules/client/03-data-and-state.md +0 -195
- package/template/_claude/rules/client/04-design-system.md +0 -408
- package/template/_claude/rules/client/05-security.md +0 -55
- package/template/_claude/rules/client/06-ux-checklist.md +0 -111
- package/template/_claude/rules/client/07-deployment.md +0 -99
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +0 -79
- package/template/_claude/rules/client/core.md +0 -46
- package/template/_claude/rules/global/completion-reports.md +0 -178
- package/template/_claude/rules/global/core.md +0 -104
- package/template/_claude/rules/global/investigation-before-conclusions.md +0 -57
- package/template/_claude/rules/server/core.md +0 -52
- package/template/_claude/rules/server/database.md +0 -124
- package/template/_claude/rules/server/deployment.md +0 -78
- package/template/_claude/rules/server/project-conventions.md +0 -254
- package/template/_claude/rules/server/response-handling.md +0 -144
- package/template/_claude/settings.json +0 -15
- package/template/_claude/skills/clean-ui/SKILL.md +0 -63
- package/template/_claude/skills/role/SKILL.md +0 -39
- package/template/_claude/skills/theme/SKILL.md +0 -109
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
|
-
"dev": "next
|
|
6
|
+
"dev": "node ./scripts/next-dev.cjs",
|
|
7
7
|
"build": "next build",
|
|
8
8
|
"start": "next start",
|
|
9
9
|
"lint": "eslint src/",
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"@reduxjs/toolkit": "^2.11.2",
|
|
17
17
|
"@sentry/nextjs": "^10.62.0",
|
|
18
18
|
"@tanstack/react-query": "^5.90.21",
|
|
19
|
-
"axios": "^1.
|
|
19
|
+
"axios": "^1.20.0",
|
|
20
20
|
"class-variance-authority": "^0.7.1",
|
|
21
21
|
"clsx": "^2.1.1",
|
|
22
22
|
"lucide-react": "^0.563.0",
|
|
23
|
-
"next": "16.
|
|
23
|
+
"next": "16.3.5",
|
|
24
24
|
"next-themes": "^0.4.6",
|
|
25
25
|
"radix-ui": "^1.4.3",
|
|
26
26
|
"react": "19.2.3",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@types/react": "^19",
|
|
42
42
|
"@types/react-dom": "^19",
|
|
43
43
|
"eslint": "^9",
|
|
44
|
-
"eslint-config-next": "16.
|
|
44
|
+
"eslint-config-next": "16.3.5",
|
|
45
45
|
"shadcn": "^3.8.4",
|
|
46
46
|
"tailwindcss": "^4",
|
|
47
47
|
"tw-animate-css": "^1.4.0",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawn } = require("node:child_process");
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_OLD_SPACE_SIZE = "768";
|
|
7
|
+
const nextBin = require.resolve("next/dist/bin/next");
|
|
8
|
+
|
|
9
|
+
function applyOldSpaceLimit(nodeOptions, sizeMb) {
|
|
10
|
+
if (/(^|\s)--max[-_]old[-_]space[-_]size(=|\s|$)/.test(nodeOptions)) {
|
|
11
|
+
return nodeOptions;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return [nodeOptions, `--max-old-space-size=${sizeMb}`].filter(Boolean).join(" ").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const env = { ...process.env };
|
|
18
|
+
env.NEXT_DISABLE_MEM_OVERRIDE = "1";
|
|
19
|
+
env.NODE_OPTIONS = applyOldSpaceLimit(
|
|
20
|
+
env.NODE_OPTIONS || "",
|
|
21
|
+
env.NEXT_DEV_MAX_OLD_SPACE_SIZE || DEFAULT_MAX_OLD_SPACE_SIZE,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const args = [
|
|
25
|
+
nextBin,
|
|
26
|
+
"dev",
|
|
27
|
+
"--webpack",
|
|
28
|
+
"--disable-source-maps",
|
|
29
|
+
"--no-server-fast-refresh",
|
|
30
|
+
...process.argv.slice(2),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const child = spawn(process.execPath, args, { stdio: "inherit", env });
|
|
34
|
+
|
|
35
|
+
child.on("error", (error) => {
|
|
36
|
+
console.error(error);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
child.on("exit", (code, signal) => {
|
|
41
|
+
if (signal) {
|
|
42
|
+
process.kill(process.pid, signal);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.exit(code ?? 0);
|
|
47
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import { MainLayout } from '@/components/layout/MainLayout';
|
|
4
|
+
|
|
5
|
+
export default function MainRouteLayout({
|
|
6
|
+
children,
|
|
7
|
+
}: Readonly<{
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}>): React.ReactElement {
|
|
10
|
+
return <MainLayout>{children}</MainLayout>;
|
|
11
|
+
}
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
html {
|
|
92
92
|
@apply bg-background;
|
|
93
93
|
overflow-x: hidden;
|
|
94
|
+
scroll-behavior: smooth;
|
|
94
95
|
-webkit-text-size-adjust: 100%;
|
|
95
96
|
text-size-adjust: 100%;
|
|
96
97
|
}
|
|
@@ -100,4 +101,7 @@
|
|
|
100
101
|
overflow-x: hidden;
|
|
101
102
|
touch-action: manipulation;
|
|
102
103
|
}
|
|
104
|
+
html[data-smooth-navigation="pending"] body {
|
|
105
|
+
min-height: var(--smooth-navigation-min-height);
|
|
106
|
+
}
|
|
103
107
|
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { RouteLoadingShell } from '@/components/common/RouteLoadingShell';
|
|
4
4
|
|
|
5
5
|
export default function Loading(): React.ReactElement {
|
|
6
|
-
return
|
|
7
|
-
<div className="flex min-h-dvh items-center justify-center">
|
|
8
|
-
<LoadingSpinner size="lg" />
|
|
9
|
-
</div>
|
|
10
|
-
);
|
|
6
|
+
return <RouteLoadingShell className="min-h-dvh" />;
|
|
11
7
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
2
|
|
|
3
|
-
import Link from 'next/link';
|
|
4
|
-
|
|
5
3
|
import { FileQuestion } from 'lucide-react';
|
|
6
4
|
|
|
7
5
|
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { AppLink } from '@/components/common/AppLink';
|
|
8
7
|
import { ROUTES } from '@/lib/constants/routes';
|
|
9
8
|
|
|
10
9
|
export default function NotFound(): React.ReactElement {
|
|
@@ -16,7 +15,7 @@ export default function NotFound(): React.ReactElement {
|
|
|
16
15
|
The page you're looking for doesn't exist or has been moved.
|
|
17
16
|
</p>
|
|
18
17
|
<Button asChild>
|
|
19
|
-
<
|
|
18
|
+
<AppLink href={ROUTES.HOME}>Go home</AppLink>
|
|
20
19
|
</Button>
|
|
21
20
|
</div>
|
|
22
21
|
);
|
|
@@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux';
|
|
|
8
8
|
import { ThemeProvider } from 'next-themes';
|
|
9
9
|
import { Toaster } from 'sonner';
|
|
10
10
|
|
|
11
|
+
import { SmoothNavigationProvider } from '@/components/common/SmoothNavigationProvider';
|
|
11
12
|
import { store } from '@/store';
|
|
12
13
|
import { AuthInitializer } from '@/features/auth/components/AuthInitializer';
|
|
13
14
|
|
|
@@ -44,9 +45,11 @@ export function Providers({
|
|
|
44
45
|
enableColorScheme={false}
|
|
45
46
|
nonce={nonce}
|
|
46
47
|
>
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
<SmoothNavigationProvider>
|
|
49
|
+
<AuthInitializer>
|
|
50
|
+
{children}
|
|
51
|
+
</AuthInitializer>
|
|
52
|
+
</SmoothNavigationProvider>
|
|
50
53
|
<Toaster position="top-right" richColors />
|
|
51
54
|
</ThemeProvider>
|
|
52
55
|
</QueryClientProvider>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
import { forwardRef, useRef } from 'react';
|
|
5
|
+
|
|
6
|
+
import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
|
|
7
|
+
|
|
8
|
+
import { useSmoothNavigation } from './SmoothNavigationProvider';
|
|
9
|
+
|
|
10
|
+
type AnchorProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkProps>;
|
|
11
|
+
|
|
12
|
+
export type AppLinkProps = Omit<NextLinkProps, 'scroll'> & AnchorProps;
|
|
13
|
+
|
|
14
|
+
export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(
|
|
15
|
+
({ onClick, onNavigate, ...props }, ref): React.ReactElement => {
|
|
16
|
+
const { startNavigation } = useSmoothNavigation();
|
|
17
|
+
const pendingDestinationRef = useRef<string | null>(null);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<NextLink
|
|
21
|
+
{...props}
|
|
22
|
+
ref={ref}
|
|
23
|
+
scroll={false}
|
|
24
|
+
onClick={(event) => {
|
|
25
|
+
onClick?.(event);
|
|
26
|
+
pendingDestinationRef.current = null;
|
|
27
|
+
if (
|
|
28
|
+
event.defaultPrevented ||
|
|
29
|
+
event.button !== 0 ||
|
|
30
|
+
event.metaKey ||
|
|
31
|
+
event.ctrlKey ||
|
|
32
|
+
event.shiftKey ||
|
|
33
|
+
event.altKey
|
|
34
|
+
) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const anchor = event.currentTarget;
|
|
39
|
+
const destination = new URL(anchor.href);
|
|
40
|
+
const current = new URL(window.location.href);
|
|
41
|
+
const opensElsewhere = Boolean(anchor.target && anchor.target !== '_self');
|
|
42
|
+
const isHashOnly =
|
|
43
|
+
destination.pathname === current.pathname &&
|
|
44
|
+
destination.search === current.search &&
|
|
45
|
+
Boolean(destination.hash);
|
|
46
|
+
|
|
47
|
+
if (
|
|
48
|
+
opensElsewhere ||
|
|
49
|
+
anchor.hasAttribute('download') ||
|
|
50
|
+
destination.origin !== current.origin ||
|
|
51
|
+
isHashOnly
|
|
52
|
+
) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// onNavigate runs after onClick only for client-side navigations. Wait
|
|
57
|
+
// for it so a consumer can still prevent navigation without briefly
|
|
58
|
+
// showing a loader or starting the scroll animation.
|
|
59
|
+
pendingDestinationRef.current = destination.href;
|
|
60
|
+
}}
|
|
61
|
+
onNavigate={(event) => {
|
|
62
|
+
let navigationPrevented = false;
|
|
63
|
+
onNavigate?.({
|
|
64
|
+
preventDefault: () => {
|
|
65
|
+
navigationPrevented = true;
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (navigationPrevented) {
|
|
70
|
+
pendingDestinationRef.current = null;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (pendingDestinationRef.current) {
|
|
75
|
+
startNavigation(pendingDestinationRef.current);
|
|
76
|
+
pendingDestinationRef.current = null;
|
|
77
|
+
}
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
AppLink.displayName = 'AppLink';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
2
|
|
|
3
|
-
import Link from 'next/link';
|
|
4
3
|
import { FileQuestion } from 'lucide-react';
|
|
5
4
|
|
|
6
5
|
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { AppLink } from './AppLink';
|
|
7
7
|
|
|
8
8
|
interface EmptyStateProps {
|
|
9
9
|
title: string;
|
|
@@ -24,7 +24,7 @@ export const EmptyState = ({
|
|
|
24
24
|
<p className="mb-4 text-muted-foreground">{description}</p>
|
|
25
25
|
{actionLabel && actionHref && (
|
|
26
26
|
<Button asChild>
|
|
27
|
-
<
|
|
27
|
+
<AppLink href={actionHref}>{actionLabel}</AppLink>
|
|
28
28
|
</Button>
|
|
29
29
|
)}
|
|
30
30
|
</div>
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import type React from 'react';
|
|
4
4
|
import { Suspense, useCallback } from 'react';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { useSearchParams, usePathname } from 'next/navigation';
|
|
7
7
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
8
8
|
|
|
9
9
|
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { useAppRouter } from '@/hooks/useAppRouter';
|
|
10
11
|
|
|
11
12
|
interface PaginationProps {
|
|
12
13
|
page: number;
|
|
@@ -14,7 +15,7 @@ interface PaginationProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const PaginationInner = ({ page, totalPages }: PaginationProps): React.ReactElement => {
|
|
17
|
-
const router =
|
|
18
|
+
const router = useAppRouter();
|
|
18
19
|
const pathname = usePathname();
|
|
19
20
|
const searchParams = useSearchParams();
|
|
20
21
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
import { LoadingSpinner } from './LoadingSpinner';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared fallback for route transitions that are waiting on server data.
|
|
8
|
+
* Keep this intentionally generic so feature pages can later replace it with
|
|
9
|
+
* a layout-matching skeleton without changing the navigation contract.
|
|
10
|
+
*/
|
|
11
|
+
interface RouteLoadingShellProps {
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const RouteLoadingShell = ({ className }: RouteLoadingShellProps): React.ReactElement => {
|
|
16
|
+
return (
|
|
17
|
+
<div className={cn('flex min-h-[calc(100dvh-8rem)] items-center justify-center p-6', className)}>
|
|
18
|
+
<LoadingSpinner size="lg" />
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type React from 'react';
|
|
4
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
7
|
+
|
|
8
|
+
import { RouteLoadingShell } from './RouteLoadingShell';
|
|
9
|
+
|
|
10
|
+
const MAX_SMOOTH_SCROLL_MS = 1500;
|
|
11
|
+
const NAVIGATION_FAILSAFE_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
interface SmoothNavigationContextValue {
|
|
14
|
+
startNavigation: (destination: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SmoothNavigationContext = createContext<SmoothNavigationContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
interface SmoothNavigationProviderProps {
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SmoothNavigationProvider = ({
|
|
24
|
+
children,
|
|
25
|
+
}: SmoothNavigationProviderProps): React.ReactElement => {
|
|
26
|
+
const pathname = usePathname();
|
|
27
|
+
const searchParams = useSearchParams();
|
|
28
|
+
const [isNavigating, setIsNavigating] = useState(false);
|
|
29
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
30
|
+
const destinationRef = useRef<string | null>(null);
|
|
31
|
+
const originRef = useRef<string | null>(null);
|
|
32
|
+
const navigationTimeoutRef = useRef<number | null>(null);
|
|
33
|
+
const timeoutRef = useRef<number | null>(null);
|
|
34
|
+
|
|
35
|
+
const clearTransitionState = useCallback((): void => {
|
|
36
|
+
if (animationFrameRef.current !== null) {
|
|
37
|
+
window.cancelAnimationFrame(animationFrameRef.current);
|
|
38
|
+
animationFrameRef.current = null;
|
|
39
|
+
}
|
|
40
|
+
if (timeoutRef.current !== null) {
|
|
41
|
+
window.clearTimeout(timeoutRef.current);
|
|
42
|
+
timeoutRef.current = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const root = document.documentElement;
|
|
46
|
+
delete root.dataset.smoothNavigation;
|
|
47
|
+
root.style.removeProperty('--smooth-navigation-min-height');
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const finishNavigation = useCallback((): void => {
|
|
51
|
+
if (navigationTimeoutRef.current !== null) {
|
|
52
|
+
window.clearTimeout(navigationTimeoutRef.current);
|
|
53
|
+
navigationTimeoutRef.current = null;
|
|
54
|
+
}
|
|
55
|
+
destinationRef.current = null;
|
|
56
|
+
originRef.current = null;
|
|
57
|
+
setIsNavigating(false);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const cancelNavigation = useCallback((): void => {
|
|
61
|
+
finishNavigation();
|
|
62
|
+
clearTransitionState();
|
|
63
|
+
}, [clearTransitionState, finishNavigation]);
|
|
64
|
+
|
|
65
|
+
const startNavigation = useCallback((destination: string): void => {
|
|
66
|
+
clearTransitionState();
|
|
67
|
+
|
|
68
|
+
const root = document.documentElement;
|
|
69
|
+
const documentHeight = Math.max(root.scrollHeight, document.body.scrollHeight, root.clientHeight);
|
|
70
|
+
|
|
71
|
+
// The route fallback can be shorter than the outgoing page. Preserve its
|
|
72
|
+
// height until scrolling reaches the top so the browser cannot clamp the
|
|
73
|
+
// scroll position and turn a smooth transition into an instant jump.
|
|
74
|
+
root.dataset.smoothNavigation = 'pending';
|
|
75
|
+
root.style.setProperty('--smooth-navigation-min-height', `${documentHeight}px`);
|
|
76
|
+
|
|
77
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
|
|
78
|
+
|
|
79
|
+
const startedAt = window.performance.now();
|
|
80
|
+
const monitorScroll = (): void => {
|
|
81
|
+
const timedOut = window.performance.now() - startedAt >= MAX_SMOOTH_SCROLL_MS;
|
|
82
|
+
if (Math.abs(window.scrollY) <= 1 || timedOut) {
|
|
83
|
+
clearTransitionState();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
animationFrameRef.current = window.requestAnimationFrame(monitorScroll);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
animationFrameRef.current = window.requestAnimationFrame(monitorScroll);
|
|
90
|
+
timeoutRef.current = window.setTimeout(clearTransitionState, MAX_SMOOTH_SCROLL_MS + 100);
|
|
91
|
+
|
|
92
|
+
const destinationUrl = new URL(destination, window.location.href);
|
|
93
|
+
const destinationKey = `${destinationUrl.pathname}${destinationUrl.search}`;
|
|
94
|
+
const currentKey = `${window.location.pathname}${window.location.search}`;
|
|
95
|
+
if (destinationKey === currentKey) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
destinationRef.current = destinationKey;
|
|
100
|
+
originRef.current = currentKey;
|
|
101
|
+
setIsNavigating(true);
|
|
102
|
+
if (navigationTimeoutRef.current !== null) {
|
|
103
|
+
window.clearTimeout(navigationTimeoutRef.current);
|
|
104
|
+
}
|
|
105
|
+
navigationTimeoutRef.current = window.setTimeout(cancelNavigation, NAVIGATION_FAILSAFE_MS);
|
|
106
|
+
}, [cancelNavigation, clearTransitionState]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const currentKey = `${pathname}${searchParams.size > 0 ? `?${searchParams.toString()}` : ''}`;
|
|
110
|
+
if (
|
|
111
|
+
destinationRef.current === currentKey ||
|
|
112
|
+
(originRef.current !== null && originRef.current !== currentKey)
|
|
113
|
+
) {
|
|
114
|
+
// The new route can commit before the smooth scroll reaches the top.
|
|
115
|
+
// Hide the overlay without releasing the preserved document height. A
|
|
116
|
+
// changed key also handles redirects whose URL differs from the request.
|
|
117
|
+
finishNavigation();
|
|
118
|
+
}
|
|
119
|
+
}, [finishNavigation, pathname, searchParams]);
|
|
120
|
+
|
|
121
|
+
useEffect(
|
|
122
|
+
() => () => {
|
|
123
|
+
if (navigationTimeoutRef.current !== null) {
|
|
124
|
+
window.clearTimeout(navigationTimeoutRef.current);
|
|
125
|
+
}
|
|
126
|
+
clearTransitionState();
|
|
127
|
+
},
|
|
128
|
+
[clearTransitionState]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const value = useMemo(() => ({ startNavigation }), [startNavigation]);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<SmoothNavigationContext.Provider value={value}>
|
|
135
|
+
{children}
|
|
136
|
+
{isNavigating && (
|
|
137
|
+
<div className="fixed inset-0 z-40 overflow-hidden bg-background" aria-busy="true">
|
|
138
|
+
<RouteLoadingShell className="min-h-dvh" />
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</SmoothNavigationContext.Provider>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const useSmoothNavigation = (): SmoothNavigationContextValue => {
|
|
146
|
+
const context = useContext(SmoothNavigationContext);
|
|
147
|
+
if (!context) {
|
|
148
|
+
throw new Error('useSmoothNavigation must be used within SmoothNavigationProvider');
|
|
149
|
+
}
|
|
150
|
+
return context;
|
|
151
|
+
};
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import type React from 'react';
|
|
4
4
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
5
5
|
|
|
6
|
-
import Link from 'next/link';
|
|
7
6
|
import { useTheme } from 'next-themes';
|
|
8
7
|
import { Sun, Moon, Menu, X, LogOut } from 'lucide-react';
|
|
9
8
|
|
|
10
9
|
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { AppLink } from '@/components/common/AppLink';
|
|
11
11
|
import { useAppSelector } from '@/store/hooks';
|
|
12
12
|
import { useAuth } from '@/features/auth/hooks/useAuth';
|
|
13
13
|
import { APP_NAME } from '@/lib/constants/app.constants';
|
|
@@ -50,18 +50,18 @@ export const Header = (): React.ReactElement => {
|
|
|
50
50
|
return (
|
|
51
51
|
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-md">
|
|
52
52
|
<div className="container mx-auto flex h-16 items-center justify-between px-4 md:px-6 lg:px-8">
|
|
53
|
-
<
|
|
53
|
+
<AppLink
|
|
54
54
|
href={ROUTES.HOME}
|
|
55
55
|
className="text-xl font-bold tracking-tight transition-colors duration-150 active:opacity-70 md:hover:text-primary"
|
|
56
56
|
>
|
|
57
57
|
{APP_NAME}
|
|
58
|
-
</
|
|
58
|
+
</AppLink>
|
|
59
59
|
|
|
60
60
|
<nav className="hidden items-center gap-2 md:flex">
|
|
61
61
|
{isAuthenticated ? (
|
|
62
62
|
<>
|
|
63
63
|
<Button variant="ghost" size="sm" asChild>
|
|
64
|
-
<
|
|
64
|
+
<AppLink href={ROUTES.DASHBOARD}>Dashboard</AppLink>
|
|
65
65
|
</Button>
|
|
66
66
|
<span className="text-sm text-muted-foreground">
|
|
67
67
|
{user?.firstName}
|
|
@@ -79,10 +79,10 @@ export const Header = (): React.ReactElement => {
|
|
|
79
79
|
) : (
|
|
80
80
|
<>
|
|
81
81
|
<Button variant="ghost" size="sm" asChild>
|
|
82
|
-
<
|
|
82
|
+
<AppLink href={ROUTES.LOGIN}>Sign in</AppLink>
|
|
83
83
|
</Button>
|
|
84
84
|
<Button size="sm" asChild>
|
|
85
|
-
<
|
|
85
|
+
<AppLink href={ROUTES.REGISTER}>Get started</AppLink>
|
|
86
86
|
</Button>
|
|
87
87
|
</>
|
|
88
88
|
)}
|
|
@@ -134,9 +134,9 @@ export const Header = (): React.ReactElement => {
|
|
|
134
134
|
{isAuthenticated ? (
|
|
135
135
|
<>
|
|
136
136
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
|
137
|
-
<
|
|
137
|
+
<AppLink href={ROUTES.DASHBOARD} onClick={closeMobileMenu}>
|
|
138
138
|
Dashboard
|
|
139
|
-
</
|
|
139
|
+
</AppLink>
|
|
140
140
|
</Button>
|
|
141
141
|
<Button
|
|
142
142
|
variant="ghost"
|
|
@@ -154,14 +154,14 @@ export const Header = (): React.ReactElement => {
|
|
|
154
154
|
) : (
|
|
155
155
|
<>
|
|
156
156
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
|
157
|
-
<
|
|
157
|
+
<AppLink href={ROUTES.LOGIN} onClick={closeMobileMenu}>
|
|
158
158
|
Sign in
|
|
159
|
-
</
|
|
159
|
+
</AppLink>
|
|
160
160
|
</Button>
|
|
161
161
|
<Button className="w-full" asChild>
|
|
162
|
-
<
|
|
162
|
+
<AppLink href={ROUTES.REGISTER} onClick={closeMobileMenu}>
|
|
163
163
|
Get started
|
|
164
|
-
</
|
|
164
|
+
</AppLink>
|
|
165
165
|
</Button>
|
|
166
166
|
</>
|
|
167
167
|
)}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
4
|
import { toast } from 'sonner';
|
|
6
5
|
|
|
6
|
+
import { useAppRouter } from '@/hooks/useAppRouter';
|
|
7
7
|
import { getErrorMessage } from '@/lib/utils/error';
|
|
8
8
|
import { adminService } from '../services/admin.service';
|
|
9
9
|
import { adminKeys } from './useAdminUsers';
|
|
@@ -49,7 +49,7 @@ interface UseForceExpireSessionReturn {
|
|
|
49
49
|
|
|
50
50
|
export const useForceExpireSession = (): UseForceExpireSessionReturn => {
|
|
51
51
|
const queryClient = useQueryClient();
|
|
52
|
-
const router =
|
|
52
|
+
const router = useAppRouter();
|
|
53
53
|
|
|
54
54
|
const mutation = useMutation({
|
|
55
55
|
mutationFn: (sessionId: string) => adminService.deleteSession(sessionId),
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
4
|
import { toast } from 'sonner';
|
|
6
5
|
|
|
6
|
+
import { useAppRouter } from '@/hooks/useAppRouter';
|
|
7
7
|
import { getErrorMessage } from '@/lib/utils/error';
|
|
8
8
|
import { adminService } from '../services/admin.service';
|
|
9
9
|
|
|
@@ -82,7 +82,7 @@ interface UseUpdateUserStatusReturn {
|
|
|
82
82
|
|
|
83
83
|
export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
|
|
84
84
|
const queryClient = useQueryClient();
|
|
85
|
-
const router =
|
|
85
|
+
const router = useAppRouter();
|
|
86
86
|
|
|
87
87
|
const mutation = useMutation({
|
|
88
88
|
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
|
@@ -114,7 +114,7 @@ interface UseUpdateUserRoleReturn {
|
|
|
114
114
|
|
|
115
115
|
export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
|
|
116
116
|
const queryClient = useQueryClient();
|
|
117
|
-
const router =
|
|
117
|
+
const router = useAppRouter();
|
|
118
118
|
|
|
119
119
|
const mutation = useMutation({
|
|
120
120
|
mutationFn: ({ userId, role }: { userId: string; role: 'USER' | 'ADMIN' }) =>
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
import type React from 'react';
|
|
4
4
|
import { useEffect } from 'react';
|
|
5
5
|
|
|
6
|
-
import { usePathname
|
|
6
|
+
import { usePathname } from 'next/navigation';
|
|
7
7
|
|
|
8
8
|
import { toast } from 'sonner';
|
|
9
9
|
|
|
10
10
|
import { useAppSelector } from '@/store/hooks';
|
|
11
|
+
import { useAppRouter } from '@/hooks/useAppRouter';
|
|
11
12
|
import { ROUTES } from '@/lib/constants/routes';
|
|
12
13
|
import { isErrorCode, ERROR_CODES } from '@/lib/utils/error';
|
|
13
14
|
import { useCurrentUser } from '../hooks/useCurrentUser';
|
|
@@ -37,7 +38,7 @@ interface HttpLikeError {
|
|
|
37
38
|
|
|
38
39
|
export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
|
|
39
40
|
const pathname = usePathname();
|
|
40
|
-
const router =
|
|
41
|
+
const router = useAppRouter();
|
|
41
42
|
const { isLoggingOut } = useAppSelector((state) => state.auth);
|
|
42
43
|
|
|
43
44
|
// Skip getMe() on auth pages and during logout.
|