@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.
@@ -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
+ }