@jhits/dashboard 0.0.1

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 (63) hide show
  1. package/README.md +36 -0
  2. package/next.config.ts +32 -0
  3. package/package.json +79 -0
  4. package/postcss.config.mjs +7 -0
  5. package/src/api/README.md +72 -0
  6. package/src/api/masterRouter.ts +150 -0
  7. package/src/api/pluginRouter.ts +135 -0
  8. package/src/app/[locale]/(auth)/layout.tsx +30 -0
  9. package/src/app/[locale]/(auth)/login/page.tsx +201 -0
  10. package/src/app/[locale]/catch-all/page.tsx +10 -0
  11. package/src/app/[locale]/dashboard/[...pluginRoute]/page.tsx +98 -0
  12. package/src/app/[locale]/dashboard/layout.tsx +42 -0
  13. package/src/app/[locale]/dashboard/page.tsx +121 -0
  14. package/src/app/[locale]/dashboard/preferences/page.tsx +295 -0
  15. package/src/app/[locale]/dashboard/profile/page.tsx +491 -0
  16. package/src/app/[locale]/layout.tsx +28 -0
  17. package/src/app/actions/preferences.ts +40 -0
  18. package/src/app/actions/user.ts +191 -0
  19. package/src/app/api/auth/[...nextauth]/route.ts +6 -0
  20. package/src/app/api/plugin-images/list/route.ts +96 -0
  21. package/src/app/api/plugin-images/upload/route.ts +88 -0
  22. package/src/app/api/telemetry/log/route.ts +10 -0
  23. package/src/app/api/telemetry/route.ts +12 -0
  24. package/src/app/api/uploads/[filename]/route.ts +33 -0
  25. package/src/app/globals.css +181 -0
  26. package/src/app/layout.tsx +4 -0
  27. package/src/assets/locales/en/common.json +47 -0
  28. package/src/assets/locales/nl/common.json +48 -0
  29. package/src/assets/locales/sv/common.json +48 -0
  30. package/src/assets/plugins.json +42 -0
  31. package/src/assets/public/Logo_JH_black.jpg +0 -0
  32. package/src/assets/public/Logo_JH_black.png +0 -0
  33. package/src/assets/public/Logo_JH_white.png +0 -0
  34. package/src/assets/public/animated-logo-white.svg +5 -0
  35. package/src/assets/public/logo_black.svg +5 -0
  36. package/src/assets/public/logo_white.svg +5 -0
  37. package/src/assets/public/noimagefound.jpg +0 -0
  38. package/src/components/DashboardCatchAll.tsx +95 -0
  39. package/src/components/DashboardRootLayout.tsx +37 -0
  40. package/src/components/PluginNotFound.tsx +24 -0
  41. package/src/components/Providers.tsx +59 -0
  42. package/src/components/dashboard/Sidebar.tsx +263 -0
  43. package/src/components/dashboard/Topbar.tsx +363 -0
  44. package/src/components/page.tsx +130 -0
  45. package/src/config.ts +230 -0
  46. package/src/i18n/navigation.ts +7 -0
  47. package/src/i18n/request.ts +41 -0
  48. package/src/i18n/routing.ts +35 -0
  49. package/src/i18n/translations.ts +20 -0
  50. package/src/index.tsx +69 -0
  51. package/src/lib/auth.ts +159 -0
  52. package/src/lib/db.ts +11 -0
  53. package/src/lib/get-website-info.ts +78 -0
  54. package/src/lib/modules-config.ts +68 -0
  55. package/src/lib/mongodb.ts +32 -0
  56. package/src/lib/plugin-registry.tsx +77 -0
  57. package/src/lib/website-context.tsx +39 -0
  58. package/src/proxy.ts +55 -0
  59. package/src/router.tsx +45 -0
  60. package/src/routes.tsx +3 -0
  61. package/src/server.ts +8 -0
  62. package/src/types/plugin.ts +24 -0
  63. package/src/types/preferences.ts +13 -0
@@ -0,0 +1,191 @@
1
+ "use server";
2
+
3
+ import clientPromise from "../../lib/mongodb";
4
+ import { getServerSession } from "next-auth";
5
+ import { revalidatePath } from "next/cache";
6
+ import fs from "fs/promises";
7
+ import path from "path";
8
+ import bcrypt from "bcrypt";
9
+ import { authOptions } from "../../lib/auth";
10
+ import { ObjectId } from "mongodb";
11
+ import { headers } from "next/headers";
12
+
13
+ /**
14
+ * FETCH USER PROFILE
15
+ */
16
+ export async function getUserProfile() {
17
+ const session = await getServerSession();
18
+ if (!session?.user?.email) return null;
19
+
20
+ const client = await clientPromise;
21
+ const db = client.db();
22
+
23
+ const user = await db.collection("users").findOne(
24
+ { email: session.user.email },
25
+ { projection: { password: 0 } }
26
+ );
27
+
28
+ return user ? JSON.parse(JSON.stringify(user)) : null;
29
+ }
30
+
31
+ export async function revokeSession(activityId: string) {
32
+ const session = await getServerSession(authOptions);
33
+ if (!session?.user) throw new Error("Unauthorized");
34
+
35
+ const client = await clientPromise;
36
+ const db = client.db();
37
+
38
+ const result = await db.collection("user_activities").deleteOne({
39
+ _id: new ObjectId(activityId),
40
+ userId: (session.user as { id: string }).id
41
+ });
42
+
43
+ if (result.deletedCount === 0) {
44
+ throw new Error("Could not find session to revoke.");
45
+ }
46
+
47
+ return { success: true };
48
+ }
49
+
50
+ /**
51
+ * Helper to turn ugly User Agent strings into clean names
52
+ */
53
+ function parseUA(ua: string): string {
54
+ if (ua.includes("Firefox")) return "Firefox";
55
+ if (ua.includes("Edg")) return "Edge";
56
+ // Brave often hides inside the Chrome string, so we check for both
57
+ if (ua.includes("Chrome")) {
58
+ if (ua.includes("Brave") || ua.includes("brave")) return "Brave";
59
+ return "Chrome";
60
+ }
61
+ if (ua.includes("Safari") && !ua.includes("Chrome")) return "Safari";
62
+ return "Unknown Browser";
63
+ }
64
+
65
+ export async function getUserSessions() {
66
+ const session = await getServerSession(authOptions);
67
+ const headersList = await headers();
68
+ const currentUa = headersList.get("user-agent") || "";
69
+
70
+ if (!session?.user) return [];
71
+
72
+ const client = await clientPromise;
73
+ const db = client.db();
74
+
75
+ const activities = await db.collection("user_activities")
76
+ .find({ userId: (session.user as { id: string }).id })
77
+ .sort({ lastActive: -1 })
78
+ .toArray();
79
+
80
+ return activities.map(a => ({
81
+ id: a._id.toString(),
82
+ device: a.userAgent.includes("Mobi") ? "Mobile Device" : "Desktop Computer",
83
+ browser: parseUA(a.userAgent), // Now this will work!
84
+ location: a.ip || "Unknown IP",
85
+ isCurrent: a.userAgent === currentUa,
86
+ lastActive: "Active Now"
87
+ }));
88
+ }
89
+
90
+ /**
91
+ * UPDATE TEXT FIELDS (Name & Email)
92
+ */
93
+ export async function updateProfileFields(name: string, email: string) {
94
+ const session = await getServerSession();
95
+ const currentEmail = session?.user?.email;
96
+ if (!currentEmail) throw new Error("Unauthorized");
97
+
98
+ const client = await clientPromise;
99
+ const db = client.db();
100
+
101
+ // Check if new email is already taken by someone else
102
+ if (email !== currentEmail) {
103
+ const existingUser = await db.collection("users").findOne({ email });
104
+ if (existingUser) throw new Error("Email already in use");
105
+ }
106
+
107
+ await db.collection("users").updateOne(
108
+ { email: currentEmail },
109
+ { $set: { name, email, updatedAt: new Date() } }
110
+ );
111
+
112
+ revalidatePath("/profile");
113
+ return { success: true, name, email };
114
+ }
115
+
116
+ /**
117
+ * UPDATE OR DELETE PROFILE IMAGE
118
+ * Handles physical file cleanup on the Raspberry Pi
119
+ */
120
+ export async function updateProfileImage(base64Image: string | null) {
121
+ const session = await getServerSession();
122
+ const currentEmail = session?.user?.email;
123
+ if (!currentEmail) throw new Error("Unauthorized");
124
+
125
+ const client = await clientPromise;
126
+ const db = client.db();
127
+
128
+ // 1. Get current user for cleanup
129
+ const user = await db.collection("users").findOne({ email: currentEmail });
130
+ const oldImagePath = user?.image;
131
+ let newPath: string | null = oldImagePath;
132
+
133
+ const uploadDir = path.join(process.cwd(), "data/uploads");
134
+
135
+ // 2. Process New Upload
136
+ if (base64Image && base64Image.startsWith('data:image')) {
137
+ await fs.mkdir(uploadDir, { recursive: true });
138
+ const base64Data = base64Image.replace(/^data:image\/\w+;base64,/, "");
139
+ const filename = `avatar-${Date.now()}.jpg`;
140
+ await fs.writeFile(path.join(uploadDir, filename), Buffer.from(base64Data, 'base64'));
141
+ newPath = `/api/uploads/${filename}`;
142
+ }
143
+ // 3. Handle Deletion
144
+ else if (base64Image === null) {
145
+ newPath = null;
146
+ }
147
+
148
+ // 4. Physical Cleanup (Delete old file if path changed or removed)
149
+ if (oldImagePath && oldImagePath !== newPath && oldImagePath.startsWith('/api/uploads/')) {
150
+ const oldFile = oldImagePath.split('/').pop();
151
+ if (oldFile) {
152
+ try { await fs.unlink(path.join(uploadDir, oldFile)); } catch (e) { }
153
+ }
154
+ }
155
+
156
+ // 5. Update DB
157
+ await db.collection("users").updateOne(
158
+ { email: currentEmail },
159
+ { $set: { image: newPath, updatedAt: new Date() } }
160
+ );
161
+
162
+ revalidatePath("/profile");
163
+ return { success: true, image: newPath };
164
+ };
165
+
166
+ export async function changePassword(currentPassword: string, newPassword: string) {
167
+ const session = await getServerSession();
168
+ if (!session?.user?.email) throw new Error("Unauthorized");
169
+
170
+ const client = await clientPromise;
171
+ const db = client.db();
172
+
173
+ // 1. Find user to get the current hashed password
174
+ const user = await db.collection("users").findOne({ email: session.user.email });
175
+ if (!user) throw new Error("User not found");
176
+
177
+ // 2. Verify current password
178
+ const isMatch = await bcrypt.compare(currentPassword, user.password);
179
+ if (!isMatch) throw new Error("Current password is incorrect");
180
+
181
+ // 3. Hash the new password
182
+ const hashedNewPassword = await bcrypt.hash(newPassword, 12);
183
+
184
+ // 4. Update the database
185
+ await db.collection("users").updateOne(
186
+ { email: session.user.email },
187
+ { $set: { password: hashedNewPassword, updatedAt: new Date() } }
188
+ );
189
+
190
+ return { success: true };
191
+ }
@@ -0,0 +1,6 @@
1
+ import NextAuth from "next-auth";
2
+ import { authOptions } from "../../../../lib/auth";
3
+
4
+ const handler = NextAuth(authOptions);
5
+
6
+ export { handler as GET, handler as POST };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Image List API Route
3
+ * Returns paginated list of uploaded images for plugin-images
4
+ */
5
+
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+ import { readdir, stat } from 'fs/promises';
8
+ import path from 'path';
9
+
10
+ const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
11
+
12
+ export async function GET(request: NextRequest) {
13
+ try {
14
+ const { searchParams } = new URL(request.url);
15
+ const page = parseInt(searchParams.get('page') || '1');
16
+ const limit = parseInt(searchParams.get('limit') || '20');
17
+ const search = searchParams.get('search') || '';
18
+
19
+ // Read uploads directory
20
+ let files: string[] = [];
21
+ try {
22
+ files = await readdir(uploadsDir);
23
+ } catch (error) {
24
+ // Directory doesn't exist yet
25
+ return NextResponse.json({
26
+ images: [],
27
+ total: 0,
28
+ page: 1,
29
+ limit,
30
+ });
31
+ }
32
+
33
+ // Filter image files and get metadata
34
+ const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
35
+ const imageFiles = files.filter(file => {
36
+ const ext = path.extname(file).toLowerCase();
37
+ return imageExtensions.includes(ext);
38
+ });
39
+
40
+ // Apply search filter if provided
41
+ const filteredFiles = search
42
+ ? imageFiles.filter(file => file.toLowerCase().includes(search.toLowerCase()))
43
+ : imageFiles;
44
+
45
+ // Sort by modification time (newest first)
46
+ const filesWithStats = await Promise.all(
47
+ filteredFiles.map(async (filename) => {
48
+ const filePath = path.join(uploadsDir, filename);
49
+ const stats = await stat(filePath);
50
+ return {
51
+ filename,
52
+ mtime: stats.mtime,
53
+ size: stats.size,
54
+ };
55
+ })
56
+ );
57
+
58
+ filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
59
+
60
+ // Paginate
61
+ const start = (page - 1) * limit;
62
+ const end = start + limit;
63
+ const paginatedFiles = filesWithStats.slice(start, end);
64
+
65
+ // Build image metadata
66
+ const images = paginatedFiles.map(({ filename, mtime, size }) => {
67
+ const ext = path.extname(filename).toLowerCase();
68
+ const mimeType = ext === '.png' ? 'image/png' :
69
+ ext === '.webp' ? 'image/webp' :
70
+ ext === '.gif' ? 'image/gif' : 'image/jpeg';
71
+
72
+ return {
73
+ id: filename,
74
+ filename,
75
+ url: `/api/uploads/${filename}`,
76
+ size,
77
+ mimeType,
78
+ uploadedAt: mtime.toISOString(),
79
+ };
80
+ });
81
+
82
+ return NextResponse.json({
83
+ images,
84
+ total: filteredFiles.length,
85
+ page,
86
+ limit,
87
+ });
88
+ } catch (error) {
89
+ console.error('List images error:', error);
90
+ return NextResponse.json(
91
+ { error: 'Failed to list images' },
92
+ { status: 500 }
93
+ );
94
+ }
95
+ }
96
+
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Image Upload API Route
3
+ * Handles image file uploads for plugin-images
4
+ */
5
+
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+ import { writeFile, mkdir } from 'fs/promises';
8
+ import path from 'path';
9
+ import { randomBytes } from 'crypto';
10
+
11
+ // Ensure uploads directory exists
12
+ const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
13
+
14
+ async function ensureUploadsDir() {
15
+ try {
16
+ await mkdir(uploadsDir, { recursive: true });
17
+ } catch (error) {
18
+ // Directory might already exist
19
+ }
20
+ }
21
+
22
+ export async function POST(request: NextRequest) {
23
+ try {
24
+ await ensureUploadsDir();
25
+
26
+ const formData = await request.formData();
27
+ const file = formData.get('file') as File;
28
+
29
+ if (!file) {
30
+ return NextResponse.json(
31
+ { success: false, error: 'No file provided' },
32
+ { status: 400 }
33
+ );
34
+ }
35
+
36
+ // Validate file type
37
+ const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
38
+ if (!allowedTypes.includes(file.type)) {
39
+ return NextResponse.json(
40
+ { success: false, error: 'Invalid file type. Only images are allowed.' },
41
+ { status: 400 }
42
+ );
43
+ }
44
+
45
+ // Validate file size (max 10MB)
46
+ const maxSize = 10 * 1024 * 1024; // 10MB
47
+ if (file.size > maxSize) {
48
+ return NextResponse.json(
49
+ { success: false, error: 'File size exceeds 10MB limit' },
50
+ { status: 400 }
51
+ );
52
+ }
53
+
54
+ // Generate unique filename
55
+ const ext = path.extname(file.name);
56
+ const uniqueId = randomBytes(16).toString('hex');
57
+ const timestamp = Date.now();
58
+ const uniqueFilename = `${timestamp}-${uniqueId}${ext}`;
59
+ const filePath = path.join(uploadsDir, uniqueFilename);
60
+
61
+ // Save file
62
+ const bytes = await file.arrayBuffer();
63
+ const buffer = Buffer.from(bytes);
64
+ await writeFile(filePath, buffer);
65
+
66
+ // Get image dimensions (basic - could use sharp for better handling)
67
+ const imageMetadata = {
68
+ id: uniqueFilename,
69
+ filename: file.name,
70
+ url: `/api/uploads/${uniqueFilename}`,
71
+ size: file.size,
72
+ mimeType: file.type,
73
+ uploadedAt: new Date().toISOString(),
74
+ };
75
+
76
+ return NextResponse.json({
77
+ success: true,
78
+ image: imageMetadata,
79
+ });
80
+ } catch (error) {
81
+ console.error('Upload error:', error);
82
+ return NextResponse.json(
83
+ { success: false, error: 'Failed to upload image' },
84
+ { status: 500 }
85
+ );
86
+ }
87
+ }
88
+
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Telemetry API Route (Legacy)
3
+ * This route is kept for backward compatibility but redirects to /api/telemetry
4
+ * Plugin-mounted API route using the telemetry handler
5
+ */
6
+ import { POST } from '@jhits/plugin-telemetry/api/route';
7
+
8
+ // Re-export the POST handler from the plugin
9
+ export { POST };
10
+
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Telemetry API Route
3
+ * Plugin-mounted API route using the telemetry handler
4
+ * Mounted at /api/telemetry
5
+ *
6
+ * IMPORTANT: This route file is server-only and should never be imported in client code.
7
+ */
8
+ import { POST } from '@jhits/plugin-telemetry/api/route';
9
+
10
+ // Re-export the POST handler from the plugin
11
+ export { POST };
12
+
@@ -0,0 +1,33 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+
5
+ export async function GET(
6
+ request: NextRequest,
7
+ { params }: { params: Promise<{ filename: string }> } // 1. Define params as a Promise
8
+ ) {
9
+ // 2. Await the params to unwrap them
10
+ const { filename } = await params;
11
+
12
+ // 3. Security: Prevent directory traversal (only allow the filename)
13
+ const sanitizedFilename = path.basename(filename);
14
+ const filePath = path.join(process.cwd(), "data/uploads", sanitizedFilename);
15
+
16
+ try {
17
+ const fileBuffer = await fs.readFile(filePath);
18
+
19
+ // Determine content type based on extension
20
+ const ext = path.extname(sanitizedFilename).toLowerCase();
21
+ const contentType = ext === '.png' ? 'image/png' : 'image/jpeg';
22
+
23
+ return new NextResponse(fileBuffer, {
24
+ headers: {
25
+ 'Content-Type': contentType,
26
+ 'Cache-Control': 'public, max-age=31536000, immutable',
27
+ },
28
+ });
29
+ } catch (e) {
30
+ console.error("File serving error:", e);
31
+ return new NextResponse("File not found", { status: 404 });
32
+ }
33
+ }
@@ -0,0 +1,181 @@
1
+ @import "tailwindcss";
2
+ @source "../../../**/*.{ts,tsx}";
3
+
4
+ /* Dashboard-specific styles with higher specificity to override client app styles */
5
+ /* These styles apply globally when dashboard CSS is loaded */
6
+ .dashboard-html,
7
+ .dashboard-body,
8
+ [data-dashboard="true"],
9
+ [data-dashboard="true"] * {
10
+ /* Ensure dashboard styles take precedence */
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ @font-face {
15
+ font-family: "PP Neue Corp Tight";
16
+ src: url("https://cdn.prod.website-files.com/673af51dea86ab95d124c3ee/673b0f5784f7060c0ac05534_PPNeueCorp-TightUltrabold.otf") format("opentype");
17
+ font-weight: 700;
18
+ font-style: normal;
19
+ font-display: swap;
20
+ }
21
+
22
+ @theme {
23
+ --font-pp: "PP Neue Corp Tight";
24
+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
25
+
26
+ /* REMOVE QUOTES AROUND VALUES HERE */
27
+ --breakpoint-xs: 420px;
28
+
29
+ --color-primary: var(--color-violet-700);
30
+ --color-secondary: var(--color-amber-600);
31
+ --color-tertiary: var(--color-slate-500);
32
+
33
+ /* Your Brand Palette */
34
+ --color-neutral-100: oklch(100% 0 0);
35
+ --color-neutral-200: oklch(94.93% 0.0029 84.56);
36
+ --color-neutral-300: oklch(91.05% 0.0046 78.3);
37
+ --color-neutral-400: oklch(83.43% 0.0053 67.75);
38
+ --color-neutral-500: oklch(60.29% 0.0015 106.44);
39
+ --color-neutral-600: oklch(29.31% 0 0);
40
+ --color-neutral-700: oklch(23.93% 0 0);
41
+ --color-neutral-800: oklch(18.67% 0 0);
42
+ --color-neutral-900: oklch(10.67% 0 0);
43
+
44
+ /* Dashboard Mapping */
45
+ --color-background: var(--background);
46
+ --color-foreground: var(--foreground);
47
+ --color-card: var(--card);
48
+ --color-border: var(--border);
49
+
50
+ /* Dashboard Semantic Variables */
51
+ --color-dashboard-bg: var(--background);
52
+ --color-dashboard-card: var(--card);
53
+ --color-dashboard-text: var(--foreground);
54
+ --color-dashboard-sidebar: var(--card);
55
+ --color-dashboard-border: var(--border);
56
+ }
57
+
58
+ :root {
59
+ /* Light Mode: Soft warm off-white base with deep charcoal text */
60
+ --background: var(--color-neutral-50);
61
+ --foreground: var(--color-neutral-800);
62
+ --card: var(--color-neutral-50);
63
+ --border: var(--color-neutral-200);
64
+ /* Dashboard sidebar uses card color in light mode */
65
+ --color-dashboard-sidebar: var(--card);
66
+ }
67
+
68
+ /* Ensure this is exactly as Tailwind v4 expects for dark mode */
69
+ @variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
70
+
71
+ [data-theme="dark"] {
72
+ color-scheme: dark;
73
+ /* Dark Mode: Deep midnight base with neutral-800 for cards/sidebars */
74
+ --background: var(--color-neutral-950);
75
+ --foreground: var(--color-neutral-100);
76
+ --card: var(--color-neutral-800);
77
+ --border: var(--color-neutral-700);
78
+ /* Dashboard sidebar uses card color in dark mode for depth */
79
+ --color-dashboard-sidebar: var(--card);
80
+ }
81
+
82
+ /* --- Utilities --- */
83
+ @utility scrollbar-hidden {
84
+ scrollbar-width: none;
85
+ -ms-overflow-style: none;
86
+
87
+ &::-webkit-scrollbar {
88
+ display: none;
89
+ }
90
+ }
91
+
92
+ .custom-scrollbar::-webkit-scrollbar {
93
+ width: 4px;
94
+ }
95
+
96
+ .custom-scrollbar::-webkit-scrollbar-thumb {
97
+ background: rgba(155, 155, 155, 0.2);
98
+ border-radius: 10px;
99
+ }
100
+
101
+ /* Fixing the container logic that caused the crash */
102
+ @layer base {
103
+ /* Dashboard-specific container - scoped to dashboard */
104
+ [data-dashboard="true"] .container {
105
+ width: 100%;
106
+ margin-left: auto;
107
+ margin-right: auto;
108
+ }
109
+
110
+ @media (min-width: 420px) {
111
+ [data-dashboard="true"] .container {
112
+ max-width: 420px;
113
+ }
114
+ }
115
+
116
+ /* Dashboard body styles - scoped to dashboard container */
117
+ [data-dashboard="true"] {
118
+ @apply bg-background text-foreground antialiased;
119
+ font-feature-settings: "ss01", "ss02", "cv01", "cv02";
120
+ }
121
+
122
+ /* Auth pages (login) should also use dashboard fonts */
123
+ .font-sans {
124
+ font-family: var(--font-sans);
125
+ }
126
+
127
+ .font-pp {
128
+ font-family: var(--font-pp);
129
+ }
130
+ }
131
+
132
+ /* Dashboard-specific utility classes with higher specificity */
133
+ @layer utilities {
134
+ [data-dashboard="true"] .scrollbar-hidden {
135
+ scrollbar-width: none;
136
+ -ms-overflow-style: none;
137
+ }
138
+
139
+ [data-dashboard="true"] .scrollbar-hidden::-webkit-scrollbar {
140
+ display: none;
141
+ }
142
+
143
+ [data-dashboard="true"] .custom-scrollbar::-webkit-scrollbar {
144
+ width: 4px;
145
+ }
146
+
147
+ [data-dashboard="true"] .custom-scrollbar::-webkit-scrollbar-thumb {
148
+ background: rgba(155, 155, 155, 0.2);
149
+ border-radius: 10px;
150
+ }
151
+ }
152
+
153
+ /* Standardize theme transition duration across the entire dashboard */
154
+ @layer base {
155
+ /* Apply consistent 300ms transition to all theme-related color changes */
156
+ /* This ensures smooth, synchronized theme switching */
157
+ /* Only target color-related properties to avoid interfering with layout animations */
158
+ [data-dashboard="true"],
159
+ [data-dashboard="true"] *,
160
+ [data-dashboard="true"] *::before,
161
+ [data-dashboard="true"] *::after {
162
+ /* Set default transition for color properties only */
163
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
164
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
165
+ transition-duration: 300ms;
166
+ }
167
+
168
+ /* Force all transition-colors to use 300ms for theme consistency */
169
+ [data-dashboard="true"] [class*="transition-colors"] {
170
+ transition-duration: 300ms !important;
171
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
172
+ }
173
+
174
+ /* For transition-all: ensure consistent 300ms for theme synchronization */
175
+ /* When theme changes, all properties should transition together at the same speed */
176
+ /* This ensures visual consistency during theme switching */
177
+ [data-dashboard="true"] [class*="transition-all"] {
178
+ transition-duration: 300ms;
179
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
180
+ }
181
+ }
@@ -0,0 +1,4 @@
1
+ // This file just passes through to the [locale] layout
2
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
3
+ return children;
4
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "sidebar": {
3
+ "users": "Users",
4
+ "website": "Site",
5
+ "blog": "Blog",
6
+ "newsletter": "Newsletter",
7
+ "stats": "Analytics",
8
+ "logout": "Logout",
9
+ "logging_out": "Logging out..."
10
+ },
11
+ "dashboard": {
12
+ "status": "PLATFORM ACTIVE",
13
+ "welcome": "Welcome back, {name}",
14
+ "description": "Your modular ecosystem is ready. Monitor your stats, update your content, or manage your newsletters from one place.",
15
+ "your_modules": "Installed Modules",
16
+ "open_module": "Launch Module",
17
+ "module_desc": {
18
+ "users": "Manage user accounts, roles, and permissions.",
19
+ "website": "Configure site settings, pages, and navigation.",
20
+ "stats": "View real-time traffic and engagement data.",
21
+ "blog": "Publish and edit your latest articles.",
22
+ "newsletter": "Manage your subscribers and campaigns."
23
+ }
24
+ },
25
+ "profile": {
26
+ "personal_information": "Personal Information",
27
+ "full_name": "Full Name",
28
+ "email_address": "Email Address",
29
+ "save_changes": "Save Changes",
30
+ "updating": "Updating",
31
+ "verified_account": "Verified Account",
32
+ "login_activity": "Login Activity",
33
+ "current": "Current",
34
+ "revoke": "Revoke",
35
+ "revoking": "Revoking...",
36
+ "security_access": "Security & Access",
37
+ "change_password": "Change Password",
38
+ "two_factor_auth": "Two-Factor Auth",
39
+ "billing_details": "Billing Details",
40
+ "security_update": "Security Update",
41
+ "new_password": "New Password",
42
+ "confirm_new_password": "Confirm New Password",
43
+ "update_password": "Update Password",
44
+ "updating_password": "Updating...",
45
+ "terminate_session_confirm": "Are you sure you want to terminate this session?"
46
+ }
47
+ }