@ima-jin/ui 1.0.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,611 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef } from 'react';
4
+ import { AppLauncher } from './app-launcher';
5
+ import { NotificationBell } from './notification-bell';
6
+ import { getPort } from '@imajin/config';
7
+ import { useIdentities } from './use-identities';
8
+
9
+ export interface NavIdentity {
10
+ isLoggedIn: boolean;
11
+ handle?: string | null;
12
+ did?: string | null;
13
+ name?: string | null;
14
+ tier?: string | null;
15
+ onLogout?: () => void;
16
+ onViewProfile?: () => void;
17
+ onEditProfile?: () => void;
18
+ onLogin?: () => void;
19
+ onRegister?: () => void;
20
+ }
21
+
22
+ export interface ServiceUrls {
23
+ www?: string;
24
+ events?: string;
25
+ auth?: string;
26
+ connections?: string;
27
+ chat?: string;
28
+ profile?: string;
29
+ pay?: string;
30
+ registry?: string;
31
+ notify?: string;
32
+ }
33
+
34
+ export interface NavBarProps {
35
+ currentService?: string;
36
+ servicePrefix?: string;
37
+ domain?: string;
38
+ identity?: NavIdentity;
39
+ unreadMessages?: number;
40
+ serviceUrls?: ServiceUrls;
41
+ children?: React.ReactNode;
42
+ }
43
+
44
+ function buildUrl(service: string, prefix: string, domain: string, overrides?: ServiceUrls) {
45
+ const url = overrides?.[service as keyof ServiceUrls];
46
+ if (url) return url;
47
+
48
+ // Localhost-aware: use canonical port map instead of subdomain pattern
49
+ if (domain.includes('localhost') || prefix.includes('localhost')) {
50
+ const port = getPort(service, 'dev');
51
+ return port ? `http://localhost:${port}` : `http://localhost:3000`;
52
+ }
53
+
54
+ // Single-domain mode: prefix is a full URL like "https://dev-jin.imajin.ai/"
55
+ // Strip protocol and check for dots to detect
56
+ const stripped = prefix.replace(/^https?:\/\//, '');
57
+ if (stripped.includes('.')) {
58
+ const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
59
+ if (service === 'www' || service === 'kernel') return base;
60
+ return `${base}/${service}`;
61
+ }
62
+
63
+ return `${prefix}${service}.${domain}`;
64
+ }
65
+
66
+ function scopeIcon(scope: string): string {
67
+ if (scope === 'community') return '🏛️';
68
+ if (scope === 'org') return '🏢';
69
+ if (scope === 'family') return '👨‍👩‍👦';
70
+ if (scope === 'node') return '🖥️';
71
+ if (scope === 'agent') return '🤖';
72
+ if (scope === 'device') return '📱';
73
+ return '👤';
74
+ }
75
+
76
+ function buildUserLinks(prefix: string, domain: string, overrides?: ServiceUrls) {
77
+ return {
78
+ connections: buildUrl('connections', prefix, domain, overrides),
79
+ messages: buildUrl('chat', prefix, domain, overrides),
80
+ profile: buildUrl('profile', prefix, domain, overrides),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Map identity tier to launcher tier.
86
+ */
87
+ function getLauncherTier(identity: NavIdentity | null): 'anonymous' | 'soft' | 'hard' | 'creator' {
88
+ if (!identity?.isLoggedIn) return 'anonymous';
89
+ if (identity.tier === 'soft') return 'soft';
90
+ // TODO: distinguish creator from hard DID when roles are implemented
91
+ return 'hard';
92
+ }
93
+
94
+ /**
95
+ * Hook that auto-fetches identity from auth service when no identity prop is provided.
96
+ */
97
+ function useAutoIdentity(servicePrefix: string, domain: string, overrides?: ServiceUrls): NavIdentity | null {
98
+ const [identity, setIdentity] = useState<NavIdentity | null>(null);
99
+
100
+ useEffect(() => {
101
+ if (typeof window === 'undefined') return;
102
+
103
+ const authUrl = buildUrl('auth', servicePrefix, domain, overrides);
104
+ const profileUrl = buildUrl('profile', servicePrefix, domain, overrides);
105
+
106
+ async function checkSession() {
107
+ try {
108
+ const res = await fetch(`${authUrl}/api/session`, {
109
+ credentials: 'include',
110
+ });
111
+ if (res.ok) {
112
+ const data = await res.json();
113
+ setIdentity({
114
+ isLoggedIn: true,
115
+ handle: data.handle || null,
116
+ did: data.did || null,
117
+ name: data.name || null,
118
+ tier: data.tier || null,
119
+ onLogout: async () => {
120
+ try {
121
+ await fetch(`${authUrl}/api/logout`, {
122
+ method: 'POST',
123
+ credentials: 'include',
124
+ });
125
+ } catch {}
126
+ setIdentity({
127
+ isLoggedIn: false,
128
+ onLogin: () => { window.location.href = `${authUrl}/login?next=${encodeURIComponent(window.location.href)}`; },
129
+ onRegister: () => { window.location.href = `${authUrl}/register`; },
130
+ });
131
+ },
132
+ onViewProfile: data.handle
133
+ ? () => { window.location.href = `${profileUrl}/${data.handle}`; }
134
+ : data.did
135
+ ? () => { window.location.href = `${profileUrl}/${data.did}`; }
136
+ : undefined,
137
+ onEditProfile: () => { window.location.href = `${profileUrl}/edit`; },
138
+ onLogin: () => { window.location.href = `${authUrl}/login?next=${encodeURIComponent(window.location.href)}`; },
139
+ onRegister: () => { window.location.href = `${authUrl}/register`; },
140
+ });
141
+ } else {
142
+ setIdentity({
143
+ isLoggedIn: false,
144
+ onLogin: () => { window.location.href = `${authUrl}/login?next=${encodeURIComponent(window.location.href)}`; },
145
+ onRegister: () => { window.location.href = `${authUrl}/register`; },
146
+ });
147
+ }
148
+ } catch {
149
+ setIdentity(null);
150
+ }
151
+ }
152
+
153
+ checkSession();
154
+
155
+ // Re-fetch when another tab or component signals a session change
156
+ // (e.g. after onboarding claim in a polling flow)
157
+ const handler = () => checkSession();
158
+ window.addEventListener('imajin:session-changed', handler);
159
+ return () => window.removeEventListener('imajin:session-changed', handler);
160
+ }, [servicePrefix, domain, overrides]);
161
+
162
+ return identity;
163
+ }
164
+
165
+ export function NavBar({
166
+ currentService,
167
+ servicePrefix = 'https://',
168
+ domain = 'imajin.ai',
169
+ identity: identityProp,
170
+ unreadMessages = 0,
171
+ serviceUrls,
172
+ children,
173
+ }: NavBarProps) {
174
+ // Embed mode: skip NavBar when ?embed=hub is present
175
+ const [isEmbed, setIsEmbed] = useState(false);
176
+ useEffect(() => {
177
+ if (typeof window !== 'undefined') {
178
+ setIsEmbed(new URLSearchParams(window.location.search).get('embed') === 'hub');
179
+ }
180
+ }, []);
181
+
182
+ const userLinks = buildUserLinks(servicePrefix, domain, serviceUrls);
183
+ const isDev = servicePrefix.includes('dev-');
184
+ const [showDropdown, setShowDropdown] = useState(false);
185
+ const [showMobileMenu, setShowMobileMenu] = useState(false);
186
+ const dropdownRef = useRef<HTMLDivElement>(null);
187
+ const [theme, setTheme] = useState<'dark' | 'light'>('dark');
188
+
189
+ useEffect(() => {
190
+ const saved = typeof window !== 'undefined' ? localStorage.getItem('theme') : null;
191
+ setTheme(saved === 'light' ? 'light' : 'dark');
192
+ }, []);
193
+
194
+ function toggleTheme() {
195
+ const next = theme === 'dark' ? 'light' : 'dark';
196
+ setTheme(next);
197
+ localStorage.setItem('theme', next);
198
+ if (next === 'dark') {
199
+ document.documentElement.classList.add('dark');
200
+ } else {
201
+ document.documentElement.classList.remove('dark');
202
+ }
203
+ }
204
+
205
+ // Auto-fetch identity if no prop provided
206
+ const autoIdentity = useAutoIdentity(servicePrefix, domain, serviceUrls);
207
+ const identity = identityProp ?? autoIdentity;
208
+
209
+ const registryUrl = buildUrl('registry', servicePrefix, domain, serviceUrls);
210
+ const launcherTier = getLauncherTier(identity);
211
+
212
+ // Fetch balance from pay service (scope-aware, re-fetches on scope switch)
213
+ const [cashBalance, setCashBalance] = useState<number | null>(null);
214
+ const [mjnBalance, setMjnBalance] = useState<number | null>(null);
215
+ const [scopeVersion, setScopeVersion] = useState(0);
216
+ useEffect(() => {
217
+ const handler = () => setScopeVersion(v => v + 1);
218
+ window.addEventListener('imajin:acting-as-changed', handler);
219
+ return () => window.removeEventListener('imajin:acting-as-changed', handler);
220
+ }, []);
221
+ useEffect(() => {
222
+ if (!identity?.isLoggedIn || !identity?.did) { setCashBalance(null); setMjnBalance(null); return; }
223
+ const actingAs = typeof window !== 'undefined' ? localStorage.getItem('imajin:acting-as') : null;
224
+ const effectiveDid = actingAs || identity.did;
225
+ const payUrl = buildUrl('pay', servicePrefix, domain, serviceUrls);
226
+ fetch(`${payUrl}/api/balance/${encodeURIComponent(effectiveDid)}`, { credentials: 'include' })
227
+ .then(r => r.ok ? r.json() : null)
228
+ .then(data => {
229
+ if (data) {
230
+ setCashBalance(data.cashAmount != null ? parseFloat(data.cashAmount) : null);
231
+ setMjnBalance(data.creditAmount != null ? parseFloat(data.creditAmount) : null);
232
+ } else {
233
+ setCashBalance(null);
234
+ setMjnBalance(null);
235
+ }
236
+ })
237
+ .catch(() => {});
238
+ }, [identity?.isLoggedIn, identity?.did, servicePrefix, domain, serviceUrls, scopeVersion]);
239
+
240
+ // Fetch unread message count from chat service
241
+ const [unread, setUnread] = useState<number>(unreadMessages);
242
+ useEffect(() => {
243
+ if (!identity?.isLoggedIn || identity?.tier === 'soft') { setUnread(0); return; }
244
+ const chatUrl = buildUrl('chat', servicePrefix, domain, serviceUrls);
245
+ function fetchUnread() {
246
+ fetch(`${chatUrl}/api/conversations/unread`, { credentials: 'include' })
247
+ .then(r => r.ok ? r.json() : null)
248
+ .then(data => { if (data?.total != null) setUnread(data.total); })
249
+ .catch(() => {});
250
+ }
251
+ fetchUnread();
252
+ const interval = setInterval(fetchUnread, 60_000); // poll every 60s
253
+ return () => clearInterval(interval);
254
+ }, [identity?.isLoggedIn, identity?.tier, servicePrefix, domain, serviceUrls]);
255
+
256
+ // Identities (group identities)
257
+ const authUrl = buildUrl('auth', servicePrefix, domain, serviceUrls);
258
+ const profileUrl = buildUrl('profile', servicePrefix, domain, serviceUrls);
259
+ const { identities, activeIdentity, activeConfig, setActiveIdentity } = useIdentities(
260
+ identity?.isLoggedIn && identity?.tier !== 'soft' ? authUrl : null,
261
+ identity?.isLoggedIn && identity?.tier !== 'soft' ? profileUrl : null
262
+ );
263
+ const activeIdentityData = identities.find((f) => f.groupDid === activeIdentity) ?? null;
264
+
265
+ useEffect(() => {
266
+ function handleClickOutside(event: MouseEvent) {
267
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
268
+ setShowDropdown(false);
269
+ }
270
+ }
271
+ if (showDropdown) {
272
+ document.addEventListener('mousedown', handleClickOutside);
273
+ return () => document.removeEventListener('mousedown', handleClickOutside);
274
+ }
275
+ }, [showDropdown]);
276
+
277
+ return !isEmbed ? (
278
+ <nav className="w-full border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm relative z-50">
279
+ {isDev && (
280
+ <div className="w-full bg-amber-500/90 text-black text-xs font-bold text-center py-1 tracking-wide">
281
+ ⚠ DEVELOPMENT ENVIRONMENT
282
+ </div>
283
+ )}
284
+ <div className="max-w-6xl mx-auto px-4 py-3 flex items-center gap-2">
285
+ {/* Logo */}
286
+ <a
287
+ href={buildUrl('www', servicePrefix, domain, serviceUrls)}
288
+ className="flex items-center hover:opacity-80 transition shrink-0"
289
+ >
290
+ <span className="w-8 h-8 rounded-lg bg-amber-500/10 dark:bg-amber-500/20 flex items-center justify-center">
291
+ <span className="text-xl font-bold text-amber-500">人</span>
292
+ </span>
293
+ </a>
294
+
295
+ {/* Children slot (center, fills available space) */}
296
+ <div className="flex-1 min-w-0">{children}</div>
297
+
298
+ {/* Right - Launcher + Quick Access (desktop) */}
299
+ <div className="hidden sm:flex items-center gap-1">
300
+ <AppLauncher
301
+ registryUrl={registryUrl}
302
+ currentService={currentService}
303
+ tier={launcherTier}
304
+ variant="grid"
305
+ authUrl={identity?.isLoggedIn && identity?.tier !== 'soft' ? authUrl : undefined}
306
+ enabledServices={activeConfig?.enabledServices}
307
+ />
308
+ {identity?.isLoggedIn && identity?.tier !== 'soft' && (
309
+ <>
310
+ <a
311
+ href={userLinks.messages}
312
+ className="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition no-underline"
313
+ title="Messages"
314
+ >
315
+ <span className="text-lg">💬</span>
316
+ {unread > 0 && (
317
+ <span className="absolute -top-0.5 -right-0.5 bg-orange-500 text-white text-[10px] font-bold rounded-full min-w-[1.1rem] h-[1.1rem] flex items-center justify-center px-1">
318
+ {unread > 99 ? '99+' : unread}
319
+ </span>
320
+ )}
321
+ </a>
322
+ <a
323
+ href={userLinks.connections}
324
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition no-underline"
325
+ title="Connections"
326
+ >
327
+ <span className="text-lg">🤝</span>
328
+ </a>
329
+ </>
330
+ )}
331
+ </div>
332
+
333
+ {/* Notification Bell (desktop) */}
334
+ {process.env.NEXT_PUBLIC_NOTIFY_URL && (
335
+ <div className="hidden sm:flex items-center">
336
+ <NotificationBell />
337
+ </div>
338
+ )}
339
+
340
+ {/* Mobile hamburger */}
341
+ <button
342
+ onClick={() => setShowMobileMenu(!showMobileMenu)}
343
+ className="sm:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
344
+ >
345
+ <span className="text-xl">{showMobileMenu ? '✕' : '☰'}</span>
346
+ </button>
347
+
348
+ {/* Right - Auth Section */}
349
+ <div className="flex items-center gap-2">
350
+ {identity?.isLoggedIn && identity?.tier === 'soft' ? (
351
+ /* Soft DID — just a logout button, no dropdown */
352
+ <button
353
+ onClick={() => identity.onLogout?.()}
354
+ className="px-3 py-1.5 rounded-lg text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
355
+ >
356
+ Logout
357
+ </button>
358
+ ) : identity?.isLoggedIn ? (
359
+ <div className="flex items-center gap-2">
360
+ {(cashBalance !== null && cashBalance > 0) && (
361
+ <a
362
+ href={buildUrl('pay', servicePrefix, domain, serviceUrls)}
363
+ className="text-sm font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2.5 py-1 rounded-full hover:bg-green-100 dark:hover:bg-green-900/40 transition no-underline"
364
+ >
365
+ ${cashBalance.toFixed(2)}
366
+ </a>
367
+ )}
368
+ {(mjnBalance !== null && mjnBalance > 0) && (
369
+ <a
370
+ href={buildUrl('pay', servicePrefix, domain, serviceUrls)}
371
+ className="text-sm font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-2.5 py-1 rounded-full hover:bg-amber-100 dark:hover:bg-amber-900/40 transition no-underline"
372
+ >
373
+ 人{Math.round(mjnBalance)}
374
+ </a>
375
+ )}
376
+ <div className="relative" ref={dropdownRef}>
377
+ <button
378
+ onClick={() => setShowDropdown(!showDropdown)}
379
+ className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
380
+ >
381
+ <span className="text-xl">
382
+ {activeIdentityData ? scopeIcon(activeIdentityData.scope) : '👤'}
383
+ </span>
384
+ <span className="flex flex-col items-start" style={{ gap: '2px' }}>
385
+ {activeIdentityData && (
386
+ <span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium leading-none">
387
+ acting as
388
+ </span>
389
+ )}
390
+ <span className="text-sm font-medium leading-none">
391
+ {activeIdentityData
392
+ ? (activeIdentityData.name || activeIdentityData.handle || 'Identity')
393
+ : identity.handle
394
+ ? `@${identity.handle}`
395
+ : identity.name
396
+ ? identity.name
397
+ : identity.did?.slice(0, 12) + '...'}
398
+ </span>
399
+ </span>
400
+ </button>
401
+
402
+ {showDropdown && (
403
+ <div className="absolute right-0 mt-2 w-52 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-lg py-1 z-50">
404
+ {identity.tier !== 'soft' && (
405
+ <>
406
+ {identity.onViewProfile && (
407
+ <button
408
+ onClick={() => { identity.onViewProfile?.(); setShowDropdown(false); }}
409
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2"
410
+ >
411
+ <span>👤</span> View Profile
412
+ </button>
413
+ )}
414
+ {identity.onEditProfile && (
415
+ <button
416
+ onClick={() => { identity.onEditProfile?.(); setShowDropdown(false); }}
417
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2"
418
+ >
419
+ <span>✏️</span> Edit Profile
420
+ </button>
421
+ )}
422
+ <a
423
+ href={`${buildUrl('auth', servicePrefix, domain, serviceUrls)}/settings/security`}
424
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
425
+ >
426
+ <span>🔒</span> Security
427
+ </a>
428
+ <a
429
+ href={`${buildUrl('notify', servicePrefix, domain, serviceUrls)}/settings`}
430
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
431
+ >
432
+ <span>🔔</span> Notifications
433
+ </a>
434
+ <hr className="my-1 border-gray-200 dark:border-gray-800" />
435
+ <a
436
+ href={userLinks.messages}
437
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
438
+ >
439
+ <span>💬</span> Messages
440
+ {unread > 0 && (
441
+ <span className="ml-auto bg-orange-500 text-white text-xs font-bold rounded-full px-2 py-0.5 min-w-[1.25rem] text-center">
442
+ {unread}
443
+ </span>
444
+ )}
445
+ </a>
446
+ <a
447
+ href={userLinks.connections}
448
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
449
+ >
450
+ <span>🤝</span> Connections
451
+ </a>
452
+ <a
453
+ href={buildUrl('pay', servicePrefix, domain, serviceUrls)}
454
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
455
+ >
456
+ <span>💰</span> Wallet
457
+ </a>
458
+ <a
459
+ href={buildUrl('media', servicePrefix, domain, serviceUrls)}
460
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
461
+ >
462
+ <span>📁</span> Media
463
+ </a>
464
+ <a
465
+ href={buildUrl('auth', servicePrefix, domain, serviceUrls)}
466
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
467
+ >
468
+ <span>🔑</span> Identities
469
+ </a>
470
+ <hr className="my-1 border-gray-200 dark:border-gray-800" />
471
+ <div className="px-4 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
472
+ Switch To
473
+ </div>
474
+ {activeIdentity && identity && (
475
+ <div className="flex items-center group/identity">
476
+ <button
477
+ onClick={() => {
478
+ setActiveIdentity(null);
479
+ setShowDropdown(false);
480
+ }}
481
+ className="flex-1 text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2"
482
+ >
483
+ <span>👤</span>
484
+ <span>
485
+ {identity.handle ? `@${identity.handle}` : identity.name || 'Personal'}
486
+ </span>
487
+ </button>
488
+ </div>
489
+ )}
490
+ {identities.slice(0, 5).map((ident) => {
491
+ const isActive = ident.groupDid === activeIdentity;
492
+ return (
493
+ <div key={ident.groupDid} className="flex items-center group/identity">
494
+ <button
495
+ onClick={() => {
496
+ setActiveIdentity(isActive ? null : ident.groupDid);
497
+ setShowDropdown(false);
498
+ }}
499
+ className="flex-1 text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2"
500
+ >
501
+ <span>{scopeIcon(ident.scope)}</span>
502
+ <span className={isActive ? 'font-medium' : ''}>
503
+ {ident.name || ident.handle || ident.groupDid.slice(0, 12)}
504
+ </span>
505
+ {isActive && (
506
+ <span className="ml-auto text-amber-600 dark:text-amber-400 font-bold text-xs">✓</span>
507
+ )}
508
+ </button>
509
+ <a
510
+ href={`${authUrl}/groups/${encodeURIComponent(ident.groupDid)}/settings`}
511
+ onClick={e => e.stopPropagation()}
512
+ className="pr-3 py-2 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition opacity-0 group-hover/identity:opacity-100 no-underline text-sm"
513
+ title="Settings"
514
+ >
515
+ ⚙️
516
+ </a>
517
+ </div>
518
+ );
519
+ })}
520
+ {identities.length > 5 && (
521
+ <a
522
+ href={buildUrl('auth', servicePrefix, domain, serviceUrls)}
523
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit text-gray-500 dark:text-gray-400"
524
+ >
525
+ View all →
526
+ </a>
527
+ )}
528
+ <hr className="my-1 border-gray-200 dark:border-gray-800" />
529
+ <button
530
+ onClick={toggleTheme}
531
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2"
532
+ >
533
+ <span>{theme === 'dark' ? '☀️' : '🌙'}</span> {theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
534
+ </button>
535
+ <a
536
+ href={`${buildUrl('www', servicePrefix, domain, serviceUrls)}/bugs`}
537
+ className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2 no-underline text-inherit"
538
+ >
539
+ <span>🐛</span> Report a Bug
540
+ </a>
541
+ </>
542
+ )}
543
+ {identity.onLogout && (
544
+ <>
545
+ {identity.tier !== 'soft' && <hr className="my-1 border-gray-200 dark:border-gray-800" />}
546
+ <button
547
+ onClick={() => { identity.onLogout?.(); setShowDropdown(false); }}
548
+ className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-2"
549
+ >
550
+ <span>🚪</span> Logout
551
+ </button>
552
+ </>
553
+ )}
554
+ </div>
555
+ )}
556
+ </div>
557
+ </div>
558
+ ) : identity ? (
559
+ <>
560
+ {identity.onLogin && (
561
+ <button
562
+ onClick={identity.onLogin}
563
+ className="px-3 py-1.5 rounded-lg text-sm bg-[#F59E0B] text-black hover:bg-[#D97706] transition font-medium"
564
+ >
565
+ Login
566
+ </button>
567
+ )}
568
+ </>
569
+ ) : null}
570
+ </div>
571
+ </div>
572
+
573
+ {/* Mobile menu */}
574
+ {showMobileMenu && (
575
+ <div className="sm:hidden border-t border-gray-200 dark:border-gray-800 px-4 py-3">
576
+ {children && <div className="mb-3">{children}</div>}
577
+ <AppLauncher
578
+ registryUrl={registryUrl}
579
+ currentService={currentService}
580
+ tier={launcherTier}
581
+ inline
582
+ variant="grid"
583
+ authUrl={identity?.isLoggedIn && identity?.tier !== 'soft' ? authUrl : undefined}
584
+ enabledServices={activeConfig?.enabledServices}
585
+ />
586
+ {identity?.isLoggedIn && identity?.tier !== 'soft' && (
587
+ <div className="flex items-center gap-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-800">
588
+ <a
589
+ href={userLinks.messages}
590
+ className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition no-underline text-sm text-inherit"
591
+ >
592
+ <span>💬</span> Messages
593
+ {unread > 0 && (
594
+ <span className="bg-orange-500 text-white text-xs font-bold rounded-full px-2 py-0.5 min-w-[1.25rem] text-center">
595
+ {unread}
596
+ </span>
597
+ )}
598
+ </a>
599
+ <a
600
+ href={userLinks.connections}
601
+ className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition no-underline text-sm text-inherit"
602
+ >
603
+ <span>🤝</span> Connections
604
+ </a>
605
+ </div>
606
+ )}
607
+ </div>
608
+ )}
609
+ </nav>
610
+ ) : null;
611
+ }