@mounaji_npm/saas-template 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mounajimodules.es.js +689 -0
- package/dist/mounajimodules.es.js.map +1 -0
- package/dist/mounajimodules.umd.cjs +23 -0
- package/dist/mounajimodules.umd.cjs.map +1 -0
- package/package.json +40 -0
- package/src/index.js +16 -0
- package/src/modules/index.js +36 -0
- package/src/modules/manifests.js +263 -0
- package/src/modules/pages/DashboardPage.jsx +140 -0
- package/src/modules/pages/SettingsPage.jsx +205 -0
- package/src/registry/ModuleRegistry.jsx +131 -0
- package/src/shell/AppShell.jsx +192 -0
- package/src/shell/Sidebar.jsx +357 -0
- package/src/shell/TopNav.jsx +291 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModuleRegistry — @mounaji/saas-template
|
|
3
|
+
*
|
|
4
|
+
* Central registry for navigation modules. Each page in the app registers
|
|
5
|
+
* a manifest here; the Sidebar reads the registry to build its nav tree.
|
|
6
|
+
*
|
|
7
|
+
* Two usage patterns:
|
|
8
|
+
*
|
|
9
|
+
* ── Static (recommended for most apps) ─────────────────────────────────────
|
|
10
|
+
* Pass modules directly to AppShell:
|
|
11
|
+
* <AppShell modules={[DASHBOARD_MODULE, CHAT_MODULE, MY_CUSTOM_MODULE]}>
|
|
12
|
+
* {children}
|
|
13
|
+
* </AppShell>
|
|
14
|
+
*
|
|
15
|
+
* ── Dynamic (for plugin-style architectures) ───────────────────────────────
|
|
16
|
+
* Call useRegisterModule() at the top of a page component:
|
|
17
|
+
* function MyPage() {
|
|
18
|
+
* useRegisterModule(MY_MODULE);
|
|
19
|
+
* return <div>...</div>;
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* ── Module Manifest Shape ────────────────────────────────────────────────────
|
|
23
|
+
* {
|
|
24
|
+
* id: string — unique identifier (e.g. 'dashboard')
|
|
25
|
+
* label: string — display name in sidebar
|
|
26
|
+
* icon: ReactNode — icon element or render function
|
|
27
|
+
* path: string — href for Link (e.g. '/dashboard')
|
|
28
|
+
* section: string|null — sidebar section group label (null = top section)
|
|
29
|
+
* order: number — sort order within section (lower = higher)
|
|
30
|
+
* children: Manifest[] — optional sub-nav items
|
|
31
|
+
* badge: string|null — badge text shown next to label (e.g. 'Beta')
|
|
32
|
+
* disabled: boolean — grayed out, not clickable
|
|
33
|
+
* hidden: boolean — completely omit from nav (useful for role-based hiding)
|
|
34
|
+
* external: boolean — open in new tab
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
|
|
39
|
+
|
|
40
|
+
const RegistryContext = createContext({
|
|
41
|
+
modules: [],
|
|
42
|
+
register: () => {},
|
|
43
|
+
unregister: () => {},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export function ModuleRegistryProvider({ children, initialModules = [] }) {
|
|
47
|
+
const [dynamic, setDynamic] = useState([]);
|
|
48
|
+
|
|
49
|
+
const register = useCallback((manifest) => {
|
|
50
|
+
setDynamic(prev => {
|
|
51
|
+
const exists = prev.find(m => m.id === manifest.id);
|
|
52
|
+
if (exists) return prev.map(m => m.id === manifest.id ? manifest : m);
|
|
53
|
+
return [...prev, manifest];
|
|
54
|
+
});
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const unregister = useCallback((id) => {
|
|
58
|
+
setDynamic(prev => prev.filter(m => m.id !== id));
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Merge initial (static) + dynamic modules, deduplicated by id
|
|
62
|
+
// Dynamic modules override static ones with the same id
|
|
63
|
+
const modules = useMemo(() => {
|
|
64
|
+
const map = new Map();
|
|
65
|
+
[...initialModules, ...dynamic].forEach(m => map.set(m.id, m));
|
|
66
|
+
return Array.from(map.values());
|
|
67
|
+
}, [initialModules, dynamic]);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<RegistryContext.Provider value={{ modules, register, unregister }}>
|
|
71
|
+
{children}
|
|
72
|
+
</RegistryContext.Provider>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useModuleRegistry() {
|
|
77
|
+
return useContext(RegistryContext);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Dynamically register a module when a component mounts.
|
|
82
|
+
* Automatically unregisters when the component unmounts.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} manifest
|
|
85
|
+
* @param {boolean} [enabled=true] — set false to skip registration (for conditional modules)
|
|
86
|
+
*/
|
|
87
|
+
export function useRegisterModule(manifest, enabled = true) {
|
|
88
|
+
const { register, unregister } = useModuleRegistry();
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!enabled || !manifest?.id) return;
|
|
92
|
+
register(manifest);
|
|
93
|
+
return () => unregister(manifest.id);
|
|
94
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
|
+
}, [manifest?.id, enabled]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns the nav tree sorted into sections, ready for Sidebar rendering.
|
|
100
|
+
*
|
|
101
|
+
* @returns {{ label: string|null, items: object[] }[]}
|
|
102
|
+
*/
|
|
103
|
+
export function useNavTree() {
|
|
104
|
+
const { modules } = useModuleRegistry();
|
|
105
|
+
|
|
106
|
+
return useMemo(() => {
|
|
107
|
+
const visible = modules.filter(m => !m.hidden);
|
|
108
|
+
const sectionMap = new Map();
|
|
109
|
+
|
|
110
|
+
visible.forEach(m => {
|
|
111
|
+
const key = m.section ?? '__TOP__';
|
|
112
|
+
if (!sectionMap.has(key)) sectionMap.set(key, []);
|
|
113
|
+
sectionMap.get(key).push(m);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Sort items within each section
|
|
117
|
+
sectionMap.forEach(items => items.sort((a, b) => (a.order ?? 99) - (b.order ?? 99)));
|
|
118
|
+
|
|
119
|
+
// Order sections: __TOP__ first, then alphabetical
|
|
120
|
+
const sections = [];
|
|
121
|
+
if (sectionMap.has('__TOP__')) {
|
|
122
|
+
sections.push({ label: null, items: sectionMap.get('__TOP__') });
|
|
123
|
+
}
|
|
124
|
+
Array.from(sectionMap.entries())
|
|
125
|
+
.filter(([k]) => k !== '__TOP__')
|
|
126
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
127
|
+
.forEach(([label, items]) => sections.push({ label, items }));
|
|
128
|
+
|
|
129
|
+
return sections;
|
|
130
|
+
}, [modules]);
|
|
131
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppShell — @mounaji/saas-template
|
|
3
|
+
*
|
|
4
|
+
* Root layout compositor. Wires together:
|
|
5
|
+
* TokensProvider → design tokens injected to :root
|
|
6
|
+
* ModuleRegistry → nav module list available to Sidebar
|
|
7
|
+
* Sidebar → collapsible left nav
|
|
8
|
+
* TopNav → sticky top bar
|
|
9
|
+
* <children> → page content
|
|
10
|
+
*
|
|
11
|
+
* ── Minimal usage (React app, e.g. Vite) ───────────────────────────────────
|
|
12
|
+
* import { AppShell } from '@mounaji/saas-template';
|
|
13
|
+
* import { DASHBOARD_MODULE, CHAT_MODULE } from '@mounaji/saas-template/modules';
|
|
14
|
+
*
|
|
15
|
+
* <AppShell modules={[DASHBOARD_MODULE, CHAT_MODULE]} activePath={location.pathname}>
|
|
16
|
+
* <Route path="/dashboard" element={<Dashboard />} />
|
|
17
|
+
* </AppShell>
|
|
18
|
+
*
|
|
19
|
+
* ── Next.js App Router (app/layout.js) ─────────────────────────────────────
|
|
20
|
+
* 'use client'; // AppShell is a client component
|
|
21
|
+
* import { AppShell } from '@mounaji/saas-template';
|
|
22
|
+
* import { DASHBOARD_MODULE, CHAT_MODULE } from '@mounaji/saas-template/modules';
|
|
23
|
+
* import { usePathname } from 'next/navigation';
|
|
24
|
+
* import Link from 'next/link';
|
|
25
|
+
*
|
|
26
|
+
* export default function RootLayout({ children }) {
|
|
27
|
+
* return (
|
|
28
|
+
* <html><body>
|
|
29
|
+
* <AppShellWrapper>{children}</AppShellWrapper>
|
|
30
|
+
* </body></html>
|
|
31
|
+
* );
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* function AppShellWrapper({ children }) {
|
|
35
|
+
* const pathname = usePathname();
|
|
36
|
+
* return (
|
|
37
|
+
* <AppShell
|
|
38
|
+
* modules={[DASHBOARD_MODULE, CHAT_MODULE]}
|
|
39
|
+
* activePath={pathname}
|
|
40
|
+
* LinkComponent={Link} // pass Next.js Link for client-side nav
|
|
41
|
+
* >
|
|
42
|
+
* {children}
|
|
43
|
+
* </AppShell>
|
|
44
|
+
* );
|
|
45
|
+
* }
|
|
46
|
+
*
|
|
47
|
+
* ── Adding a custom page module ─────────────────────────────────────────────
|
|
48
|
+
* // my-module.js — export a manifest
|
|
49
|
+
* export const MY_MODULE = {
|
|
50
|
+
* id: 'analytics',
|
|
51
|
+
* label: 'Analytics',
|
|
52
|
+
* icon: '📊', // string emoji, or pass React element/function
|
|
53
|
+
* path: '/analytics',
|
|
54
|
+
* section: 'Workspace',
|
|
55
|
+
* order: 10,
|
|
56
|
+
* };
|
|
57
|
+
*
|
|
58
|
+
* // in AppShell modules prop:
|
|
59
|
+
* <AppShell modules={[...DEFAULT_MODULES, MY_MODULE]}>
|
|
60
|
+
*
|
|
61
|
+
* Props:
|
|
62
|
+
* modules — ModuleManifest[] (nav items)
|
|
63
|
+
* activePath — string (current route path)
|
|
64
|
+
* onNavigate — (path) => void (router push — omit to use href links)
|
|
65
|
+
* LinkComponent — React component (default: 'a'. Pass Next.js Link, React Router Link, etc.)
|
|
66
|
+
* logo — React node (sidebar brand logo)
|
|
67
|
+
* bottomModules — ModuleManifest[] (bottom nav items: Settings, Help)
|
|
68
|
+
* org — { name, logo? }
|
|
69
|
+
* project — { name }
|
|
70
|
+
* onOrgClick — () => void
|
|
71
|
+
* onProjectClick — () => void
|
|
72
|
+
* onNewProject — () => void
|
|
73
|
+
* user — { name, avatar?, email? }
|
|
74
|
+
* onLogout — () => void
|
|
75
|
+
* isDark — boolean (for theme toggle display)
|
|
76
|
+
* onThemeToggle — () => void
|
|
77
|
+
* topNavLeft — React node (override breadcrumb)
|
|
78
|
+
* topNavCenter — React node
|
|
79
|
+
* topNavRight — React node (appended to right side)
|
|
80
|
+
* tokens — Partial<DEFAULT_TOKENS>
|
|
81
|
+
* tokenPersist — boolean (persist tokens to localStorage, default: true)
|
|
82
|
+
* tokenStorageKey — string (default: 'mn_design_tokens')
|
|
83
|
+
* children — React node (page content)
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
import { useState } from 'react';
|
|
87
|
+
import { TokensProvider } from '@mounaji_npm/tokens';
|
|
88
|
+
import { ModuleRegistryProvider } from '../registry/ModuleRegistry.jsx';
|
|
89
|
+
import { Sidebar } from './Sidebar.jsx';
|
|
90
|
+
import { TopNav } from './TopNav.jsx';
|
|
91
|
+
|
|
92
|
+
const SIDEBAR_EXPANDED = 220;
|
|
93
|
+
const SIDEBAR_COLLAPSED = 60;
|
|
94
|
+
|
|
95
|
+
export function AppShell({
|
|
96
|
+
// Nav
|
|
97
|
+
modules = [],
|
|
98
|
+
bottomModules = DEFAULT_BOTTOM_MODULES,
|
|
99
|
+
activePath = '/',
|
|
100
|
+
onNavigate,
|
|
101
|
+
LinkComponent = 'a',
|
|
102
|
+
logo,
|
|
103
|
+
|
|
104
|
+
// TopNav
|
|
105
|
+
org,
|
|
106
|
+
project,
|
|
107
|
+
onOrgClick,
|
|
108
|
+
onProjectClick,
|
|
109
|
+
onNewProject,
|
|
110
|
+
user,
|
|
111
|
+
onLogout,
|
|
112
|
+
isDark = true,
|
|
113
|
+
onThemeToggle,
|
|
114
|
+
topNavLeft,
|
|
115
|
+
topNavCenter,
|
|
116
|
+
topNavRight,
|
|
117
|
+
|
|
118
|
+
// Tokens
|
|
119
|
+
tokens,
|
|
120
|
+
tokenPersist = true,
|
|
121
|
+
tokenStorageKey = 'mn_design_tokens',
|
|
122
|
+
|
|
123
|
+
// Content
|
|
124
|
+
children,
|
|
125
|
+
}) {
|
|
126
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
127
|
+
|
|
128
|
+
const sidebarWidth = collapsed ? SIDEBAR_COLLAPSED : SIDEBAR_EXPANDED;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<TokensProvider
|
|
132
|
+
initialTokens={tokens}
|
|
133
|
+
persist={tokenPersist}
|
|
134
|
+
storageKey={tokenStorageKey}
|
|
135
|
+
>
|
|
136
|
+
<ModuleRegistryProvider initialModules={modules}>
|
|
137
|
+
<div style={{ display: 'flex', minHeight: '100vh', backgroundColor: 'var(--mn-color-bg-dark, #060919)' }}>
|
|
138
|
+
|
|
139
|
+
{/* Sidebar */}
|
|
140
|
+
<Sidebar
|
|
141
|
+
logo={logo}
|
|
142
|
+
bottomItems={bottomModules}
|
|
143
|
+
collapsed={collapsed}
|
|
144
|
+
onCollapse={setCollapsed}
|
|
145
|
+
activePath={activePath}
|
|
146
|
+
onNavigate={onNavigate}
|
|
147
|
+
LinkComponent={LinkComponent}
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
{/* Main area — offset by sidebar width */}
|
|
151
|
+
<div style={{
|
|
152
|
+
flex: 1,
|
|
153
|
+
marginLeft: sidebarWidth,
|
|
154
|
+
transition: `margin-left 340ms cubic-bezier(0.16,1,0.3,1)`,
|
|
155
|
+
display: 'flex',
|
|
156
|
+
flexDirection: 'column',
|
|
157
|
+
minWidth: 0,
|
|
158
|
+
}}>
|
|
159
|
+
{/* Top Nav */}
|
|
160
|
+
<TopNav
|
|
161
|
+
org={org}
|
|
162
|
+
project={project}
|
|
163
|
+
onOrgClick={onOrgClick}
|
|
164
|
+
onProjectClick={onProjectClick}
|
|
165
|
+
onNewProject={onNewProject}
|
|
166
|
+
user={user}
|
|
167
|
+
onLogout={onLogout}
|
|
168
|
+
isDark={isDark}
|
|
169
|
+
onThemeToggle={onThemeToggle}
|
|
170
|
+
leftSlot={topNavLeft}
|
|
171
|
+
centerSlot={topNavCenter}
|
|
172
|
+
rightSlot={topNavRight}
|
|
173
|
+
/>
|
|
174
|
+
|
|
175
|
+
{/* Page content */}
|
|
176
|
+
<main style={{ flex: 1, minHeight: 0 }}>
|
|
177
|
+
{children}
|
|
178
|
+
</main>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
</div>
|
|
182
|
+
</ModuleRegistryProvider>
|
|
183
|
+
</TokensProvider>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Default bottom modules ───────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export const DEFAULT_BOTTOM_MODULES = [
|
|
190
|
+
{ id: 'settings', label: 'Settings', icon: '⚙', path: '/settings', order: 1 },
|
|
191
|
+
{ id: 'help', label: 'Help', icon: '?', path: '/help', order: 2 },
|
|
192
|
+
];
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar — @mounaji/saas-template
|
|
3
|
+
*
|
|
4
|
+
* Collapsible sidebar that renders nav sections from ModuleRegistry.
|
|
5
|
+
* All styles are token-driven via CSS variables.
|
|
6
|
+
*
|
|
7
|
+
* Props:
|
|
8
|
+
* logo — React node (brand logo/name)
|
|
9
|
+
* bottomItems — Manifest[] (always-visible bottom nav items like Settings, Help)
|
|
10
|
+
* collapsed — boolean (controlled)
|
|
11
|
+
* onCollapse — (collapsed: boolean) => void
|
|
12
|
+
* activePath — string (current pathname — compare to module.path)
|
|
13
|
+
* onNavigate — (path: string) => void (call router.push internally)
|
|
14
|
+
* LinkComponent — React component for links (default: <a>; pass Next.js <Link>)
|
|
15
|
+
* tokens — Partial<DEFAULT_TOKENS>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useState, useCallback } from 'react';
|
|
19
|
+
import { useNavTree } from '../registry/ModuleRegistry.jsx';
|
|
20
|
+
import { TOKEN_CSS_MAP } from '@mounaji_npm/tokens';
|
|
21
|
+
|
|
22
|
+
function buildInlineTokens(tokens) {
|
|
23
|
+
if (!tokens) return {};
|
|
24
|
+
const s = {};
|
|
25
|
+
Object.entries(tokens).forEach(([k, v]) => { const c = TOKEN_CSS_MAP[k]; if (c) s[c] = v; });
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SIDEBAR_EXPANDED_WIDTH = 220;
|
|
30
|
+
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
|
31
|
+
|
|
32
|
+
export function Sidebar({
|
|
33
|
+
logo,
|
|
34
|
+
bottomItems = [],
|
|
35
|
+
collapsed = false,
|
|
36
|
+
onCollapse,
|
|
37
|
+
activePath = '/',
|
|
38
|
+
onNavigate,
|
|
39
|
+
LinkComponent = 'a',
|
|
40
|
+
tokens,
|
|
41
|
+
style,
|
|
42
|
+
}) {
|
|
43
|
+
const sections = useNavTree();
|
|
44
|
+
const [expandedIds, setExpandedIds] = useState(new Set());
|
|
45
|
+
|
|
46
|
+
const toggleExpand = useCallback((id) => {
|
|
47
|
+
setExpandedIds(prev => {
|
|
48
|
+
const next = new Set(prev);
|
|
49
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
50
|
+
return next;
|
|
51
|
+
});
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
function isActive(path) {
|
|
55
|
+
if (path === '/') return activePath === '/';
|
|
56
|
+
return activePath === path || activePath.startsWith(path + '/');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const width = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<aside
|
|
63
|
+
style={{
|
|
64
|
+
position: 'fixed',
|
|
65
|
+
top: 0,
|
|
66
|
+
left: 0,
|
|
67
|
+
bottom: 0,
|
|
68
|
+
width,
|
|
69
|
+
display: 'flex',
|
|
70
|
+
flexDirection: 'column',
|
|
71
|
+
backgroundColor: 'var(--mn-color-nav-dark, #07091C)',
|
|
72
|
+
borderRight: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
|
|
73
|
+
transition: `width 340ms cubic-bezier(0.16,1,0.3,1)`,
|
|
74
|
+
overflow: 'hidden',
|
|
75
|
+
zIndex: 40,
|
|
76
|
+
fontFamily: 'var(--mn-font-family, inherit)',
|
|
77
|
+
...buildInlineTokens(tokens),
|
|
78
|
+
...style,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{/* Brand / Logo */}
|
|
82
|
+
<div style={{
|
|
83
|
+
height: 56,
|
|
84
|
+
display: 'flex',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
justifyContent: collapsed ? 'center' : 'space-between',
|
|
87
|
+
padding: collapsed ? '0' : '0 12px 0 16px',
|
|
88
|
+
borderBottom: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
|
|
89
|
+
flexShrink: 0,
|
|
90
|
+
gap: 8,
|
|
91
|
+
}}>
|
|
92
|
+
{!collapsed && (
|
|
93
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, overflow: 'hidden' }}>
|
|
94
|
+
{logo ?? <DefaultLogo />}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<CollapseButton collapsed={collapsed} onCollapse={() => onCollapse?.(!collapsed)} />
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* Nav sections — scrollable */}
|
|
102
|
+
<nav style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 0' }}>
|
|
103
|
+
{sections.map(section => (
|
|
104
|
+
<SidebarSection
|
|
105
|
+
key={section.label ?? '__top__'}
|
|
106
|
+
section={section}
|
|
107
|
+
collapsed={collapsed}
|
|
108
|
+
isActive={isActive}
|
|
109
|
+
expandedIds={expandedIds}
|
|
110
|
+
onToggle={toggleExpand}
|
|
111
|
+
onNavigate={onNavigate}
|
|
112
|
+
LinkComponent={LinkComponent}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</nav>
|
|
116
|
+
|
|
117
|
+
{/* Bottom items (Settings, Help, etc.) */}
|
|
118
|
+
{bottomItems.length > 0 && (
|
|
119
|
+
<div style={{
|
|
120
|
+
borderTop: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
|
|
121
|
+
padding: '8px 0',
|
|
122
|
+
flexShrink: 0,
|
|
123
|
+
}}>
|
|
124
|
+
{bottomItems.map(item => (
|
|
125
|
+
<NavItem
|
|
126
|
+
key={item.id}
|
|
127
|
+
item={item}
|
|
128
|
+
collapsed={collapsed}
|
|
129
|
+
active={isActive(item.path)}
|
|
130
|
+
onNavigate={onNavigate}
|
|
131
|
+
LinkComponent={LinkComponent}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</aside>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Section ──────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function SidebarSection({ section, collapsed, isActive, expandedIds, onToggle, onNavigate, LinkComponent }) {
|
|
143
|
+
return (
|
|
144
|
+
<div style={{ marginBottom: 4 }}>
|
|
145
|
+
{/* Section label */}
|
|
146
|
+
{section.label && !collapsed && (
|
|
147
|
+
<p style={{
|
|
148
|
+
padding: '8px 16px 4px',
|
|
149
|
+
fontSize: 10,
|
|
150
|
+
fontWeight: 600,
|
|
151
|
+
letterSpacing: '0.08em',
|
|
152
|
+
textTransform: 'uppercase',
|
|
153
|
+
color: 'var(--mn-text-muted-dark, #64748B)',
|
|
154
|
+
margin: 0,
|
|
155
|
+
whiteSpace: 'nowrap',
|
|
156
|
+
overflow: 'hidden',
|
|
157
|
+
}}>
|
|
158
|
+
{section.label}
|
|
159
|
+
</p>
|
|
160
|
+
)}
|
|
161
|
+
{section.label && collapsed && <div style={{ height: 8 }} />}
|
|
162
|
+
|
|
163
|
+
{section.items.map(item => (
|
|
164
|
+
<NavItem
|
|
165
|
+
key={item.id}
|
|
166
|
+
item={item}
|
|
167
|
+
collapsed={collapsed}
|
|
168
|
+
active={isActive(item.path)}
|
|
169
|
+
expanded={expandedIds.has(item.id)}
|
|
170
|
+
onToggle={() => item.children?.length && onToggle(item.id)}
|
|
171
|
+
onNavigate={onNavigate}
|
|
172
|
+
LinkComponent={LinkComponent}
|
|
173
|
+
isActive={isActive}
|
|
174
|
+
/>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── NavItem ──────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
function NavItem({ item, collapsed, active, expanded, onToggle, onNavigate, LinkComponent, isActive }) {
|
|
183
|
+
const hasChildren = item.children?.length > 0;
|
|
184
|
+
const isDisabled = Boolean(item.disabled);
|
|
185
|
+
|
|
186
|
+
const itemStyle = {
|
|
187
|
+
display: 'flex',
|
|
188
|
+
alignItems: 'center',
|
|
189
|
+
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
190
|
+
gap: collapsed ? 0 : 10,
|
|
191
|
+
width: '100%',
|
|
192
|
+
padding: collapsed ? '0' : '0 12px',
|
|
193
|
+
margin: '1px 6px',
|
|
194
|
+
width: 'calc(100% - 12px)',
|
|
195
|
+
height: 36,
|
|
196
|
+
borderRadius: 'var(--mn-radius-lg, 0.75rem)',
|
|
197
|
+
fontSize: 'var(--mn-font-size-sm, 0.875rem)',
|
|
198
|
+
fontWeight: 500,
|
|
199
|
+
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
200
|
+
opacity: isDisabled ? 0.4 : 1,
|
|
201
|
+
textDecoration: 'none',
|
|
202
|
+
border: 'none',
|
|
203
|
+
outline: 'none',
|
|
204
|
+
transition: 'background-color 150ms, color 150ms',
|
|
205
|
+
whiteSpace: 'nowrap',
|
|
206
|
+
overflow: 'hidden',
|
|
207
|
+
fontFamily: 'var(--mn-font-family, inherit)',
|
|
208
|
+
...(active
|
|
209
|
+
? {
|
|
210
|
+
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
211
|
+
color: 'var(--mn-text-primary-dark, #F0F4FF)',
|
|
212
|
+
}
|
|
213
|
+
: {
|
|
214
|
+
backgroundColor: 'transparent',
|
|
215
|
+
color: 'var(--mn-text-secondary-dark, #94A3B8)',
|
|
216
|
+
}),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const content = (
|
|
220
|
+
<>
|
|
221
|
+
{/* Icon */}
|
|
222
|
+
<IconWrapper icon={item.icon} active={active} />
|
|
223
|
+
|
|
224
|
+
{/* Label (hidden when collapsed) */}
|
|
225
|
+
{!collapsed && (
|
|
226
|
+
<>
|
|
227
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
228
|
+
{item.label}
|
|
229
|
+
</span>
|
|
230
|
+
|
|
231
|
+
{item.badge && (
|
|
232
|
+
<span style={{
|
|
233
|
+
fontSize: 10, fontWeight: 600,
|
|
234
|
+
padding: '1px 6px',
|
|
235
|
+
borderRadius: 'var(--mn-radius-full, 9999px)',
|
|
236
|
+
backgroundColor: 'rgba(99,102,241,0.2)',
|
|
237
|
+
color: 'var(--mn-color-accent, #8B5CF6)',
|
|
238
|
+
flexShrink: 0,
|
|
239
|
+
}}>
|
|
240
|
+
{item.badge}
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{hasChildren && (
|
|
245
|
+
<span style={{
|
|
246
|
+
fontSize: 10,
|
|
247
|
+
color: 'var(--mn-text-muted-dark, #64748B)',
|
|
248
|
+
flexShrink: 0,
|
|
249
|
+
transform: expanded ? 'rotate(180deg)' : 'none',
|
|
250
|
+
transition: 'transform 200ms',
|
|
251
|
+
}}>▾</span>
|
|
252
|
+
)}
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const handleClick = (e) => {
|
|
259
|
+
if (isDisabled) { e.preventDefault(); return; }
|
|
260
|
+
if (hasChildren) { e.preventDefault(); onToggle?.(); return; }
|
|
261
|
+
if (item.external) return; // let native link handle it
|
|
262
|
+
if (onNavigate) { e.preventDefault(); onNavigate(item.path); }
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const linkProps = {
|
|
266
|
+
href: item.path,
|
|
267
|
+
onClick: handleClick,
|
|
268
|
+
style: itemStyle,
|
|
269
|
+
...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
|
|
270
|
+
title: collapsed ? item.label : undefined,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<>
|
|
275
|
+
{hasChildren
|
|
276
|
+
? <button style={{ ...itemStyle, background: active ? 'rgba(255,255,255,0.08)' : 'transparent', width: 'calc(100% - 12px)', margin: '1px 6px' }} onClick={handleClick}>{content}</button>
|
|
277
|
+
: <LinkComponent {...linkProps}>{content}</LinkComponent>
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
{/* Children */}
|
|
281
|
+
{hasChildren && expanded && !collapsed && (
|
|
282
|
+
<div style={{ paddingLeft: 16, marginBottom: 4 }}>
|
|
283
|
+
{item.children.map(child => (
|
|
284
|
+
<NavItem
|
|
285
|
+
key={child.id ?? child.path}
|
|
286
|
+
item={{ id: child.id ?? child.path, ...child }}
|
|
287
|
+
collapsed={false}
|
|
288
|
+
active={isActive ? isActive(child.path) : false}
|
|
289
|
+
onNavigate={onNavigate}
|
|
290
|
+
LinkComponent={LinkComponent}
|
|
291
|
+
/>
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Icon Wrapper ─────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
function IconWrapper({ icon, active }) {
|
|
302
|
+
const style = {
|
|
303
|
+
width: 16,
|
|
304
|
+
height: 16,
|
|
305
|
+
flexShrink: 0,
|
|
306
|
+
color: active ? 'var(--mn-color-primary, #3B82F6)' : 'inherit',
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (!icon) return <span style={{ width: 16 }} />;
|
|
310
|
+
if (typeof icon === 'string') return <span style={{ fontSize: 14, width: 16, textAlign: 'center' }}>{icon}</span>;
|
|
311
|
+
if (typeof icon === 'function') return icon({ style });
|
|
312
|
+
|
|
313
|
+
// React element
|
|
314
|
+
return <span style={style}>{icon}</span>;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── Collapse Button ──────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function CollapseButton({ collapsed, onCollapse }) {
|
|
320
|
+
return (
|
|
321
|
+
<button
|
|
322
|
+
onClick={onCollapse}
|
|
323
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
324
|
+
style={{
|
|
325
|
+
width: 28,
|
|
326
|
+
height: 28,
|
|
327
|
+
borderRadius: 'var(--mn-radius-md, 0.5rem)',
|
|
328
|
+
background: 'rgba(255,255,255,0.04)',
|
|
329
|
+
border: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
|
|
330
|
+
cursor: 'pointer',
|
|
331
|
+
display: 'flex',
|
|
332
|
+
alignItems: 'center',
|
|
333
|
+
justifyContent: 'center',
|
|
334
|
+
color: 'var(--mn-text-muted-dark, #64748B)',
|
|
335
|
+
fontSize: 12,
|
|
336
|
+
flexShrink: 0,
|
|
337
|
+
transition: 'background-color 150ms',
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
{collapsed ? '›' : '‹'}
|
|
341
|
+
</button>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function DefaultLogo() {
|
|
346
|
+
return (
|
|
347
|
+
<div style={{
|
|
348
|
+
width: 24, height: 24,
|
|
349
|
+
borderRadius: 6,
|
|
350
|
+
background: 'var(--mn-color-primary, #3B82F6)',
|
|
351
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
352
|
+
fontSize: 11, fontWeight: 800, color: '#fff',
|
|
353
|
+
}}>
|
|
354
|
+
M
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
}
|