@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.
- package/README.md +36 -0
- package/dist/index.js +1937 -0
- package/dist/index.mjs +1891 -0
- package/package.json +26 -0
- package/src/BuildInfo.tsx +20 -0
- package/src/DidShareListEditor.tsx +433 -0
- package/src/MarkdownContent.tsx +70 -0
- package/src/MarkdownEditor.tsx +79 -0
- package/src/MoneyInput.tsx +103 -0
- package/src/PayoutSetupBanner.tsx +117 -0
- package/src/acting-as.ts +21 -0
- package/src/action-sheet.tsx +118 -0
- package/src/app-launcher.tsx +298 -0
- package/src/app-shell.tsx +154 -0
- package/src/balance-badge.tsx +106 -0
- package/src/brand.ts +26 -0
- package/src/button.tsx +14 -0
- package/src/connection-picker.tsx +106 -0
- package/src/footer.tsx +39 -0
- package/src/index.ts +44 -0
- package/src/nav-bar.tsx +611 -0
- package/src/notification-bell.tsx +144 -0
- package/src/notification-provider.tsx +134 -0
- package/src/theme-init.ts +10 -0
- package/src/toast.tsx +147 -0
- package/src/use-identities.ts +69 -0
package/src/nav-bar.tsx
ADDED
|
@@ -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
|
+
}
|