@thinhnguyencth1204/nextcli 0.8.0 → 0.9.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 +27 -24
- package/dist/cli.js +168 -107
- package/package.json +1 -1
- package/templates/next-base/.env +16 -0
- package/templates/next-base/.env.development +16 -0
- package/templates/next-base/.env.example +16 -0
- package/templates/next-base/SETUP.md +62 -10
- package/templates/next-base/messages/vi/auth.json +42 -0
- package/templates/next-base/messages/vi/common.json +34 -0
- package/templates/next-base/messages/vi/example.json +10 -0
- package/templates/next-base/next.config.ts +4 -1
- package/templates/next-base/nextcli.json +12 -4
- package/templates/next-base/package.json +24 -5
- package/templates/next-base/prisma/schema.prisma +84 -0
- package/templates/next-base/prisma.config.ts +16 -0
- package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
- package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
- package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
- package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
- package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
- package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
- package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
- package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
- package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
- package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
- package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
- package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
- package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
- package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
- package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
- package/templates/next-base/src/app/layout.tsx +14 -6
- package/templates/next-base/src/app/page.tsx +2 -25
- package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
- package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
- package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
- package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
- package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
- package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
- package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
- package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
- package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
- package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
- package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
- package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
- package/templates/next-base/src/data/sidebar-modules.ts +11 -0
- package/templates/next-base/src/example/api/use-example.ts +21 -0
- package/templates/next-base/src/example/api/use-mutations.ts +20 -0
- package/templates/next-base/src/example/components/example-table.tsx +51 -0
- package/templates/next-base/src/example/services.ts +9 -0
- package/templates/next-base/src/example/validations.ts +8 -0
- package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
- package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
- package/templates/next-base/src/features/auth/validations.ts +14 -0
- package/templates/next-base/src/features/users/services.ts +132 -0
- package/templates/next-base/src/features/users/validations.ts +21 -0
- package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
- package/templates/next-base/src/hooks/use-mobile.ts +25 -0
- package/templates/next-base/src/i18n/config.ts +7 -0
- package/templates/next-base/src/i18n/namespaces.ts +5 -0
- package/templates/next-base/src/i18n/request.ts +25 -0
- package/templates/next-base/src/instrumentation.ts +14 -0
- package/templates/next-base/src/lib/api/axios.ts +145 -0
- package/templates/next-base/src/lib/api/response.ts +45 -0
- package/templates/next-base/src/lib/api/token-store.ts +13 -0
- package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
- package/templates/next-base/src/lib/auth/client.ts +7 -0
- package/templates/next-base/src/lib/auth/cookies.ts +15 -0
- package/templates/next-base/src/lib/auth/index.ts +1 -0
- package/templates/next-base/src/lib/auth/rbac.ts +59 -0
- package/templates/next-base/src/lib/auth/server.ts +21 -0
- package/templates/next-base/src/lib/constants.ts +10 -0
- package/templates/next-base/src/lib/db/prisma.ts +23 -0
- package/templates/next-base/src/lib/prisma.ts +23 -0
- package/templates/next-base/src/lib/supabase/client.ts +6 -0
- package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
- package/templates/next-base/src/lib/supabase/storage.ts +164 -0
- package/templates/next-base/src/types/data-table.ts +4 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getRefreshCookieName, refreshCookieOptions } from "@/lib/auth/cookies";
|
|
2
|
+
import { fail, ok } from "@/lib/api/response";
|
|
3
|
+
import prisma from "@/lib/db/prisma";
|
|
4
|
+
|
|
5
|
+
const authBaseUrl =
|
|
6
|
+
process.env.BETTER_AUTH_URL ??
|
|
7
|
+
process.env.NEXT_PUBLIC_APP_URL ??
|
|
8
|
+
"http://localhost:3000";
|
|
9
|
+
|
|
10
|
+
export async function POST(request: Request) {
|
|
11
|
+
const payload = (await request.json().catch(() => ({}))) as {
|
|
12
|
+
username?: string;
|
|
13
|
+
password?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (!payload.username || !payload.password) {
|
|
17
|
+
return fail("BAD_REQUEST", "Username and password are required.", {
|
|
18
|
+
status: 400,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const signInResponse = await fetch(`${authBaseUrl}/api/auth/sign-in/username`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
username: payload.username,
|
|
29
|
+
password: payload.password,
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!signInResponse.ok) {
|
|
34
|
+
return fail("UNAUTHORIZED", "Invalid credentials.", {
|
|
35
|
+
status: signInResponse.status,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cookieHeader = signInResponse.headers.get("set-cookie");
|
|
40
|
+
|
|
41
|
+
const tokenResponse = await fetch(`${authBaseUrl}/api/auth/token`, {
|
|
42
|
+
method: "GET",
|
|
43
|
+
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!tokenResponse.ok) {
|
|
47
|
+
return fail("UPSTREAM_ERROR", "Unable to issue access token.", {
|
|
48
|
+
status: tokenResponse.status,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tokenPayload = await tokenResponse.json();
|
|
53
|
+
|
|
54
|
+
const dbUser = await prisma.user.findUnique({
|
|
55
|
+
where: { username: payload.username },
|
|
56
|
+
select: { requirePasswordChange: true },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const response = ok({
|
|
60
|
+
accessToken: tokenPayload.token,
|
|
61
|
+
requirePasswordChange: dbUser?.requirePasswordChange ?? false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (cookieHeader) {
|
|
65
|
+
response.headers.set("set-cookie", cookieHeader);
|
|
66
|
+
}
|
|
67
|
+
response.cookies.set(getRefreshCookieName(), crypto.randomUUID(), refreshCookieOptions());
|
|
68
|
+
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getRefreshCookieName, refreshCookieOptions } from "@/lib/auth/cookies";
|
|
2
|
+
import { fail, ok } from "@/lib/api/response";
|
|
3
|
+
|
|
4
|
+
const authBaseUrl =
|
|
5
|
+
process.env.BETTER_AUTH_URL ??
|
|
6
|
+
process.env.NEXT_PUBLIC_APP_URL ??
|
|
7
|
+
"http://localhost:3000";
|
|
8
|
+
|
|
9
|
+
export async function POST(request: Request) {
|
|
10
|
+
const incomingCookie = request.headers.get("cookie") ?? "";
|
|
11
|
+
const signOutResponse = await fetch(`${authBaseUrl}/api/auth/sign-out`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
cookie: incomingCookie,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const response = signOutResponse.ok
|
|
19
|
+
? ok({ loggedOut: true })
|
|
20
|
+
: fail("UPSTREAM_ERROR", "Failed to sign out.", {
|
|
21
|
+
status: signOutResponse.status,
|
|
22
|
+
});
|
|
23
|
+
response.cookies.set(getRefreshCookieName(), "", {
|
|
24
|
+
...refreshCookieOptions(),
|
|
25
|
+
maxAge: 0,
|
|
26
|
+
});
|
|
27
|
+
return response;
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { fail, ok } from "@/lib/api/response";
|
|
2
|
+
import { getSessionUser } from "@/lib/auth/rbac";
|
|
3
|
+
|
|
4
|
+
export async function GET(request: Request) {
|
|
5
|
+
const user = await getSessionUser(request.headers);
|
|
6
|
+
|
|
7
|
+
if (!user) {
|
|
8
|
+
return fail("UNAUTHORIZED", "Unauthorized", { status: 401 });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return ok({
|
|
12
|
+
user: {
|
|
13
|
+
id: user.id,
|
|
14
|
+
username: user.username,
|
|
15
|
+
displayUsername: user.displayUsername,
|
|
16
|
+
name: user.name,
|
|
17
|
+
email: user.email,
|
|
18
|
+
requirePasswordChange: user.requirePasswordChange,
|
|
19
|
+
role: user.role
|
|
20
|
+
? { id: user.role.id, name: user.role.name, level: user.role.level }
|
|
21
|
+
: null,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getRefreshCookieName } from "@/lib/auth/cookies";
|
|
2
|
+
import { fail, ok } from "@/lib/api/response";
|
|
3
|
+
|
|
4
|
+
const authBaseUrl =
|
|
5
|
+
process.env.BETTER_AUTH_URL ??
|
|
6
|
+
process.env.NEXT_PUBLIC_APP_URL ??
|
|
7
|
+
"http://localhost:3000";
|
|
8
|
+
|
|
9
|
+
export async function POST(request: Request) {
|
|
10
|
+
const incomingCookie = request.headers.get("cookie") ?? "";
|
|
11
|
+
if (!incomingCookie.includes(getRefreshCookieName())) {
|
|
12
|
+
return fail("UNAUTHORIZED", "Refresh cookie is missing.", { status: 401 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const tokenResponse = await fetch(`${authBaseUrl}/api/auth/token`, {
|
|
16
|
+
method: "GET",
|
|
17
|
+
headers: {
|
|
18
|
+
cookie: incomingCookie,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (!tokenResponse.ok) {
|
|
23
|
+
return fail("UNAUTHORIZED", "Failed to refresh access token.", {
|
|
24
|
+
status: tokenResponse.status,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const tokenPayload = await tokenResponse.json();
|
|
29
|
+
return ok({
|
|
30
|
+
accessToken: tokenPayload.token,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createExampleSchema } from "@/example/validations";
|
|
2
|
+
import { listExamples } from "@/example/services";
|
|
3
|
+
import { fail, ok } from "@/lib/api/response";
|
|
4
|
+
import prisma from "@/lib/db/prisma";
|
|
5
|
+
|
|
6
|
+
export async function GET() {
|
|
7
|
+
try {
|
|
8
|
+
const examples = await listExamples();
|
|
9
|
+
return ok({ items: examples });
|
|
10
|
+
} catch {
|
|
11
|
+
return fail("INTERNAL_ERROR", "Failed to fetch examples.", { status: 500 });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function POST(request: Request) {
|
|
16
|
+
const payload = await request.json().catch(() => null);
|
|
17
|
+
const parsed = createExampleSchema.safeParse(payload);
|
|
18
|
+
|
|
19
|
+
if (!parsed.success) {
|
|
20
|
+
return fail("VALIDATION_ERROR", "Invalid example payload.", {
|
|
21
|
+
status: 400,
|
|
22
|
+
details: parsed.error.flatten(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const created = await prisma.example.create({
|
|
28
|
+
data: parsed.data,
|
|
29
|
+
});
|
|
30
|
+
return ok(created, { status: 201 });
|
|
31
|
+
} catch {
|
|
32
|
+
return fail("INTERNAL_ERROR", "Failed to create example.", { status: 500 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { updateUserSchema } from "@/features/users/validations";
|
|
2
|
+
import {
|
|
3
|
+
deleteUserRecord,
|
|
4
|
+
getUserByIdForActor,
|
|
5
|
+
updateUserRecord,
|
|
6
|
+
} from "@/features/users/services";
|
|
7
|
+
import { fail, ok } from "@/lib/api/response";
|
|
8
|
+
import {
|
|
9
|
+
canActOnUser,
|
|
10
|
+
canAssignRole,
|
|
11
|
+
getSessionUser,
|
|
12
|
+
isSuperAdmin,
|
|
13
|
+
} from "@/lib/auth/rbac";
|
|
14
|
+
import prisma from "@/lib/db/prisma";
|
|
15
|
+
|
|
16
|
+
type RouteContext = {
|
|
17
|
+
params: Promise<{ id: string }>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function GET(request: Request, context: RouteContext) {
|
|
21
|
+
const actor = await getSessionUser(request.headers);
|
|
22
|
+
if (!actor?.role) {
|
|
23
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { id } = await context.params;
|
|
27
|
+
const user = await getUserByIdForActor(id, actor.role.level);
|
|
28
|
+
|
|
29
|
+
if (!user) {
|
|
30
|
+
return fail("NOT_FOUND", "User not found.", { status: 404 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return ok(user);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function PATCH(request: Request, context: RouteContext) {
|
|
37
|
+
const actor = await getSessionUser(request.headers);
|
|
38
|
+
if (!actor?.role) {
|
|
39
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { id } = await context.params;
|
|
43
|
+
const target = await prisma.user.findUnique({
|
|
44
|
+
where: { id },
|
|
45
|
+
include: { role: true },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!target || !canActOnUser(actor, target)) {
|
|
49
|
+
return fail("FORBIDDEN", "Cannot modify this user.", { status: 403 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const payload = await request.json().catch(() => null);
|
|
53
|
+
const parsed = updateUserSchema.safeParse(payload);
|
|
54
|
+
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
return fail("VALIDATION_ERROR", "Invalid user payload.", {
|
|
57
|
+
status: 400,
|
|
58
|
+
details: parsed.error.flatten(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (parsed.data.roleId) {
|
|
63
|
+
const nextRole = await prisma.role.findUnique({
|
|
64
|
+
where: { id: parsed.data.roleId },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!nextRole || !canAssignRole(actor, nextRole)) {
|
|
68
|
+
return fail("FORBIDDEN", "Cannot assign the requested role.", {
|
|
69
|
+
status: 403,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const updated = await updateUserRecord(id, parsed.data);
|
|
75
|
+
if (!updated) {
|
|
76
|
+
return fail("NOT_FOUND", "User not found.", { status: 404 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return ok(updated);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function DELETE(request: Request, context: RouteContext) {
|
|
83
|
+
const actor = await getSessionUser(request.headers);
|
|
84
|
+
if (!actor?.role) {
|
|
85
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { id } = await context.params;
|
|
89
|
+
const target = await prisma.user.findUnique({
|
|
90
|
+
where: { id },
|
|
91
|
+
include: { role: true },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!target) {
|
|
95
|
+
return fail("NOT_FOUND", "User not found.", { status: 404 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isSuperAdmin(target) || !canActOnUser(actor, target)) {
|
|
99
|
+
return fail("FORBIDDEN", "Cannot delete this user.", { status: 403 });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await deleteUserRecord(id);
|
|
103
|
+
return ok({ deleted: true });
|
|
104
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createUserSchema } from "@/features/users/validations";
|
|
2
|
+
import {
|
|
3
|
+
createUserRecord,
|
|
4
|
+
listUsersForActor,
|
|
5
|
+
} from "@/features/users/services";
|
|
6
|
+
import { fail, ok } from "@/lib/api/response";
|
|
7
|
+
import { canAssignRole, getSessionUser } from "@/lib/auth/rbac";
|
|
8
|
+
import prisma from "@/lib/db/prisma";
|
|
9
|
+
|
|
10
|
+
export async function GET(request: Request) {
|
|
11
|
+
const actor = await getSessionUser(request.headers);
|
|
12
|
+
if (!actor?.role) {
|
|
13
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const users = await listUsersForActor(actor.role.level);
|
|
17
|
+
return ok({ items: users });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function POST(request: Request) {
|
|
21
|
+
const actor = await getSessionUser(request.headers);
|
|
22
|
+
if (!actor?.role) {
|
|
23
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const payload = await request.json().catch(() => null);
|
|
27
|
+
const parsed = createUserSchema.safeParse(payload);
|
|
28
|
+
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
return fail("VALIDATION_ERROR", "Invalid user payload.", {
|
|
31
|
+
status: 400,
|
|
32
|
+
details: parsed.error.flatten(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const targetRole = await prisma.role.findUnique({
|
|
37
|
+
where: { id: parsed.data.roleId },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!targetRole || !canAssignRole(actor, targetRole)) {
|
|
41
|
+
return fail("FORBIDDEN", "Cannot assign the requested role.", { status: 403 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const existing = await prisma.user.findUnique({
|
|
45
|
+
where: { username: parsed.data.username },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (existing) {
|
|
49
|
+
return fail("CONFLICT", "Username already exists.", { status: 409 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const created = await createUserRecord(parsed.data);
|
|
54
|
+
return ok(created, { status: 201 });
|
|
55
|
+
} catch {
|
|
56
|
+
return fail("INTERNAL_ERROR", "Failed to create user.", { status: 500 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
import { Be_Vietnam_Pro } from "next/font/google";
|
|
3
|
+
import { getLocale, getMessages } from "next-intl/server";
|
|
4
|
+
import { NextIntlClientProvider } from "next-intl";
|
|
3
5
|
import { Toaster } from "sonner";
|
|
4
6
|
import type { ReactNode } from "react";
|
|
7
|
+
import { QueryProvider } from "@/components/providers/query-provider";
|
|
5
8
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
|
6
9
|
import { branding } from "@/config/branding";
|
|
7
10
|
import "@/app/globals.css";
|
|
@@ -16,18 +19,23 @@ export const metadata: Metadata = {
|
|
|
16
19
|
description: branding.description,
|
|
17
20
|
};
|
|
18
21
|
|
|
19
|
-
export default function RootLayout({
|
|
22
|
+
export default async function RootLayout({
|
|
20
23
|
children,
|
|
21
24
|
}: Readonly<{
|
|
22
25
|
children: ReactNode;
|
|
23
26
|
}>) {
|
|
27
|
+
const locale = await getLocale();
|
|
28
|
+
const messages = await getMessages();
|
|
29
|
+
|
|
24
30
|
return (
|
|
25
|
-
<html lang=
|
|
31
|
+
<html lang={locale} suppressHydrationWarning>
|
|
26
32
|
<body className={beVietnamPro.className}>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
34
|
+
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
|
|
35
|
+
<QueryProvider>{children}</QueryProvider>
|
|
36
|
+
<Toaster richColors position="top-right" />
|
|
37
|
+
</ThemeProvider>
|
|
38
|
+
</NextIntlClientProvider>
|
|
31
39
|
</body>
|
|
32
40
|
</html>
|
|
33
41
|
);
|
|
@@ -1,28 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { branding } from "@/config/branding";
|
|
3
|
-
import { Button } from "@/components/ui/button";
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
4
2
|
|
|
5
3
|
export default function HomePage() {
|
|
6
|
-
|
|
7
|
-
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-8 text-center">
|
|
8
|
-
<h1 className="text-3xl font-semibold tracking-tight">
|
|
9
|
-
{branding.projectName}
|
|
10
|
-
</h1>
|
|
11
|
-
<p className="text-muted-foreground max-w-md text-sm">
|
|
12
|
-
{branding.description}
|
|
13
|
-
</p>
|
|
14
|
-
<p className="text-muted-foreground max-w-lg text-sm">
|
|
15
|
-
This project was scaffolded with NexTCLI. Add optional modules with{" "}
|
|
16
|
-
<code className="bg-muted rounded px-1.5 py-0.5 text-xs">
|
|
17
|
-
nextcli add module
|
|
18
|
-
</code>{" "}
|
|
19
|
-
to enable database, auth, dashboard, and more.
|
|
20
|
-
</p>
|
|
21
|
-
<Button asChild variant="outline">
|
|
22
|
-
<Link href="https://github.com/thinhnguyencth1204/NexTCLI">
|
|
23
|
-
NexTCLI docs
|
|
24
|
-
</Link>
|
|
25
|
-
</Button>
|
|
26
|
-
</main>
|
|
27
|
-
);
|
|
4
|
+
redirect("/dashboard");
|
|
28
5
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import {
|
|
6
|
+
useSidebar,
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarHeader,
|
|
9
|
+
SidebarTrigger,
|
|
10
|
+
} from "@/components/ui/sidebar";
|
|
11
|
+
import { Logo } from "@/components/branding/logo";
|
|
12
|
+
import { NavSidebar } from "@/components/layout/private/nav-sidebar";
|
|
13
|
+
import { cn } from "@/utils/cn";
|
|
14
|
+
|
|
15
|
+
export function AppSidebar() {
|
|
16
|
+
const { state, isMobile } = useSidebar();
|
|
17
|
+
const isCollapsed = state === "collapsed";
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Sidebar collapsible="offcanvas">
|
|
21
|
+
{!isMobile && (
|
|
22
|
+
<div className="absolute -right-4 top-20 z-10">
|
|
23
|
+
<SidebarTrigger
|
|
24
|
+
className={cn(
|
|
25
|
+
"h-8 w-4 rounded-l-none border border-border bg-background p-0 hover:bg-accent",
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
{isCollapsed ? (
|
|
29
|
+
<ChevronRight className="h-4 w-4" />
|
|
30
|
+
) : (
|
|
31
|
+
<ChevronLeft className="h-4 w-4" />
|
|
32
|
+
)}
|
|
33
|
+
</SidebarTrigger>
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
<SidebarHeader className="p-3">
|
|
37
|
+
<Link href="/dashboard">
|
|
38
|
+
<Logo showLabel />
|
|
39
|
+
</Link>
|
|
40
|
+
</SidebarHeader>
|
|
41
|
+
<NavSidebar />
|
|
42
|
+
</Sidebar>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Moon, Sun } from "lucide-react";
|
|
4
|
+
import { useTheme } from "next-themes";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
7
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Logo } from "@/components/branding/logo";
|
|
10
|
+
import { AppSidebar } from "@/components/layout/private/app-sidebar";
|
|
11
|
+
import { NavUser } from "@/components/layout/private/nav-user";
|
|
12
|
+
import { useIsMobile } from "@/hooks/use-mobile";
|
|
13
|
+
|
|
14
|
+
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
15
|
+
const { theme, setTheme } = useTheme();
|
|
16
|
+
const isMobile = useIsMobile();
|
|
17
|
+
const t = useTranslations("common.header");
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<SidebarProvider>
|
|
21
|
+
<div className="flex h-screen w-full flex-col">
|
|
22
|
+
<header className="flex h-14 items-center justify-between border-b bg-card px-4">
|
|
23
|
+
<div className="flex items-center gap-2">
|
|
24
|
+
{isMobile && <SidebarTrigger />}
|
|
25
|
+
<Logo showLabel />
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div className="flex items-center gap-2">
|
|
29
|
+
<Button
|
|
30
|
+
variant="ghost"
|
|
31
|
+
size="icon"
|
|
32
|
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
33
|
+
title={t("toggleTheme")}
|
|
34
|
+
>
|
|
35
|
+
{theme === "dark" ? (
|
|
36
|
+
<Sun className="h-5 w-5" />
|
|
37
|
+
) : (
|
|
38
|
+
<Moon className="h-5 w-5" />
|
|
39
|
+
)}
|
|
40
|
+
</Button>
|
|
41
|
+
<NavUser />
|
|
42
|
+
</div>
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<div className="flex flex-1 overflow-hidden">
|
|
46
|
+
<AppSidebar />
|
|
47
|
+
<ScrollArea className="flex-1">
|
|
48
|
+
<main className="p-4 md:p-6">{children}</main>
|
|
49
|
+
</ScrollArea>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</SidebarProvider>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useLocale, useTranslations } from "next-intl";
|
|
5
|
+
import { locales, type AppLocale } from "@/i18n/config";
|
|
6
|
+
import {
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from "@/components/ui/select";
|
|
13
|
+
|
|
14
|
+
export function LocaleSwitcher() {
|
|
15
|
+
const locale = useLocale() as AppLocale;
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const t = useTranslations("common.locale");
|
|
18
|
+
|
|
19
|
+
if (locales.length <= 1) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const setLocale = (nextLocale: string) => {
|
|
24
|
+
document.cookie = `NEXT_LOCALE=${nextLocale}; path=/; max-age=31536000`;
|
|
25
|
+
router.refresh();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-2">
|
|
30
|
+
<p className="text-xs font-medium text-muted-foreground">{t("label")}</p>
|
|
31
|
+
<Select value={locale} onValueChange={setLocale}>
|
|
32
|
+
<SelectTrigger>
|
|
33
|
+
<SelectValue />
|
|
34
|
+
</SelectTrigger>
|
|
35
|
+
<SelectContent>
|
|
36
|
+
{locales.map((item) => (
|
|
37
|
+
<SelectItem key={item} value={item}>
|
|
38
|
+
{t(item)}
|
|
39
|
+
</SelectItem>
|
|
40
|
+
))}
|
|
41
|
+
</SelectContent>
|
|
42
|
+
</Select>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { LayoutDashboard, Table2, User, type LucideIcon } from "lucide-react";
|
|
7
|
+
import {
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarGroup,
|
|
10
|
+
SidebarGroupContent,
|
|
11
|
+
SidebarGroupLabel,
|
|
12
|
+
SidebarMenu,
|
|
13
|
+
SidebarMenuButton,
|
|
14
|
+
SidebarMenuItem,
|
|
15
|
+
} from "@/components/ui/sidebar";
|
|
16
|
+
import { sidebarModules } from "@/data/sidebar-modules";
|
|
17
|
+
|
|
18
|
+
const icons: Record<string, LucideIcon> = {
|
|
19
|
+
"layout-dashboard": LayoutDashboard,
|
|
20
|
+
table: Table2,
|
|
21
|
+
user: User,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function NavSidebar() {
|
|
25
|
+
const pathname = usePathname();
|
|
26
|
+
const t = useTranslations("common.sidebar");
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<SidebarContent>
|
|
30
|
+
<SidebarGroup>
|
|
31
|
+
<SidebarGroupLabel>{t("groupGeneral")}</SidebarGroupLabel>
|
|
32
|
+
<SidebarGroupContent>
|
|
33
|
+
<SidebarMenu>
|
|
34
|
+
{sidebarModules.map((module) => {
|
|
35
|
+
const Icon = icons[module.icon];
|
|
36
|
+
const isActive =
|
|
37
|
+
pathname === module.url ||
|
|
38
|
+
pathname.startsWith(`${module.url}/`);
|
|
39
|
+
return (
|
|
40
|
+
<SidebarMenuItem key={module.id}>
|
|
41
|
+
<SidebarMenuButton asChild isActive={isActive}>
|
|
42
|
+
<Link href={module.url}>
|
|
43
|
+
<Icon className="h-4 w-4" />
|
|
44
|
+
<span>{t(module.id)}</span>
|
|
45
|
+
</Link>
|
|
46
|
+
</SidebarMenuButton>
|
|
47
|
+
</SidebarMenuItem>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
</SidebarMenu>
|
|
51
|
+
</SidebarGroupContent>
|
|
52
|
+
</SidebarGroup>
|
|
53
|
+
</SidebarContent>
|
|
54
|
+
);
|
|
55
|
+
}
|