@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.
- package/README.md +36 -0
- package/next.config.ts +32 -0
- package/package.json +79 -0
- package/postcss.config.mjs +7 -0
- package/src/api/README.md +72 -0
- package/src/api/masterRouter.ts +150 -0
- package/src/api/pluginRouter.ts +135 -0
- package/src/app/[locale]/(auth)/layout.tsx +30 -0
- package/src/app/[locale]/(auth)/login/page.tsx +201 -0
- package/src/app/[locale]/catch-all/page.tsx +10 -0
- package/src/app/[locale]/dashboard/[...pluginRoute]/page.tsx +98 -0
- package/src/app/[locale]/dashboard/layout.tsx +42 -0
- package/src/app/[locale]/dashboard/page.tsx +121 -0
- package/src/app/[locale]/dashboard/preferences/page.tsx +295 -0
- package/src/app/[locale]/dashboard/profile/page.tsx +491 -0
- package/src/app/[locale]/layout.tsx +28 -0
- package/src/app/actions/preferences.ts +40 -0
- package/src/app/actions/user.ts +191 -0
- package/src/app/api/auth/[...nextauth]/route.ts +6 -0
- package/src/app/api/plugin-images/list/route.ts +96 -0
- package/src/app/api/plugin-images/upload/route.ts +88 -0
- package/src/app/api/telemetry/log/route.ts +10 -0
- package/src/app/api/telemetry/route.ts +12 -0
- package/src/app/api/uploads/[filename]/route.ts +33 -0
- package/src/app/globals.css +181 -0
- package/src/app/layout.tsx +4 -0
- package/src/assets/locales/en/common.json +47 -0
- package/src/assets/locales/nl/common.json +48 -0
- package/src/assets/locales/sv/common.json +48 -0
- package/src/assets/plugins.json +42 -0
- package/src/assets/public/Logo_JH_black.jpg +0 -0
- package/src/assets/public/Logo_JH_black.png +0 -0
- package/src/assets/public/Logo_JH_white.png +0 -0
- package/src/assets/public/animated-logo-white.svg +5 -0
- package/src/assets/public/logo_black.svg +5 -0
- package/src/assets/public/logo_white.svg +5 -0
- package/src/assets/public/noimagefound.jpg +0 -0
- package/src/components/DashboardCatchAll.tsx +95 -0
- package/src/components/DashboardRootLayout.tsx +37 -0
- package/src/components/PluginNotFound.tsx +24 -0
- package/src/components/Providers.tsx +59 -0
- package/src/components/dashboard/Sidebar.tsx +263 -0
- package/src/components/dashboard/Topbar.tsx +363 -0
- package/src/components/page.tsx +130 -0
- package/src/config.ts +230 -0
- package/src/i18n/navigation.ts +7 -0
- package/src/i18n/request.ts +41 -0
- package/src/i18n/routing.ts +35 -0
- package/src/i18n/translations.ts +20 -0
- package/src/index.tsx +69 -0
- package/src/lib/auth.ts +159 -0
- package/src/lib/db.ts +11 -0
- package/src/lib/get-website-info.ts +78 -0
- package/src/lib/modules-config.ts +68 -0
- package/src/lib/mongodb.ts +32 -0
- package/src/lib/plugin-registry.tsx +77 -0
- package/src/lib/website-context.tsx +39 -0
- package/src/proxy.ts +55 -0
- package/src/router.tsx +45 -0
- package/src/routes.tsx +3 -0
- package/src/server.ts +8 -0
- package/src/types/plugin.ts +24 -0
- 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,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,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
|
+
}
|