@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 CHANGED
@@ -1,17 +1,15 @@
1
1
  {
2
2
  "name": "@jhits/dashboard",
3
- "version": "0.0.7",
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-content": "*",
77
- "@jhits/plugin-images": "*",
78
- "@jhits/plugin-users": "*",
79
- "@jhits/plugin-website": "*",
80
- "@jhits/plugin-newsletter": "*",
81
- "@jhits/plugin-telemetry": "*"
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
+ }
@@ -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 hasUpdatedFromEffect = useRef(false);
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
- const checkProps = () => {
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 { Providers, AuthGuard } from '../../../components/Providers';
4
+ import { AuthGuard } from '../../../components/Providers';
4
5
  import '../../../app/globals.css';
5
6
 
6
- export default function DashboardLayout({ children }: { children: React.ReactNode }) {
7
+ const DashboardLayout = memo(function DashboardLayout({ children }: { children: React.ReactNode }) {
7
8
  return (
8
- <Providers>
9
- <AuthGuard>
10
- {/* 1. Main container uses a soft neutral background (slate/neutral) */}
11
- {/* Dashboard font and styles are applied via globals.css */}
12
- <div
13
- data-dashboard="true"
14
- className="flex h-screen bg-dashboard-bg transition-colors duration-300 font-sans"
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
- {/* Sidebar sits on the neutral background */}
18
- <Sidebar />
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
- {/* 2. Content Area Wrapper */}
21
- <div className="flex-1 flex flex-col min-w-0 overflow-hidden relative z-40">
22
- <Topbar />
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
- {/* 3. The "Stage": This creates the visual separation.
25
- We add a margin, rounded corners, and a solid background to
26
- make the content look like it's sitting on a paper/plate.
27
-
28
- bg-white dark:bg-neutral-900 border border-neutral-200/50 dark:border-neutral-800/50 p-6 md:p-10
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
- </div>
32
+ </Suspense>
35
33
  </div>
36
- </main>
37
- </div>
34
+ </div>
35
+ </main>
38
36
  </div>
39
- </AuthGuard>
40
- </Providers>
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
- useEffect(() => { getPreferences().then(setPrefs); }, []);
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
- useEffect(() => { loadData(); }, []);
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
- <input type="file" ref={fileInputRef} onChange={onImageChange} className="hidden" accept="image/*" />
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
- type={type} value={value || ""} onChange={(e) => onChange(e.target.value)}
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>
@@ -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
- /* 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 */
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(--card);
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: 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);
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
- /* 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);
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
- <Providers>
47
+ <DashboardRootWrapper messages={messages} locale={locale}>
43
48
  <WebsiteProvider website={websiteInfo}>
44
49
  <LoginPage />
45
50
  </WebsiteProvider>
46
- </Providers>
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
- <WebsiteProvider website={websiteInfo}>
85
- <DashboardLayout>
86
- {dashboardContent}
87
- </DashboardLayout>
88
- </WebsiteProvider>
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
+ }