@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,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopNav — @mounaji/saas-template
|
|
3
|
+
*
|
|
4
|
+
* Top navigation bar. Mirrors the platform's ProjectNavigation + top strip.
|
|
5
|
+
* Renders:
|
|
6
|
+
* Left: breadcrumb (org → project) or custom left slot
|
|
7
|
+
* Center: custom center slot
|
|
8
|
+
* Right: theme toggle, notifications slot, user menu
|
|
9
|
+
*
|
|
10
|
+
* Props:
|
|
11
|
+
* org — { name, logo? } | null
|
|
12
|
+
* project — { name } | null
|
|
13
|
+
* onOrgClick — () => void
|
|
14
|
+
* onProjectClick — () => void
|
|
15
|
+
* onNewProject — () => void
|
|
16
|
+
* user — { name, avatar?, email? } | null
|
|
17
|
+
* onThemeToggle — () => void
|
|
18
|
+
* isDark — boolean
|
|
19
|
+
* leftSlot — React node (override left breadcrumb)
|
|
20
|
+
* centerSlot — React node
|
|
21
|
+
* rightSlot — React node (appended after built-in right items)
|
|
22
|
+
* onLogout — () => void
|
|
23
|
+
* tokens — Partial<DEFAULT_TOKENS>
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useState, useRef, useEffect } from 'react';
|
|
27
|
+
import { TOKEN_CSS_MAP } from '@mounaji_npm/tokens';
|
|
28
|
+
|
|
29
|
+
function buildInlineTokens(tokens) {
|
|
30
|
+
if (!tokens) return {};
|
|
31
|
+
const s = {};
|
|
32
|
+
Object.entries(tokens).forEach(([k, v]) => { const c = TOKEN_CSS_MAP[k]; if (c) s[c] = v; });
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function TopNav({
|
|
37
|
+
org,
|
|
38
|
+
project,
|
|
39
|
+
onOrgClick,
|
|
40
|
+
onProjectClick,
|
|
41
|
+
onNewProject,
|
|
42
|
+
user,
|
|
43
|
+
onThemeToggle,
|
|
44
|
+
isDark = true,
|
|
45
|
+
leftSlot,
|
|
46
|
+
centerSlot,
|
|
47
|
+
rightSlot,
|
|
48
|
+
onLogout,
|
|
49
|
+
tokens,
|
|
50
|
+
style,
|
|
51
|
+
}) {
|
|
52
|
+
return (
|
|
53
|
+
<header
|
|
54
|
+
style={{
|
|
55
|
+
height: 52,
|
|
56
|
+
display: 'flex',
|
|
57
|
+
alignItems: 'center',
|
|
58
|
+
gap: 12,
|
|
59
|
+
padding: '0 16px',
|
|
60
|
+
backgroundColor: 'var(--mn-color-nav-dark, #07091C)',
|
|
61
|
+
borderBottom: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
|
|
62
|
+
backdropFilter: 'blur(12px)',
|
|
63
|
+
WebkitBackdropFilter: 'blur(12px)',
|
|
64
|
+
position: 'sticky',
|
|
65
|
+
top: 0,
|
|
66
|
+
zIndex: 30,
|
|
67
|
+
fontFamily: 'var(--mn-font-family, inherit)',
|
|
68
|
+
...buildInlineTokens(tokens),
|
|
69
|
+
...style,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{/* Left — Breadcrumb or custom */}
|
|
73
|
+
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
|
74
|
+
{leftSlot ?? (
|
|
75
|
+
<Breadcrumb
|
|
76
|
+
org={org}
|
|
77
|
+
project={project}
|
|
78
|
+
onOrgClick={onOrgClick}
|
|
79
|
+
onProjectClick={onProjectClick}
|
|
80
|
+
onNewProject={onNewProject}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{/* Center */}
|
|
86
|
+
{centerSlot && (
|
|
87
|
+
<div style={{ flexShrink: 0 }}>{centerSlot}</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{/* Right */}
|
|
91
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
|
92
|
+
{onThemeToggle && (
|
|
93
|
+
<IconButton onClick={onThemeToggle} title={isDark ? 'Light mode' : 'Dark mode'}>
|
|
94
|
+
{isDark ? '☀' : '☾'}
|
|
95
|
+
</IconButton>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{rightSlot}
|
|
99
|
+
|
|
100
|
+
{user && (
|
|
101
|
+
<UserMenu user={user} onLogout={onLogout} />
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</header>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Breadcrumb ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function Breadcrumb({ org, project, onOrgClick, onProjectClick, onNewProject }) {
|
|
111
|
+
if (!org && !project) return null;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
|
|
115
|
+
{org && (
|
|
116
|
+
<button
|
|
117
|
+
onClick={onOrgClick}
|
|
118
|
+
style={crumbStyle}
|
|
119
|
+
>
|
|
120
|
+
<OrgDot />
|
|
121
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{org.name}</span>
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{org && project && (
|
|
126
|
+
<span style={{ color: 'var(--mn-text-muted-dark, #64748B)', fontSize: 14 }}>/</span>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{project ? (
|
|
130
|
+
<button onClick={onProjectClick} style={crumbStyle}>
|
|
131
|
+
<span style={{ fontSize: 12 }}>📁</span>
|
|
132
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{project.name}</span>
|
|
133
|
+
</button>
|
|
134
|
+
) : onNewProject && (
|
|
135
|
+
<button
|
|
136
|
+
onClick={onNewProject}
|
|
137
|
+
style={{ ...crumbStyle, color: 'var(--mn-color-primary, #3B82F6)', opacity: 0.7 }}
|
|
138
|
+
>
|
|
139
|
+
+ New Project
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const crumbStyle = {
|
|
147
|
+
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
148
|
+
padding: '4px 8px',
|
|
149
|
+
borderRadius: 'var(--mn-radius-md, 0.5rem)',
|
|
150
|
+
background: 'none', border: 'none',
|
|
151
|
+
color: 'var(--mn-text-secondary-dark, #94A3B8)',
|
|
152
|
+
fontSize: 'var(--mn-font-size-sm, 0.875rem)',
|
|
153
|
+
fontWeight: 500,
|
|
154
|
+
cursor: 'pointer',
|
|
155
|
+
maxWidth: 160,
|
|
156
|
+
fontFamily: 'inherit',
|
|
157
|
+
transition: 'background-color 150ms',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function OrgDot() {
|
|
161
|
+
return <span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: 'var(--mn-color-accent, #8B5CF6)', flexShrink: 0, display: 'inline-block' }} />;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── User Menu ────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
function UserMenu({ user, onLogout }) {
|
|
167
|
+
const [open, setOpen] = useState(false);
|
|
168
|
+
const ref = useRef(null);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!open) return;
|
|
172
|
+
function handler(e) {
|
|
173
|
+
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
|
174
|
+
}
|
|
175
|
+
document.addEventListener('mousedown', handler);
|
|
176
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
177
|
+
}, [open]);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div ref={ref} style={{ position: 'relative' }}>
|
|
181
|
+
<button
|
|
182
|
+
onClick={() => setOpen(o => !o)}
|
|
183
|
+
style={{
|
|
184
|
+
display: 'flex', alignItems: 'center', gap: 6,
|
|
185
|
+
padding: '4px 6px',
|
|
186
|
+
borderRadius: 'var(--mn-radius-md, 0.5rem)',
|
|
187
|
+
background: 'none', border: 'none',
|
|
188
|
+
cursor: 'pointer',
|
|
189
|
+
color: 'var(--mn-text-secondary-dark, #94A3B8)',
|
|
190
|
+
fontFamily: 'inherit',
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<UserAvatar user={user} size={26} />
|
|
194
|
+
<span style={{ fontSize: 'var(--mn-font-size-sm, 0.875rem)', fontWeight: 500 }}>
|
|
195
|
+
{user.name?.split(' ')[0] ?? 'Account'}
|
|
196
|
+
</span>
|
|
197
|
+
<span style={{ fontSize: 10 }}>▾</span>
|
|
198
|
+
</button>
|
|
199
|
+
|
|
200
|
+
{open && (
|
|
201
|
+
<div style={{
|
|
202
|
+
position: 'absolute', top: '100%', right: 0, marginTop: 6,
|
|
203
|
+
width: 200,
|
|
204
|
+
borderRadius: 'var(--mn-radius-lg, 0.75rem)',
|
|
205
|
+
backgroundColor: 'var(--mn-color-card-dark, #0B0F23)',
|
|
206
|
+
border: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
|
|
207
|
+
boxShadow: 'var(--mn-shadow-lg, 0 4px 30px rgba(0,0,0,0.4))',
|
|
208
|
+
zIndex: 100,
|
|
209
|
+
overflow: 'hidden',
|
|
210
|
+
}}>
|
|
211
|
+
{/* User info */}
|
|
212
|
+
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))' }}>
|
|
213
|
+
<p style={{ margin: 0, fontSize: '0.8125rem', fontWeight: 600, color: 'var(--mn-text-primary-dark, #F0F4FF)' }}>{user.name}</p>
|
|
214
|
+
{user.email && <p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--mn-text-muted-dark, #64748B)' }}>{user.email}</p>}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Menu items */}
|
|
218
|
+
<div style={{ padding: '6px 0' }}>
|
|
219
|
+
<MenuItem onClick={() => setOpen(false)}>Profile settings</MenuItem>
|
|
220
|
+
<MenuItem onClick={() => setOpen(false)}>API Keys</MenuItem>
|
|
221
|
+
{onLogout && (
|
|
222
|
+
<>
|
|
223
|
+
<div style={{ height: 1, backgroundColor: 'var(--mn-border-dark, rgba(255,255,255,0.05))', margin: '6px 0' }} />
|
|
224
|
+
<MenuItem onClick={() => { setOpen(false); onLogout(); }} danger>Log out</MenuItem>
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function MenuItem({ children, onClick, danger }) {
|
|
235
|
+
return (
|
|
236
|
+
<button
|
|
237
|
+
onClick={onClick}
|
|
238
|
+
style={{
|
|
239
|
+
display: 'block', width: '100%', textAlign: 'left',
|
|
240
|
+
padding: '8px 14px',
|
|
241
|
+
background: 'none', border: 'none',
|
|
242
|
+
fontSize: 'var(--mn-font-size-sm, 0.875rem)',
|
|
243
|
+
color: danger ? 'var(--mn-color-danger, #EF4444)' : 'var(--mn-text-secondary-dark, #94A3B8)',
|
|
244
|
+
cursor: 'pointer',
|
|
245
|
+
fontFamily: 'inherit',
|
|
246
|
+
transition: 'background-color 100ms',
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
{children}
|
|
250
|
+
</button>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function UserAvatar({ user, size = 28 }) {
|
|
255
|
+
const initials = (user.name ?? '?').split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase();
|
|
256
|
+
return (
|
|
257
|
+
<div style={{
|
|
258
|
+
width: size, height: size, borderRadius: '50%',
|
|
259
|
+
backgroundColor: 'var(--mn-color-accent, #8B5CF6)',
|
|
260
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
261
|
+
fontSize: size * 0.38, fontWeight: 700, color: '#fff', flexShrink: 0,
|
|
262
|
+
backgroundImage: user.avatar ? `url(${user.avatar})` : 'none',
|
|
263
|
+
backgroundSize: 'cover',
|
|
264
|
+
overflow: 'hidden',
|
|
265
|
+
}}>
|
|
266
|
+
{!user.avatar && initials}
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function IconButton({ children, onClick, title }) {
|
|
272
|
+
return (
|
|
273
|
+
<button
|
|
274
|
+
onClick={onClick}
|
|
275
|
+
title={title}
|
|
276
|
+
style={{
|
|
277
|
+
width: 32, height: 32,
|
|
278
|
+
borderRadius: 'var(--mn-radius-md, 0.5rem)',
|
|
279
|
+
background: 'rgba(255,255,255,0.04)',
|
|
280
|
+
border: '1px solid var(--mn-border-dark, rgba(255,255,255,0.07))',
|
|
281
|
+
cursor: 'pointer',
|
|
282
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
283
|
+
color: 'var(--mn-text-secondary-dark, #94A3B8)',
|
|
284
|
+
fontSize: 14,
|
|
285
|
+
transition: 'background-color 150ms',
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
{children}
|
|
289
|
+
</button>
|
|
290
|
+
);
|
|
291
|
+
}
|