@thinhnguyencth1204/nextcli 0.7.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 +37 -24
- package/dist/cli.js +168 -107
- package/package.json +5 -3
- package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
- 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/PROJECT_STRUCTURE.md +29 -18
- package/templates/next-base/SETUP.md +62 -10
- package/templates/next-base/bun.lock +59 -414
- 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-env.d.ts +1 -1
- package/templates/next-base/next.config.ts +4 -1
- package/templates/next-base/nextcli.json +12 -4
- package/templates/next-base/package.json +25 -1
- 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/blog-demo/page.tsx +9 -0
- package/templates/next-base/src/app/globals.css +57 -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/rich-text/adapters/textarea-field.tsx +50 -0
- package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
- package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
- package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
- package/templates/next-base/src/components/rich-text/index.ts +11 -0
- package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
- package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
- package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
- package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
- package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
- package/templates/next-base/src/components/rich-text/types.ts +60 -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/index.ts +1 -1
- 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/rich-text/default-image-removal.ts +10 -0
- package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
- package/templates/next-base/src/lib/rich-text/index.ts +12 -0
- package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
- package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -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
- package/templates/next-base/src/types/index.ts +0 -2
- package/templates/next-base/tsconfig.tsbuildinfo +1 -0
|
@@ -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
|
+
}
|
|
@@ -109,3 +109,60 @@
|
|
|
109
109
|
@apply bg-background text-foreground antialiased;
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
/* Lexical rich text */
|
|
114
|
+
.lexical-content-editable {
|
|
115
|
+
position: relative;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.lexical-paragraph {
|
|
119
|
+
@apply mb-2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.lexical-text-bold {
|
|
123
|
+
@apply font-bold;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.lexical-text-italic {
|
|
127
|
+
@apply italic;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.lexical-text-underline {
|
|
131
|
+
@apply underline;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.lexical-text-strikethrough {
|
|
135
|
+
@apply line-through;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.lexical-text-underline-strikethrough {
|
|
139
|
+
@apply underline line-through;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.lexical-text-code {
|
|
143
|
+
@apply bg-muted rounded px-1 font-mono text-[0.9em];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.lexical-heading-h1 {
|
|
147
|
+
@apply mb-3 text-3xl font-bold;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.lexical-heading-h2 {
|
|
151
|
+
@apply mb-2 text-2xl font-semibold;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.lexical-heading-h3 {
|
|
155
|
+
@apply mb-2 text-xl font-semibold;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.lexical-quote {
|
|
159
|
+
@apply border-muted-foreground mb-2 border-l-4 pl-4 italic;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.lexical-image-element {
|
|
163
|
+
@apply my-2 block;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.lexical-renderer .lexical-content-editable {
|
|
167
|
+
min-height: auto;
|
|
168
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { LogOut, Monitor, Moon, Sun } from "lucide-react";
|
|
4
|
+
import { useTheme } from "next-themes";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { authClient } from "@/lib/auth/client";
|
|
7
|
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
10
|
+
import {
|
|
11
|
+
Sheet,
|
|
12
|
+
SheetContent,
|
|
13
|
+
SheetFooter,
|
|
14
|
+
SheetHeader,
|
|
15
|
+
SheetTitle,
|
|
16
|
+
SheetTrigger,
|
|
17
|
+
} from "@/components/ui/sheet";
|
|
18
|
+
import { LocaleSwitcher } from "@/components/layout/private/locale-switcher";
|
|
19
|
+
|
|
20
|
+
export function NavUser() {
|
|
21
|
+
const { theme, setTheme } = useTheme();
|
|
22
|
+
const t = useTranslations("common");
|
|
23
|
+
|
|
24
|
+
const handleLogout = async () => {
|
|
25
|
+
await authClient.signOut();
|
|
26
|
+
window.location.href = "/sign-in";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Sheet>
|
|
31
|
+
<SheetTrigger asChild>
|
|
32
|
+
<Button variant="ghost" className="h-fit w-fit px-2 py-1">
|
|
33
|
+
<div className="grid text-right text-sm">
|
|
34
|
+
<span className="font-semibold">{t("userMenu.anonymousName")}</span>
|
|
35
|
+
<span className="text-xs text-muted-foreground">
|
|
36
|
+
{t("userMenu.anonymousEmail")}
|
|
37
|
+
</span>
|
|
38
|
+
</div>
|
|
39
|
+
<Avatar className="h-9 w-9">
|
|
40
|
+
<AvatarFallback>U</AvatarFallback>
|
|
41
|
+
</Avatar>
|
|
42
|
+
</Button>
|
|
43
|
+
</SheetTrigger>
|
|
44
|
+
<SheetContent side="right" className="w-full max-w-sm p-0">
|
|
45
|
+
<SheetHeader className="border-b p-4">
|
|
46
|
+
<SheetTitle>{t("userMenu.title")}</SheetTitle>
|
|
47
|
+
</SheetHeader>
|
|
48
|
+
|
|
49
|
+
<ScrollArea className="h-[calc(100vh-10rem)] p-4">
|
|
50
|
+
<div className="space-y-4">
|
|
51
|
+
<div className="rounded-md border bg-card p-4">
|
|
52
|
+
<p className="mb-3 text-sm font-medium">
|
|
53
|
+
{t("userMenu.appearance")}
|
|
54
|
+
</p>
|
|
55
|
+
<div className="grid grid-cols-3 gap-2">
|
|
56
|
+
<Button
|
|
57
|
+
variant={theme === "light" ? "default" : "outline"}
|
|
58
|
+
size="sm"
|
|
59
|
+
onClick={() => setTheme("light")}
|
|
60
|
+
>
|
|
61
|
+
<Sun className="h-4 w-4" />
|
|
62
|
+
{t("header.themeLight")}
|
|
63
|
+
</Button>
|
|
64
|
+
<Button
|
|
65
|
+
variant={theme === "dark" ? "default" : "outline"}
|
|
66
|
+
size="sm"
|
|
67
|
+
onClick={() => setTheme("dark")}
|
|
68
|
+
>
|
|
69
|
+
<Moon className="h-4 w-4" />
|
|
70
|
+
{t("header.themeDark")}
|
|
71
|
+
</Button>
|
|
72
|
+
<Button
|
|
73
|
+
variant={theme === "system" ? "default" : "outline"}
|
|
74
|
+
size="sm"
|
|
75
|
+
onClick={() => setTheme("system")}
|
|
76
|
+
>
|
|
77
|
+
<Monitor className="h-4 w-4" />
|
|
78
|
+
{t("header.themeSystem")}
|
|
79
|
+
</Button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<LocaleSwitcher />
|
|
83
|
+
</div>
|
|
84
|
+
</ScrollArea>
|
|
85
|
+
|
|
86
|
+
<SheetFooter className="border-t p-0">
|
|
87
|
+
<Button
|
|
88
|
+
variant="ghost"
|
|
89
|
+
className="w-full justify-center rounded-none py-6 text-destructive hover:bg-destructive/10"
|
|
90
|
+
onClick={handleLogout}
|
|
91
|
+
>
|
|
92
|
+
<LogOut className="h-4 w-4" />
|
|
93
|
+
{t("userMenu.logout")}
|
|
94
|
+
</Button>
|
|
95
|
+
</SheetFooter>
|
|
96
|
+
</SheetContent>
|
|
97
|
+
</Sheet>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
export function QueryProvider({ children }: { children: ReactNode }) {
|
|
9
|
+
const [queryClient] = useState(() => new QueryClient());
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<QueryClientProvider client={queryClient}>
|
|
13
|
+
{children}
|
|
14
|
+
<ReactQueryDevtools initialIsOpen={false} />
|
|
15
|
+
</QueryClientProvider>
|
|
16
|
+
);
|
|
17
|
+
}
|