@jhits/dashboard 0.0.7 → 0.0.8
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/package.json +18 -19
- package/src/api/pluginRouter.ts +7 -6
- package/src/app/[locale]/dashboard/[...pluginRoute]/page.tsx +25 -11
- package/src/app/[locale]/dashboard/layout.tsx +30 -30
- package/src/app/[locale]/dashboard/preferences/page.tsx +18 -2
- package/src/app/[locale]/dashboard/profile/page.tsx +50 -7
- package/src/app/globals.css +50 -22
- package/src/assets/public/animated-logo-white.svg +0 -0
- package/src/assets/public/logo_black.svg +0 -0
- package/src/assets/public/logo_white.svg +0 -0
- package/src/components/DashboardCatchAll.tsx +16 -8
- package/src/components/DashboardRootWrapper.tsx +59 -0
- package/src/components/Providers.tsx +56 -22
- package/src/components/dashboard/Sidebar.tsx +147 -60
- package/src/components/dashboard/Topbar.tsx +56 -25
- package/src/config.ts +80 -8
- package/src/empty.js +5 -2
- package/src/index.server.tsx +4 -1
- package/src/lib/generate-dashboard-metadata.ts +53 -0
- package/src/lib/plugin-registry.tsx +2 -1
- package/src/server.ts +1 -0
- package/src/app/[locale]/layout.tsx +0 -28
- package/src/app/api/auth/[...nextauth]/route.ts +0 -6
- package/src/app/api/plugin-images/list/route.ts +0 -96
- package/src/app/api/plugin-images/upload/route.ts +0 -88
- package/src/app/api/telemetry/log/route.ts +0 -10
- package/src/app/api/telemetry/route.ts +0 -12
- package/src/app/api/uploads/[filename]/route.ts +0 -33
- package/src/app/layout.tsx +0 -4
package/package.json
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhits/dashboard",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "A comprehensive dashboard system built to manage custom built websites - plugin based SaaS system.",
|
|
5
5
|
"main": "./src/index.tsx",
|
|
6
6
|
"types": "./src/index.tsx",
|
|
7
7
|
"browser": {
|
|
8
|
-
"./src/lib/mongodb.ts": false,
|
|
9
8
|
"./src/lib/auth.ts": false,
|
|
10
9
|
"./src/server.ts": false,
|
|
11
10
|
"./src/index.server.tsx": false,
|
|
12
11
|
"./src/api/masterRouter.ts": false,
|
|
13
12
|
"./src/api/pluginRouter.ts": false,
|
|
14
|
-
"mongodb": false,
|
|
15
13
|
"bcrypt": false,
|
|
16
14
|
"jsonwebtoken": false,
|
|
17
15
|
"server-only": false
|
|
@@ -51,13 +49,7 @@
|
|
|
51
49
|
"default": "./src/empty.js"
|
|
52
50
|
}
|
|
53
51
|
},
|
|
54
|
-
"scripts": {
|
|
55
|
-
"lint": "eslint",
|
|
56
|
-
"type-check": "tsc --noEmit",
|
|
57
|
-
"type-check:all": "bash ../check-types.sh"
|
|
58
|
-
},
|
|
59
52
|
"dependencies": {
|
|
60
|
-
"@jhits/plugin-core": "^0.0.1",
|
|
61
53
|
"@types/jsonwebtoken": "^9.0.10",
|
|
62
54
|
"bcrypt": "^6.0.0",
|
|
63
55
|
"framer-motion": "^12.23.26",
|
|
@@ -66,19 +58,21 @@
|
|
|
66
58
|
"mongodb": "^7.0.0",
|
|
67
59
|
"next-auth": "^4.24.13",
|
|
68
60
|
"next-intl": "^4.6.1",
|
|
69
|
-
"next-themes": "^0.4.6"
|
|
61
|
+
"next-themes": "^0.4.6",
|
|
62
|
+
"@jhits/plugin-core": "0.0.1"
|
|
70
63
|
},
|
|
71
64
|
"peerDependencies": {
|
|
72
65
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
73
66
|
"react": "^18.0.0 || ^19.0.0",
|
|
74
67
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
75
|
-
"@jhits/plugin-blog": "
|
|
76
|
-
"@jhits/plugin-
|
|
77
|
-
"@jhits/plugin-
|
|
78
|
-
"@jhits/plugin-
|
|
79
|
-
"@jhits/plugin-
|
|
80
|
-
"@jhits/plugin-
|
|
81
|
-
"@jhits/plugin-
|
|
68
|
+
"@jhits/plugin-blog": "0.0.6",
|
|
69
|
+
"@jhits/plugin-dep": "0.0.3",
|
|
70
|
+
"@jhits/plugin-content": "0.0.3",
|
|
71
|
+
"@jhits/plugin-images": "0.0.5",
|
|
72
|
+
"@jhits/plugin-users": "0.0.3",
|
|
73
|
+
"@jhits/plugin-website": "0.0.3",
|
|
74
|
+
"@jhits/plugin-newsletter": "0.0.3",
|
|
75
|
+
"@jhits/plugin-telemetry": "0.0.3"
|
|
82
76
|
},
|
|
83
77
|
"devDependencies": {
|
|
84
78
|
"@tailwindcss/postcss": "^4",
|
|
@@ -104,5 +98,10 @@
|
|
|
104
98
|
"next.config.ts",
|
|
105
99
|
"postcss.config.mjs",
|
|
106
100
|
"tailwind.config.ts"
|
|
107
|
-
]
|
|
108
|
-
|
|
101
|
+
],
|
|
102
|
+
"scripts": {
|
|
103
|
+
"lint": "eslint",
|
|
104
|
+
"type-check": "tsc --noEmit",
|
|
105
|
+
"type-check:all": "bash ../check-types.sh"
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/api/pluginRouter.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { NextRequest, NextResponse } from 'next/server';
|
|
6
6
|
import { getToken } from 'next-auth/jwt';
|
|
7
|
+
// Type declarations for plugins are in ./plugin-types.d.ts
|
|
7
8
|
|
|
8
9
|
export interface PluginRouterConfig {
|
|
9
10
|
mongoClient?: Promise<MongoClient>;
|
|
@@ -28,13 +29,13 @@ async function getUserIdFromRequest(req: NextRequest, jwtSecret?: string): Promi
|
|
|
28
29
|
try {
|
|
29
30
|
// Use NextAuth's getToken to get the session token
|
|
30
31
|
// This works with NextAuth's cookie-based sessions
|
|
31
|
-
const token = await getToken({
|
|
32
|
-
req,
|
|
33
|
-
secret: process.env.NEXTAUTH_SECRET || jwtSecret
|
|
32
|
+
const token = await getToken({
|
|
33
|
+
req,
|
|
34
|
+
secret: process.env.NEXTAUTH_SECRET || jwtSecret
|
|
34
35
|
});
|
|
35
|
-
|
|
36
|
+
|
|
36
37
|
if (!token || !token.sub) return null;
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
// NextAuth stores user ID in token.sub
|
|
39
40
|
return token.sub;
|
|
40
41
|
} catch {
|
|
@@ -125,7 +126,7 @@ export async function handlePluginApi(
|
|
|
125
126
|
emailConfig: config.emailConfig,
|
|
126
127
|
baseUrl: config.baseUrl,
|
|
127
128
|
};
|
|
128
|
-
|
|
129
|
+
|
|
129
130
|
// Debug logging for plugin-users to verify authOptions are present
|
|
130
131
|
if (normalizedId === 'plugin-users' && !adaptedConfig.authOptions) {
|
|
131
132
|
console.warn('[PluginRouter] WARNING: authOptions missing for plugin-users');
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// packages/jhits-dashboard/src/app/[locale]/dashboard/[...pluginRoute]/page.tsx
|
|
2
2
|
'use client';
|
|
3
3
|
|
|
4
|
-
import React, { use, useState, useEffect, useRef, useMemo } from 'react';
|
|
4
|
+
import React, { use, useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
|
5
|
+
import { usePathname } from '@/i18n/navigation';
|
|
5
6
|
import { PluginRegistry } from "../../../../lib/plugin-registry";
|
|
6
7
|
import { PluginNotFound } from "../../../../components/PluginNotFound";
|
|
7
8
|
import { PluginProps } from "../../../../types/plugin";
|
|
@@ -28,8 +29,9 @@ export default function DynamicPluginPage({
|
|
|
28
29
|
const [prefix, ...subPath] = resolvedParams.pluginRoute;
|
|
29
30
|
const locale = resolvedParams.locale;
|
|
30
31
|
const siteId = "current-site-id";
|
|
32
|
+
const pathname = usePathname();
|
|
31
33
|
|
|
32
|
-
const manifest = PluginRegistry.getDefinition(prefix);
|
|
34
|
+
const manifest = useMemo(() => PluginRegistry.getDefinition(prefix), [prefix]);
|
|
33
35
|
|
|
34
36
|
// FIX: Store the component in useMemo, but type it as 'any' or 'React.ElementType'
|
|
35
37
|
// to prevent the linter from thinking we are creating a component type.
|
|
@@ -51,28 +53,40 @@ export default function DynamicPluginPage({
|
|
|
51
53
|
return null;
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
const
|
|
56
|
+
const propsCheckedRef = useRef<string | null>(null);
|
|
55
57
|
|
|
58
|
+
// Only check props when pathname changes to this plugin route
|
|
56
59
|
useEffect(() => {
|
|
57
60
|
if (typeof window === 'undefined' || !manifest) return;
|
|
61
|
+
|
|
62
|
+
// Only check if we're actually on this plugin's route and haven't checked for this pathname yet
|
|
63
|
+
const isOnPluginRoute = pathname?.includes(`/dashboard/${prefix}`);
|
|
64
|
+
if (!isOnPluginRoute || !pathname || propsCheckedRef.current === pathname) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
propsCheckedRef.current = pathname;
|
|
58
69
|
|
|
59
|
-
|
|
70
|
+
// Check for plugin props
|
|
71
|
+
if (window.__JHITS_PLUGIN_PROPS__) {
|
|
72
|
+
const props = window.__JHITS_PLUGIN_PROPS__[manifest.repo];
|
|
73
|
+
if (props && (!clientProps || JSON.stringify(props) !== JSON.stringify(clientProps))) {
|
|
74
|
+
setClientProps(props);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Also check after a short delay in case props are set asynchronously
|
|
79
|
+
const timeoutId = setTimeout(() => {
|
|
60
80
|
if (window.__JHITS_PLUGIN_PROPS__) {
|
|
61
81
|
const props = window.__JHITS_PLUGIN_PROPS__[manifest.repo];
|
|
62
82
|
if (props && (!clientProps || JSON.stringify(props) !== JSON.stringify(clientProps))) {
|
|
63
83
|
setClientProps(props);
|
|
64
|
-
hasUpdatedFromEffect.current = true;
|
|
65
84
|
}
|
|
66
85
|
}
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
checkProps();
|
|
70
|
-
const timeoutId = setTimeout(() => {
|
|
71
|
-
if (!hasUpdatedFromEffect.current) checkProps();
|
|
72
86
|
}, 0);
|
|
73
87
|
|
|
74
88
|
return () => clearTimeout(timeoutId);
|
|
75
|
-
}, [manifest, clientProps]);
|
|
89
|
+
}, [manifest, clientProps, pathname, prefix]);
|
|
76
90
|
|
|
77
91
|
if (!manifest || !manifest.enabled || !PluginComponent) {
|
|
78
92
|
return <PluginNotFound requestedPath={prefix} />;
|
|
@@ -1,42 +1,42 @@
|
|
|
1
|
+
import { memo, Suspense } from 'react';
|
|
1
2
|
import Sidebar from '../../../components/dashboard/Sidebar';
|
|
2
3
|
import Topbar from '../../../components/dashboard/Topbar';
|
|
3
|
-
import {
|
|
4
|
+
import { AuthGuard } from '../../../components/Providers';
|
|
4
5
|
import '../../../app/globals.css';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
const DashboardLayout = memo(function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
7
8
|
return (
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
>
|
|
9
|
+
<AuthGuard>
|
|
10
|
+
<div
|
|
11
|
+
data-dashboard="true"
|
|
12
|
+
className="flex h-screen bg-dashboard-bg transition-colors duration-300 font-sans"
|
|
13
|
+
>
|
|
14
|
+
{/* Sidebar sits on the neutral background */}
|
|
15
|
+
<Sidebar />
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
{/* 2. Content Area Wrapper */}
|
|
18
|
+
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative z-40">
|
|
19
|
+
<Topbar />
|
|
19
20
|
|
|
20
|
-
{/*
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
{/* 3. The "Stage": This creates the visual separation.
|
|
22
|
+
We add a margin, rounded corners, and a solid background to
|
|
23
|
+
make the content look like it's sitting on a paper/plate.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<main className="flex-1 overflow-hidden p-2 md:p-4">
|
|
31
|
-
<div className="h-full w-full shadow-sm overflow-hidden">
|
|
32
|
-
<div className="h-full w-full mx-auto overflow-hidden">
|
|
25
|
+
bg-white dark:bg-neutral-900 border border-neutral-200/50 dark:border-neutral-800/50 p-6 md:p-10
|
|
26
|
+
*/}
|
|
27
|
+
<main className="flex-1 overflow-hidden p-2 md:p-4">
|
|
28
|
+
<div className="h-full w-full shadow-sm overflow-hidden">
|
|
29
|
+
<div className="h-full w-full mx-auto overflow-hidden">
|
|
30
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full"><div className="size-8 border-4 border-primary border-t-transparent rounded-full animate-spin" /></div>}>
|
|
33
31
|
{children}
|
|
34
|
-
</
|
|
32
|
+
</Suspense>
|
|
35
33
|
</div>
|
|
36
|
-
</
|
|
37
|
-
</
|
|
34
|
+
</div>
|
|
35
|
+
</main>
|
|
38
36
|
</div>
|
|
39
|
-
</
|
|
40
|
-
</
|
|
37
|
+
</div>
|
|
38
|
+
</AuthGuard>
|
|
41
39
|
);
|
|
42
|
-
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export default DashboardLayout;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useRef } from "react";
|
|
3
|
+
import { useState, useEffect, useRef, useMemo } from "react";
|
|
4
4
|
import { useTheme } from "next-themes";
|
|
5
|
+
import { usePathname } from '@/i18n/navigation';
|
|
5
6
|
import { motion } from "framer-motion";
|
|
6
7
|
import { createPortal } from "react-dom";
|
|
7
8
|
import {
|
|
@@ -135,6 +136,15 @@ export default function PreferencesPage() {
|
|
|
135
136
|
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
|
|
136
137
|
const [isSaving, setIsSaving] = useState(false);
|
|
137
138
|
const { theme, setTheme } = useTheme();
|
|
139
|
+
const pathname = usePathname();
|
|
140
|
+
const dataLoadedRef = useRef<string | null>(null);
|
|
141
|
+
|
|
142
|
+
// Only load data when we're actually on the preferences page
|
|
143
|
+
// Note: pathname is typed from next-intl routing, but dynamic dashboard routes aren't in the type union
|
|
144
|
+
// Using includes() handles both typed and dynamic routes safely
|
|
145
|
+
const isPreferencesPage = useMemo(() => {
|
|
146
|
+
return pathname?.includes('/preferences') ?? false;
|
|
147
|
+
}, [pathname]);
|
|
138
148
|
|
|
139
149
|
// 1. Listen for global theme changes (e.g. from Topbar) and sync local state
|
|
140
150
|
useEffect(() => {
|
|
@@ -143,7 +153,13 @@ export default function PreferencesPage() {
|
|
|
143
153
|
}
|
|
144
154
|
}, [theme, prefs]);
|
|
145
155
|
|
|
146
|
-
|
|
156
|
+
// Only load preferences when on preferences page and haven't loaded for this route yet
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (isPreferencesPage && pathname && dataLoadedRef.current !== pathname) {
|
|
159
|
+
getPreferences().then(setPrefs);
|
|
160
|
+
dataLoadedRef.current = pathname;
|
|
161
|
+
}
|
|
162
|
+
}, [isPreferencesPage, pathname]);
|
|
147
163
|
|
|
148
164
|
const handleSave = async () => {
|
|
149
165
|
if (!prefs) return;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, useRef } from 'react';
|
|
3
|
+
import { useEffect, useState, useRef, useMemo } from 'react';
|
|
4
4
|
import { useTranslations } from 'next-intl';
|
|
5
|
+
import { usePathname } from '@/i18n/navigation';
|
|
5
6
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
6
7
|
import { useSession } from 'next-auth/react';
|
|
7
8
|
import {
|
|
@@ -41,6 +42,7 @@ interface SessionLog {
|
|
|
41
42
|
export default function ProfilePage() {
|
|
42
43
|
const t = useTranslations('common.profile');
|
|
43
44
|
const { data: session, update } = useSession();
|
|
45
|
+
const pathname = usePathname();
|
|
44
46
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
45
47
|
|
|
46
48
|
// Global States
|
|
@@ -56,9 +58,30 @@ export default function ProfilePage() {
|
|
|
56
58
|
const [isChangingPwd, setIsChangingPwd] = useState(false);
|
|
57
59
|
const [passwords, setPasswords] = useState({ current: '', new: '', confirm: '' });
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
// Track if we've loaded data for this route
|
|
62
|
+
const dataLoadedRef = useRef<string | null>(null);
|
|
63
|
+
const isLoadingRef = useRef(false);
|
|
64
|
+
|
|
65
|
+
// Only load data when we're actually on the profile page
|
|
66
|
+
// Note: pathname is typed from next-intl routing, but dynamic dashboard routes aren't in the type union
|
|
67
|
+
// Using includes() handles both typed and dynamic routes safely
|
|
68
|
+
const isProfilePage = useMemo(() => {
|
|
69
|
+
return pathname?.includes('/profile') ?? false;
|
|
70
|
+
}, [pathname]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
// Only load if we're on the profile page and haven't loaded data for this route yet
|
|
74
|
+
if (isProfilePage && pathname && dataLoadedRef.current !== pathname) {
|
|
75
|
+
loadData();
|
|
76
|
+
dataLoadedRef.current = pathname;
|
|
77
|
+
}
|
|
78
|
+
}, [isProfilePage, pathname]);
|
|
60
79
|
|
|
61
80
|
async function loadData() {
|
|
81
|
+
// Skip if already loading (using ref to avoid stale closure)
|
|
82
|
+
if (isLoadingRef.current) return;
|
|
83
|
+
|
|
84
|
+
isLoadingRef.current = true;
|
|
62
85
|
setLoading(true);
|
|
63
86
|
try {
|
|
64
87
|
const [profile, activeSessions] = await Promise.all([
|
|
@@ -69,9 +92,10 @@ export default function ProfilePage() {
|
|
|
69
92
|
setOriginalUser(profile);
|
|
70
93
|
setSessions(activeSessions);
|
|
71
94
|
} catch (e) {
|
|
72
|
-
console.error(e);
|
|
95
|
+
console.error('Failed to load profile data:', e);
|
|
73
96
|
} finally {
|
|
74
97
|
setLoading(false);
|
|
98
|
+
isLoadingRef.current = false;
|
|
75
99
|
}
|
|
76
100
|
}
|
|
77
101
|
|
|
@@ -266,9 +290,19 @@ function ProfileHero({ user, isUploading, fileInputRef, onImageChange, onRemoveI
|
|
|
266
290
|
<header className="relative p-8 lg:p-10 rounded-[2.5rem] bg-neutral-100 dark:bg-neutral-700/50 border border-neutral-300 dark:border-neutral-700 overflow-hidden shadow-xl">
|
|
267
291
|
<div className="relative z-10 flex flex-col md:flex-row items-center gap-8">
|
|
268
292
|
<div className="relative group">
|
|
269
|
-
<
|
|
293
|
+
<label htmlFor="profile-image-upload" className="sr-only">Upload profile image</label>
|
|
294
|
+
<input
|
|
295
|
+
id="profile-image-upload"
|
|
296
|
+
name="profile-image"
|
|
297
|
+
type="file"
|
|
298
|
+
ref={fileInputRef}
|
|
299
|
+
onChange={onImageChange}
|
|
300
|
+
className="hidden"
|
|
301
|
+
accept="image/*"
|
|
302
|
+
aria-label="Upload profile image"
|
|
303
|
+
/>
|
|
270
304
|
<div onClick={() => fileInputRef.current?.click()} className={`size-32 lg:size-40 rounded-[2.5rem] bg-primary flex items-center justify-center text-white text-4xl font-black shadow-2xl overflow-hidden cursor-pointer transition-all ${isUploading ? 'opacity-50 grayscale' : ''}`}>
|
|
271
|
-
{user?.image ? <Image src={user.image} className="w-full h-full object-cover" alt="Profile" width={128} height={128} /> : initials}
|
|
305
|
+
{user?.image ? <Image src={user.image} className="w-full h-full object-cover" alt="Profile" width={128} height={128} loading="eager" priority /> : initials}
|
|
272
306
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"><Camera className="size-8" /></div>
|
|
273
307
|
</div>
|
|
274
308
|
{user?.image && (
|
|
@@ -389,11 +423,20 @@ function ActionButton({ label, icon: Icon, onClick }: { label: string, icon: Rea
|
|
|
389
423
|
}
|
|
390
424
|
|
|
391
425
|
function InputGroup({ label, value, onChange, type = "text" }: { label: string, value: string | undefined, onChange: (v: string) => void, type?: string }) {
|
|
426
|
+
// Generate a unique ID from the label
|
|
427
|
+
const inputId = `profile-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
|
428
|
+
// Generate a name attribute from the label
|
|
429
|
+
const inputName = label.toLowerCase().replace(/\s+/g, '-');
|
|
430
|
+
|
|
392
431
|
return (
|
|
393
432
|
<div className="space-y-2">
|
|
394
|
-
<label className="text-[10px] font-black uppercase tracking-widest text-neutral-400">{label}</label>
|
|
433
|
+
<label htmlFor={inputId} className="text-[10px] font-black uppercase tracking-widest text-neutral-400">{label}</label>
|
|
395
434
|
<input
|
|
396
|
-
|
|
435
|
+
id={inputId}
|
|
436
|
+
name={inputName}
|
|
437
|
+
type={type}
|
|
438
|
+
value={value || ""}
|
|
439
|
+
onChange={(e) => onChange(e.target.value)}
|
|
397
440
|
className="w-full bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 p-4 rounded-2xl text-sm font-bold outline-none focus:border-primary transition-all dark:text-neutral-100"
|
|
398
441
|
/>
|
|
399
442
|
</div>
|
package/src/app/globals.css
CHANGED
|
@@ -41,42 +41,70 @@
|
|
|
41
41
|
--color-neutral-800: oklch(18.67% 0 0);
|
|
42
42
|
--color-neutral-900: oklch(10.67% 0 0);
|
|
43
43
|
|
|
44
|
-
/*
|
|
45
|
-
|
|
46
|
-
--color-
|
|
47
|
-
--color-
|
|
48
|
-
--color-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
/* Tailwind utility colors - these enable bg-background, text-foreground, etc. */
|
|
45
|
+
/* Default to light mode values, will be overridden by CSS variables for dark mode */
|
|
46
|
+
--color-background: oklch(98% 0 0);
|
|
47
|
+
--color-foreground: oklch(20% 0 0);
|
|
48
|
+
--color-card: oklch(100% 0 0);
|
|
49
|
+
--color-border: oklch(90% 0 0);
|
|
50
|
+
|
|
51
|
+
/* Dashboard Semantic Variables mapped to CSS variables */
|
|
51
52
|
--color-dashboard-bg: var(--background);
|
|
52
53
|
--color-dashboard-card: var(--card);
|
|
53
54
|
--color-dashboard-text: var(--foreground);
|
|
54
|
-
--color-dashboard-sidebar: var(--
|
|
55
|
+
--color-dashboard-sidebar: var(--sidebar);
|
|
55
56
|
--color-dashboard-border: var(--border);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
:root {
|
|
59
60
|
/* Light Mode: Soft warm off-white base with deep charcoal text */
|
|
60
|
-
--background:
|
|
61
|
-
--foreground:
|
|
62
|
-
--card:
|
|
63
|
-
--
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
--background: oklch(98% 0 0); /* Soft off-white */
|
|
62
|
+
--foreground: oklch(20% 0 0); /* Deep charcoal */
|
|
63
|
+
--card: oklch(100% 0 0);
|
|
64
|
+
--sidebar: oklch(100% 0 0);
|
|
65
|
+
--border: oklch(90% 0 0);
|
|
66
|
+
|
|
67
|
+
/* Tailwind utility colors - default to light mode */
|
|
68
|
+
--color-background: oklch(98% 0 0);
|
|
69
|
+
--color-foreground: oklch(20% 0 0);
|
|
70
|
+
--color-card: oklch(100% 0 0);
|
|
71
|
+
--color-border: oklch(90% 0 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* System preference fallback - applies immediately before script runs */
|
|
75
|
+
@media (prefers-color-scheme: dark) {
|
|
76
|
+
:root:not([data-theme="light"]) {
|
|
77
|
+
color-scheme: dark;
|
|
78
|
+
--background: oklch(15% 0 0);
|
|
79
|
+
--foreground: oklch(95% 0 0);
|
|
80
|
+
--card: oklch(25% 0 0);
|
|
81
|
+
--sidebar: oklch(20% 0 0);
|
|
82
|
+
--border: oklch(30% 0 0);
|
|
83
|
+
--color-background: oklch(15% 0 0);
|
|
84
|
+
--color-foreground: oklch(95% 0 0);
|
|
85
|
+
--color-card: oklch(25% 0 0);
|
|
86
|
+
--color-border: oklch(30% 0 0);
|
|
87
|
+
}
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
/* Ensure this is exactly as Tailwind v4 expects for dark mode */
|
|
69
|
-
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
91
|
+
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
|
70
92
|
|
|
93
|
+
/* 2. Dark Variables - Scoped to the attribute */
|
|
94
|
+
:root[data-theme="dark"],
|
|
71
95
|
[data-theme="dark"] {
|
|
72
96
|
color-scheme: dark;
|
|
73
|
-
|
|
74
|
-
--
|
|
75
|
-
--
|
|
76
|
-
--
|
|
77
|
-
--border:
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
--background: oklch(15% 0 0); /* Midnight */
|
|
98
|
+
--foreground: oklch(95% 0 0); /* Off-white text */
|
|
99
|
+
--card: oklch(25% 0 0); /* Lighter gray for cards */
|
|
100
|
+
--sidebar: oklch(20% 0 0); /* Distinct sidebar dark */
|
|
101
|
+
--border: oklch(30% 0 0);
|
|
102
|
+
|
|
103
|
+
/* Override Tailwind theme colors for dark mode */
|
|
104
|
+
--color-background: oklch(15% 0 0);
|
|
105
|
+
--color-foreground: oklch(95% 0 0);
|
|
106
|
+
--color-card: oklch(25% 0 0);
|
|
107
|
+
--color-border: oklch(30% 0 0);
|
|
80
108
|
}
|
|
81
109
|
|
|
82
110
|
/* --- Utilities --- */
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -8,6 +8,8 @@ import DashboardPluginRoute from '../app/[locale]/dashboard/[...pluginRoute]/pag
|
|
|
8
8
|
import { WebsiteProvider } from '../lib/website-context';
|
|
9
9
|
import { getWebsiteInfo } from '../lib/get-website-info';
|
|
10
10
|
import { notFound } from 'next/navigation';
|
|
11
|
+
import { getMessages } from 'next-intl/server';
|
|
12
|
+
import { DashboardRootWrapper } from '../components/DashboardRootWrapper';
|
|
11
13
|
|
|
12
14
|
export interface DashboardCatchAllProps {
|
|
13
15
|
params: Promise<{ locale: string; path: string[] }>;
|
|
@@ -31,19 +33,22 @@ export default async function DashboardCatchAll({ params }: DashboardCatchAllPro
|
|
|
31
33
|
const path = resolvedParams.path;
|
|
32
34
|
const locale = resolvedParams.locale;
|
|
33
35
|
|
|
36
|
+
// Get messages for next-intl (dashboard builds its own intl context)
|
|
37
|
+
const messages = await getMessages();
|
|
38
|
+
|
|
34
39
|
// Handle login routes first (these should be accessible without auth)
|
|
35
40
|
if (path.length > 0 && path[0] === 'login') {
|
|
36
41
|
const { default: LoginPage } = await import('../app/[locale]/(auth)/login/page');
|
|
37
|
-
const { Providers } = await import('../components/Providers');
|
|
38
42
|
const { WebsiteProvider } = await import('../lib/website-context');
|
|
39
43
|
const websiteInfo = await getWebsiteInfo(locale);
|
|
40
44
|
|
|
45
|
+
// Build complete dashboard structure - all dashboard code stays in dashboard package
|
|
41
46
|
return (
|
|
42
|
-
<
|
|
47
|
+
<DashboardRootWrapper messages={messages} locale={locale}>
|
|
43
48
|
<WebsiteProvider website={websiteInfo}>
|
|
44
49
|
<LoginPage />
|
|
45
50
|
</WebsiteProvider>
|
|
46
|
-
</
|
|
51
|
+
</DashboardRootWrapper>
|
|
47
52
|
);
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -80,12 +85,15 @@ export default async function DashboardCatchAll({ params }: DashboardCatchAllPro
|
|
|
80
85
|
})} />;
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
// Build complete dashboard structure - all dashboard code stays in dashboard package
|
|
83
89
|
return (
|
|
84
|
-
<
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
<DashboardRootWrapper messages={messages} locale={locale}>
|
|
91
|
+
<WebsiteProvider website={websiteInfo}>
|
|
92
|
+
<DashboardLayout>
|
|
93
|
+
{dashboardContent}
|
|
94
|
+
</DashboardLayout>
|
|
95
|
+
</WebsiteProvider>
|
|
96
|
+
</DashboardRootWrapper>
|
|
89
97
|
);
|
|
90
98
|
}
|
|
91
99
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { NextIntlClientProvider } from 'next-intl';
|
|
5
|
+
import { Providers } from './Providers';
|
|
6
|
+
|
|
7
|
+
interface DashboardRootWrapperProps {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
locale: string;
|
|
10
|
+
messages: Record<string, Record<string, string>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DashboardRootWrapper - Handles all dashboard-specific HTML setup
|
|
15
|
+
* This component sets HTML attributes, injects theme script, and wraps with providers
|
|
16
|
+
* All dashboard-specific code stays in the dashboard package
|
|
17
|
+
*/
|
|
18
|
+
export function DashboardRootWrapper({ children, locale, messages }: DashboardRootWrapperProps) {
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
// Set HTML attributes immediately on mount
|
|
21
|
+
if (typeof document !== 'undefined') {
|
|
22
|
+
document.documentElement.setAttribute('data-scroll-behavior', 'smooth');
|
|
23
|
+
|
|
24
|
+
// Apply theme from localStorage or system preference
|
|
25
|
+
try {
|
|
26
|
+
const stored = localStorage.getItem('jhits-dashboard-theme');
|
|
27
|
+
let theme = 'light';
|
|
28
|
+
if (stored === 'dark' || stored === 'light') {
|
|
29
|
+
theme = stored;
|
|
30
|
+
} else {
|
|
31
|
+
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
32
|
+
theme = systemPrefersDark ? 'dark' : 'light';
|
|
33
|
+
}
|
|
34
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
35
|
+
} catch {
|
|
36
|
+
// Ignore errors
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
{/* Blocking script that runs before React hydrates - regular script tag works in body */}
|
|
44
|
+
<script
|
|
45
|
+
suppressHydrationWarning
|
|
46
|
+
dangerouslySetInnerHTML={{
|
|
47
|
+
__html: `
|
|
48
|
+
!function(){try{var e=localStorage.getItem('jhits-dashboard-theme'),t='light';if('dark'===e||'light'===e)t=e;else{var n=window.matchMedia('(prefers-color-scheme: dark)').matches;t=n?'dark':'light'}document.documentElement.setAttribute('data-theme',t);document.documentElement.setAttribute('data-scroll-behavior','smooth')}catch(e){}}();
|
|
49
|
+
`,
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
<NextIntlClientProvider messages={messages} locale={locale}>
|
|
53
|
+
<Providers>
|
|
54
|
+
{children}
|
|
55
|
+
</Providers>
|
|
56
|
+
</NextIntlClientProvider>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|