@thinhnguyencth1204/nextcli 0.2.1 → 0.4.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.
Files changed (98) hide show
  1. package/README.md +6 -2
  2. package/dist/cli.js +778 -101
  3. package/package.json +2 -1
  4. package/templates/next-base/PROJECT_STRUCTURE.md +88 -0
  5. package/templates/next-base/SETUP.md +86 -0
  6. package/templates/next-base/bun.lock +1443 -0
  7. package/templates/next-base/components.json +21 -0
  8. package/templates/next-base/messages/vi/auth.json +42 -0
  9. package/templates/next-base/messages/vi/common.json +34 -0
  10. package/templates/next-base/messages/vi/example.json +10 -0
  11. package/templates/next-base/next-env.d.ts +3 -1
  12. package/templates/next-base/next.config.ts +11 -1
  13. package/templates/next-base/nextcli.json +8 -0
  14. package/templates/next-base/package.json +21 -1
  15. package/templates/next-base/postcss.config.mjs +5 -0
  16. package/templates/next-base/prisma/migrations/20260612000000_init/migration.sql +104 -0
  17. package/templates/next-base/prisma/migrations/migration_lock.toml +3 -0
  18. package/templates/next-base/prisma/schema.prisma +23 -9
  19. package/templates/next-base/public/logo.svg +4 -0
  20. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  21. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  22. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  23. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  24. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +6 -3
  25. package/templates/next-base/src/app/(dashboard)/account/page.tsx +9 -5
  26. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  27. package/templates/next-base/src/app/(dashboard)/example/page.tsx +5 -2
  28. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  29. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  30. package/templates/next-base/src/app/api/v1/auth/login/route.ts +15 -5
  31. package/templates/next-base/src/app/api/v1/auth/me/route.ts +17 -19
  32. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  33. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  34. package/templates/next-base/src/app/globals.css +111 -0
  35. package/templates/next-base/src/app/layout.tsx +24 -10
  36. package/templates/next-base/src/app/page.tsx +2 -18
  37. package/templates/next-base/src/components/branding/logo.tsx +27 -0
  38. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  39. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  40. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  41. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  42. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  43. package/templates/next-base/src/components/providers/theme-provider.tsx +11 -0
  44. package/templates/next-base/src/components/ui/alert-dialog.tsx +11 -0
  45. package/templates/next-base/src/components/ui/avatar.tsx +45 -0
  46. package/templates/next-base/src/components/ui/badge.tsx +29 -0
  47. package/templates/next-base/src/components/ui/button.tsx +47 -7
  48. package/templates/next-base/src/components/ui/card.tsx +54 -0
  49. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  50. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  51. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  52. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  53. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  54. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  55. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  56. package/templates/next-base/src/components/ui/dialog.tsx +105 -0
  57. package/templates/next-base/src/components/ui/dropdown-menu.tsx +44 -0
  58. package/templates/next-base/src/components/ui/input.tsx +19 -0
  59. package/templates/next-base/src/components/ui/label.tsx +15 -0
  60. package/templates/next-base/src/components/ui/popover.tsx +30 -0
  61. package/templates/next-base/src/components/ui/scroll-area.tsx +47 -0
  62. package/templates/next-base/src/components/ui/select.tsx +76 -0
  63. package/templates/next-base/src/components/ui/separator.tsx +23 -0
  64. package/templates/next-base/src/components/ui/sheet.tsx +117 -0
  65. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  66. package/templates/next-base/src/components/ui/skeleton.tsx +10 -0
  67. package/templates/next-base/src/components/ui/sonner.tsx +3 -0
  68. package/templates/next-base/src/components/ui/table.tsx +54 -0
  69. package/templates/next-base/src/components/ui/tabs.tsx +52 -0
  70. package/templates/next-base/src/components/ui/textarea.tsx +17 -0
  71. package/templates/next-base/src/components/ui/tooltip.tsx +26 -0
  72. package/templates/next-base/src/config/branding.ts +14 -0
  73. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  74. package/templates/next-base/src/example/components/example-table.tsx +25 -40
  75. package/templates/next-base/src/features/auth/components/account-panel.tsx +32 -14
  76. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  77. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +53 -35
  78. package/templates/next-base/src/features/auth/validations.ts +7 -1
  79. package/templates/next-base/src/features/users/services.ts +132 -0
  80. package/templates/next-base/src/features/users/validations.ts +21 -0
  81. package/templates/next-base/src/hooks/index.ts +1 -1
  82. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  83. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  84. package/templates/next-base/src/i18n/config.ts +7 -0
  85. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  86. package/templates/next-base/src/i18n/request.ts +19 -2
  87. package/templates/next-base/src/instrumentation.ts +14 -0
  88. package/templates/next-base/src/lib/auth-client.ts +2 -2
  89. package/templates/next-base/src/lib/auth.ts +2 -2
  90. package/templates/next-base/src/lib/bootstrap.ts +96 -0
  91. package/templates/next-base/src/lib/constants.ts +7 -0
  92. package/templates/next-base/src/lib/prisma.ts +11 -1
  93. package/templates/next-base/src/lib/rbac.ts +62 -0
  94. package/templates/next-base/src/types/data-table.ts +4 -0
  95. package/templates/next-base/src/types/index.ts +2 -0
  96. package/templates/next-base/tsconfig.json +29 -7
  97. package/templates/next-base/middleware.ts +0 -10
  98. package/templates/next-base/src/app/styles.css +0 -12
@@ -1,26 +1,24 @@
1
1
  import { fail, ok } from "@/lib/api-response";
2
-
3
- const authBaseUrl =
4
- process.env.BETTER_AUTH_URL ??
5
- process.env.NEXT_PUBLIC_APP_URL ??
6
- "http://localhost:3000";
2
+ import { getSessionUser } from "@/lib/rbac";
7
3
 
8
4
  export async function GET(request: Request) {
9
- const incomingCookie = request.headers.get("cookie") ?? "";
10
-
11
- const sessionResponse = await fetch(`${authBaseUrl}/api/auth/get-session`, {
12
- method: "GET",
13
- headers: {
14
- cookie: incomingCookie,
15
- },
16
- });
5
+ const user = await getSessionUser(request.headers);
17
6
 
18
- if (!sessionResponse.ok) {
19
- return fail("UNAUTHORIZED", "Unauthorized", {
20
- status: sessionResponse.status,
21
- });
7
+ if (!user) {
8
+ return fail("UNAUTHORIZED", "Unauthorized", { status: 401 });
22
9
  }
23
10
 
24
- const payload = await sessionResponse.json();
25
- return ok(payload);
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
+ });
26
24
  }
@@ -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/rbac";
14
+ import prisma from "@/lib/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/rbac";
8
+ import prisma from "@/lib/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
+ }
@@ -0,0 +1,111 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ /* nextcli:theme:start — edit brand colors below */
5
+ :root {
6
+ --background: oklch(1 0 0);
7
+ --foreground: oklch(0.145 0 0);
8
+ --card: oklch(1 0 0);
9
+ --card-foreground: oklch(0.145 0 0);
10
+ --popover: oklch(1 0 0);
11
+ --popover-foreground: oklch(0.145 0 0);
12
+ --primary: oklch(0.205 0 0);
13
+ --primary-foreground: oklch(0.985 0 0);
14
+ --secondary: oklch(0.97 0 0);
15
+ --secondary-foreground: oklch(0.205 0 0);
16
+ --muted: oklch(0.97 0 0);
17
+ --muted-foreground: oklch(0.556 0 0);
18
+ --accent: oklch(0.97 0 0);
19
+ --accent-foreground: oklch(0.205 0 0);
20
+ --destructive: oklch(0.577 0.245 27.325);
21
+ --destructive-foreground: oklch(0.985 0 0);
22
+ --border: oklch(0.922 0 0);
23
+ --input: oklch(0.922 0 0);
24
+ --ring: oklch(0.708 0 0);
25
+ --radius: 0.625rem;
26
+ --sidebar: oklch(0.985 0 0);
27
+ --sidebar-foreground: oklch(0.145 0 0);
28
+ --sidebar-primary: oklch(0.205 0 0);
29
+ --sidebar-primary-foreground: oklch(0.985 0 0);
30
+ --sidebar-accent: oklch(0.97 0 0);
31
+ --sidebar-accent-foreground: oklch(0.205 0 0);
32
+ --sidebar-border: oklch(0.922 0 0);
33
+ --sidebar-ring: oklch(0.708 0 0);
34
+ }
35
+
36
+ /* nextcli:theme-dark:start */
37
+ .dark {
38
+ --background: oklch(0.145 0 0);
39
+ --foreground: oklch(0.985 0 0);
40
+ --card: oklch(0.205 0 0);
41
+ --card-foreground: oklch(0.985 0 0);
42
+ --popover: oklch(0.205 0 0);
43
+ --popover-foreground: oklch(0.985 0 0);
44
+ --primary: oklch(0.922 0 0);
45
+ --primary-foreground: oklch(0.205 0 0);
46
+ --secondary: oklch(0.269 0 0);
47
+ --secondary-foreground: oklch(0.985 0 0);
48
+ --muted: oklch(0.269 0 0);
49
+ --muted-foreground: oklch(0.708 0 0);
50
+ --accent: oklch(0.269 0 0);
51
+ --accent-foreground: oklch(0.985 0 0);
52
+ --destructive: oklch(0.704 0.191 22.216);
53
+ --destructive-foreground: oklch(0.985 0 0);
54
+ --border: oklch(1 0 0 / 10%);
55
+ --input: oklch(1 0 0 / 15%);
56
+ --ring: oklch(0.556 0 0);
57
+ --sidebar: oklch(0.205 0 0);
58
+ --sidebar-foreground: oklch(0.985 0 0);
59
+ --sidebar-primary: oklch(0.488 0.243 264.376);
60
+ --sidebar-primary-foreground: oklch(0.985 0 0);
61
+ --sidebar-accent: oklch(0.269 0 0);
62
+ --sidebar-accent-foreground: oklch(0.985 0 0);
63
+ --sidebar-border: oklch(1 0 0 / 10%);
64
+ --sidebar-ring: oklch(0.556 0 0);
65
+ }
66
+ /* nextcli:theme-dark:end */
67
+ /* nextcli:theme:end */
68
+
69
+ @theme inline {
70
+ --color-background: var(--background);
71
+ --color-foreground: var(--foreground);
72
+ --color-card: var(--card);
73
+ --color-card-foreground: var(--card-foreground);
74
+ --color-popover: var(--popover);
75
+ --color-popover-foreground: var(--popover-foreground);
76
+ --color-primary: var(--primary);
77
+ --color-primary-foreground: var(--primary-foreground);
78
+ --color-secondary: var(--secondary);
79
+ --color-secondary-foreground: var(--secondary-foreground);
80
+ --color-muted: var(--muted);
81
+ --color-muted-foreground: var(--muted-foreground);
82
+ --color-accent: var(--accent);
83
+ --color-accent-foreground: var(--accent-foreground);
84
+ --color-destructive: var(--destructive);
85
+ --color-destructive-foreground: var(--destructive-foreground);
86
+ --color-border: var(--border);
87
+ --color-input: var(--input);
88
+ --color-ring: var(--ring);
89
+ --color-sidebar: var(--sidebar);
90
+ --color-sidebar-foreground: var(--sidebar-foreground);
91
+ --color-sidebar-primary: var(--sidebar-primary);
92
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
93
+ --color-sidebar-accent: var(--sidebar-accent);
94
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
95
+ --color-sidebar-border: var(--sidebar-border);
96
+ --color-sidebar-ring: var(--sidebar-ring);
97
+ --radius-sm: calc(var(--radius) - 4px);
98
+ --radius-md: calc(var(--radius) - 2px);
99
+ --radius-lg: var(--radius);
100
+ --radius-xl: calc(var(--radius) + 4px);
101
+ }
102
+
103
+ @layer base {
104
+ * {
105
+ @apply border-border outline-ring/50;
106
+ }
107
+
108
+ body {
109
+ @apply bg-background text-foreground antialiased;
110
+ }
111
+ }
@@ -1,27 +1,41 @@
1
1
  import type { Metadata } from "next";
2
- import { Inter } from "next/font/google";
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";
5
7
  import { QueryProvider } from "@/components/providers/query-provider";
6
- import "@/app/styles.css";
8
+ import { ThemeProvider } from "@/components/providers/theme-provider";
9
+ import { branding } from "@/config/branding";
10
+ import "@/app/globals.css";
7
11
 
8
- const inter = Inter({ subsets: ["latin"] });
12
+ const beVietnamPro = Be_Vietnam_Pro({
13
+ subsets: ["latin", "vietnamese"],
14
+ weight: ["400", "500", "600", "700"],
15
+ });
9
16
 
10
17
  export const metadata: Metadata = {
11
- title: "__PROJECT_NAME__",
12
- description: "Outsource-ready Next.js scaffolded by NexTCLI",
18
+ title: branding.projectName,
19
+ description: branding.description,
13
20
  };
14
21
 
15
- export default function RootLayout({
22
+ export default async function RootLayout({
16
23
  children,
17
24
  }: Readonly<{
18
25
  children: ReactNode;
19
26
  }>) {
27
+ const locale = await getLocale();
28
+ const messages = await getMessages();
29
+
20
30
  return (
21
- <html lang="en">
22
- <body className={inter.className}>
23
- <QueryProvider>{children}</QueryProvider>
24
- <Toaster richColors position="top-right" />
31
+ <html lang={locale} suppressHydrationWarning>
32
+ <body className={beVietnamPro.className}>
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>
25
39
  </body>
26
40
  </html>
27
41
  );
@@ -1,21 +1,5 @@
1
- import Link from "next/link";
1
+ import { redirect } from "next/navigation";
2
2
 
3
3
  export default function HomePage() {
4
- return (
5
- <main style={{ padding: 24 }}>
6
- <h1>NexTCLI Base Template</h1>
7
- <p>Ship outsource projects faster with standardized architecture.</p>
8
- <ul style={{ display: "grid", gap: 8 }}>
9
- <li>
10
- <Link href="/example">Go to example dashboard page</Link>
11
- </li>
12
- <li>
13
- <Link href="/sign-in">Go to sign-in page</Link>
14
- </li>
15
- <li>
16
- <Link href="/account">Go to account page</Link>
17
- </li>
18
- </ul>
19
- </main>
20
- );
4
+ redirect("/dashboard");
21
5
  }
@@ -0,0 +1,27 @@
1
+ import Image from "next/image";
2
+ import { branding } from "@/config/branding";
3
+ import { cn } from "@/utils/cn";
4
+
5
+ type LogoProps = {
6
+ className?: string;
7
+ size?: number;
8
+ showLabel?: boolean;
9
+ };
10
+
11
+ export function Logo({ className, size = 28, showLabel = false }: LogoProps) {
12
+ return (
13
+ <span className={cn("inline-flex items-center gap-2", className)}>
14
+ <Image
15
+ src={branding.logoPath}
16
+ alt={branding.projectName}
17
+ width={size}
18
+ height={size}
19
+ className="shrink-0"
20
+ priority
21
+ />
22
+ {showLabel ? (
23
+ <span className="font-semibold">{branding.projectName}</span>
24
+ ) : null}
25
+ </span>
26
+ );
27
+ }
@@ -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
+ }