@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useMemo } from 'react';
|
|
4
|
+
import type { Money } from '@imajin/fair';
|
|
5
|
+
|
|
6
|
+
interface MoneyInputProps {
|
|
7
|
+
value?: Money;
|
|
8
|
+
onChange: (value: Money | undefined) => void;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
className?: string;
|
|
11
|
+
currencies?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'MJNX'];
|
|
15
|
+
|
|
16
|
+
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
17
|
+
USD: '$',
|
|
18
|
+
EUR: '€',
|
|
19
|
+
GBP: '£',
|
|
20
|
+
CAD: 'C$',
|
|
21
|
+
MJNX: 'Ⓜ',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function formatCents(cents: number): string {
|
|
25
|
+
return (cents / 100).toFixed(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseDecimalInput(raw: string): number | null {
|
|
29
|
+
const cleaned = raw.replace(/,/g, '');
|
|
30
|
+
const parsed = parseFloat(cleaned);
|
|
31
|
+
if (Number.isNaN(parsed) || parsed < 0) return null;
|
|
32
|
+
return Math.round(parsed * 100);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function MoneyInput({
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
readOnly = false,
|
|
39
|
+
className = '',
|
|
40
|
+
currencies = DEFAULT_CURRENCIES,
|
|
41
|
+
}: MoneyInputProps) {
|
|
42
|
+
const symbol = value ? (CURRENCY_SYMBOLS[value.currency] ?? value.currency) : '$';
|
|
43
|
+
|
|
44
|
+
const formattedPreview = useMemo(() => {
|
|
45
|
+
if (!value) return '';
|
|
46
|
+
const amt = (value.amount / 100).toFixed(2);
|
|
47
|
+
return `${symbol}${amt} ${value.currency}`;
|
|
48
|
+
}, [value, symbol]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className={`space-y-1 ${className}`}>
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<div className="relative flex-1">
|
|
54
|
+
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-500 pointer-events-none">
|
|
55
|
+
{symbol}
|
|
56
|
+
</span>
|
|
57
|
+
<input
|
|
58
|
+
type="text"
|
|
59
|
+
inputMode="decimal"
|
|
60
|
+
value={value ? formatCents(value.amount) : ''}
|
|
61
|
+
onChange={(e) => {
|
|
62
|
+
const cents = parseDecimalInput(e.target.value);
|
|
63
|
+
if (cents === null) {
|
|
64
|
+
onChange(undefined);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
onChange({
|
|
68
|
+
amount: cents,
|
|
69
|
+
currency: value?.currency ?? currencies[0],
|
|
70
|
+
});
|
|
71
|
+
}}
|
|
72
|
+
placeholder="0.00"
|
|
73
|
+
readOnly={readOnly}
|
|
74
|
+
className="w-full bg-[#1a1a1a] border border-gray-700 rounded pl-6 pr-2 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-orange-500 read-only:opacity-60"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<select
|
|
78
|
+
value={value?.currency ?? currencies[0]}
|
|
79
|
+
onChange={(e) => {
|
|
80
|
+
const currency = e.target.value;
|
|
81
|
+
const amount = value?.amount ?? 0;
|
|
82
|
+
if (amount === 0 && !value) {
|
|
83
|
+
onChange(undefined);
|
|
84
|
+
} else {
|
|
85
|
+
onChange({ amount, currency });
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
disabled={readOnly}
|
|
89
|
+
className="bg-[#1a1a1a] border border-gray-700 rounded px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-orange-500 disabled:opacity-60"
|
|
90
|
+
>
|
|
91
|
+
{currencies.map((c) => (
|
|
92
|
+
<option key={c} value={c}>
|
|
93
|
+
{c}
|
|
94
|
+
</option>
|
|
95
|
+
))}
|
|
96
|
+
</select>
|
|
97
|
+
</div>
|
|
98
|
+
{value && value.amount > 0 && (
|
|
99
|
+
<p className="text-[10px] text-gray-500">{formattedPreview}</p>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface PayoutSetupBannerProps {
|
|
6
|
+
/** DID whose payout status is being checked (e.g. event creator) */
|
|
7
|
+
did: string;
|
|
8
|
+
/** Pay service base URL */
|
|
9
|
+
payUrl: string;
|
|
10
|
+
/** Optional override for the message shown to the DID owner */
|
|
11
|
+
message?: string;
|
|
12
|
+
/** Currently signed-in user's DID. If provided and != did, banner switches
|
|
13
|
+
* to third-person copy ("Event creator @handle needs to connect Stripe")
|
|
14
|
+
* and hides the "Set up payouts →" CTA, since the viewer can't fix it. */
|
|
15
|
+
viewerDid?: string;
|
|
16
|
+
/** Display handle for the DID owner (used in third-person copy) */
|
|
17
|
+
ownerHandle?: string | null;
|
|
18
|
+
/** Display name for the DID owner (fallback when no handle) */
|
|
19
|
+
ownerName?: string | null;
|
|
20
|
+
/** Label for what the owner is responsible for in third-person copy */
|
|
21
|
+
ownerRole?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reusable banner nudging users to connect their bank for payouts.
|
|
26
|
+
* Works cross-service — pass the pay service URL explicitly.
|
|
27
|
+
*
|
|
28
|
+
* Checks GET {payUrl}/api/connect/status?did=xxx
|
|
29
|
+
* If not connected or incomplete → shows dismissible banner
|
|
30
|
+
* If connected or on error → renders nothing
|
|
31
|
+
*
|
|
32
|
+
* If `viewerDid` differs from `did`, the banner becomes informational (no CTA).
|
|
33
|
+
*/
|
|
34
|
+
export function PayoutSetupBanner({
|
|
35
|
+
did,
|
|
36
|
+
payUrl,
|
|
37
|
+
message = 'Set up payouts to receive funds from events, tips, and sales',
|
|
38
|
+
viewerDid,
|
|
39
|
+
ownerHandle,
|
|
40
|
+
ownerName,
|
|
41
|
+
ownerRole = 'Event creator',
|
|
42
|
+
}: PayoutSetupBannerProps) {
|
|
43
|
+
const [shouldShow, setShouldShow] = useState(false);
|
|
44
|
+
const [loading, setLoading] = useState(true);
|
|
45
|
+
|
|
46
|
+
const isThirdParty = !!viewerDid && viewerDid !== did;
|
|
47
|
+
const ownerLabel = ownerHandle ? `@${ownerHandle}` : ownerName || 'they';
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const dismissedKey = `payout-banner-dismissed-${did}`;
|
|
51
|
+
if (typeof window !== 'undefined' && localStorage.getItem(dismissedKey) === 'true') {
|
|
52
|
+
setLoading(false);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const check = async () => {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`${payUrl}/api/connect/status?did=${encodeURIComponent(did)}`, {
|
|
59
|
+
credentials: 'include',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (res.status === 404 || res.status === 403) {
|
|
63
|
+
setShouldShow(true);
|
|
64
|
+
} else if (res.ok) {
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
setShouldShow(!data.onboardingComplete);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Network error / endpoint doesn't exist yet — stay hidden
|
|
70
|
+
} finally {
|
|
71
|
+
setLoading(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (did) check();
|
|
76
|
+
else setLoading(false);
|
|
77
|
+
}, [did, payUrl]);
|
|
78
|
+
|
|
79
|
+
const handleDismiss = () => {
|
|
80
|
+
localStorage.setItem(`payout-banner-dismissed-${did}`, 'true');
|
|
81
|
+
setShouldShow(false);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (loading || !shouldShow) return null;
|
|
85
|
+
|
|
86
|
+
const displayMessage = isThirdParty
|
|
87
|
+
? `${ownerRole} ${ownerLabel} needs to connect Stripe before tickets can be sold.`
|
|
88
|
+
: message;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="bg-orange-900/20 border border-orange-800/50 rounded-xl p-4 mb-6">
|
|
92
|
+
<div className="flex items-center justify-between gap-4">
|
|
93
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
94
|
+
<div className="w-2 h-2 bg-orange-500 rounded-full animate-pulse shrink-0" />
|
|
95
|
+
<p className="text-sm text-orange-200">{displayMessage}</p>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="flex items-center gap-3 shrink-0">
|
|
98
|
+
{!isThirdParty && (
|
|
99
|
+
<a
|
|
100
|
+
href={`${payUrl}/payouts`}
|
|
101
|
+
className="text-sm text-orange-400 hover:text-orange-300 font-medium transition-colors whitespace-nowrap"
|
|
102
|
+
>
|
|
103
|
+
Set up payouts →
|
|
104
|
+
</a>
|
|
105
|
+
)}
|
|
106
|
+
<button
|
|
107
|
+
onClick={handleDismiss}
|
|
108
|
+
className="text-orange-400/60 hover:text-orange-400 text-lg leading-none transition-colors"
|
|
109
|
+
aria-label="Dismiss"
|
|
110
|
+
>
|
|
111
|
+
×
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
package/src/acting-as.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function getActingAs(): string | null {
|
|
2
|
+
if (typeof window === 'undefined') return null;
|
|
3
|
+
return localStorage.getItem('imajin:acting-as');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function setActingAs(did: string | null): void {
|
|
7
|
+
if (typeof window === 'undefined') return;
|
|
8
|
+
if (did) {
|
|
9
|
+
localStorage.setItem('imajin:acting-as', did);
|
|
10
|
+
document.cookie = `x-acting-as=${did}; path=/; max-age=31536000; SameSite=Lax`;
|
|
11
|
+
} else {
|
|
12
|
+
localStorage.removeItem('imajin:acting-as');
|
|
13
|
+
document.cookie = 'x-acting-as=; path=/; max-age=0';
|
|
14
|
+
}
|
|
15
|
+
window.dispatchEvent(new CustomEvent('imajin:acting-as-changed', { detail: { did } }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getActingAsHeaders(): Record<string, string> {
|
|
19
|
+
const did = getActingAs();
|
|
20
|
+
return did ? { 'X-Acting-As': did } : {};
|
|
21
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ActionSheetProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
title?: string;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ReactionsProps {
|
|
13
|
+
emojis: string[];
|
|
14
|
+
onSelect: (emoji: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ActionsProps {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ActionProps {
|
|
22
|
+
icon?: string;
|
|
23
|
+
label: string;
|
|
24
|
+
onPress: () => void;
|
|
25
|
+
variant?: 'default' | 'danger';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Reactions({ emojis, onSelect }: ReactionsProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex justify-around px-4 py-3 border-b border-gray-700">
|
|
31
|
+
{emojis.map((emoji) => (
|
|
32
|
+
<button
|
|
33
|
+
key={emoji}
|
|
34
|
+
onClick={() => onSelect(emoji)}
|
|
35
|
+
className="w-11 h-11 flex items-center justify-center text-2xl hover:bg-gray-700 rounded-full transition"
|
|
36
|
+
aria-label={emoji}
|
|
37
|
+
>
|
|
38
|
+
{emoji}
|
|
39
|
+
</button>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Actions({ children }: ActionsProps) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="border-b border-gray-700 last:border-b-0">
|
|
48
|
+
{children}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Action({ icon, label, onPress, variant = 'default' }: ActionProps) {
|
|
54
|
+
return (
|
|
55
|
+
<button
|
|
56
|
+
onClick={onPress}
|
|
57
|
+
className={`w-full flex items-center gap-3 px-5 py-3.5 text-left text-sm transition hover:bg-gray-800 ${
|
|
58
|
+
variant === 'danger' ? 'text-red-400' : 'text-white'
|
|
59
|
+
}`}
|
|
60
|
+
>
|
|
61
|
+
{icon && <span className="text-lg w-6 text-center">{icon}</span>}
|
|
62
|
+
<span>{label}</span>
|
|
63
|
+
</button>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ActionSheet({ open, onClose, title, children }: ActionSheetProps) {
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!open) return;
|
|
70
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
71
|
+
if (e.key === 'Escape') onClose();
|
|
72
|
+
};
|
|
73
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
74
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
75
|
+
}, [open, onClose]);
|
|
76
|
+
|
|
77
|
+
if (!open) return null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="fixed inset-0 z-[9998] flex items-end">
|
|
81
|
+
<style>{`
|
|
82
|
+
@keyframes actionSheetSlideUp {
|
|
83
|
+
from { transform: translateY(100%); }
|
|
84
|
+
to { transform: translateY(0); }
|
|
85
|
+
}
|
|
86
|
+
`}</style>
|
|
87
|
+
{/* Backdrop */}
|
|
88
|
+
<div
|
|
89
|
+
className="absolute inset-0 bg-black/60"
|
|
90
|
+
onClick={onClose}
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
/>
|
|
93
|
+
{/* Sheet */}
|
|
94
|
+
<div
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
aria-label={title ?? 'Actions'}
|
|
98
|
+
className="relative w-full bg-gray-900 rounded-t-2xl border-t border-gray-700 max-h-[70vh] overflow-y-auto"
|
|
99
|
+
style={{ animation: 'actionSheetSlideUp 0.25s ease-out' }}
|
|
100
|
+
>
|
|
101
|
+
{/* Drag handle */}
|
|
102
|
+
<div className="flex justify-center pt-3 pb-1">
|
|
103
|
+
<div className="w-10 h-1 rounded-full bg-gray-600" />
|
|
104
|
+
</div>
|
|
105
|
+
{title && (
|
|
106
|
+
<div className="px-5 py-2 border-b border-gray-700">
|
|
107
|
+
<p className="text-sm font-medium text-gray-400 text-center">{title}</p>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ActionSheet.Reactions = Reactions;
|
|
117
|
+
ActionSheet.Actions = Actions;
|
|
118
|
+
ActionSheet.Action = Action;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import { useIdentities } from './use-identities';
|
|
5
|
+
|
|
6
|
+
export interface LauncherService {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
icon: string;
|
|
10
|
+
label: string;
|
|
11
|
+
visibility: 'public' | 'authenticated' | 'creator' | 'internal';
|
|
12
|
+
category: 'kernel' | 'core' | 'creator' | 'developer' | 'infrastructure' | 'meta';
|
|
13
|
+
url: string;
|
|
14
|
+
externalUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AppLauncherProps {
|
|
18
|
+
registryUrl: string;
|
|
19
|
+
currentService?: string;
|
|
20
|
+
tier?: 'anonymous' | 'soft' | 'hard' | 'creator';
|
|
21
|
+
/** Render inline (for mobile menu) instead of as a flyout */
|
|
22
|
+
inline?: boolean;
|
|
23
|
+
/** Layout variant: 'list' (default) or 'grid' (4-column icon grid) */
|
|
24
|
+
variant?: 'list' | 'grid';
|
|
25
|
+
/** Auth service URL — enables identity switcher when provided */
|
|
26
|
+
authUrl?: string;
|
|
27
|
+
/** If set, only show services in this list (from active identity config) */
|
|
28
|
+
enabledServices?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function filterByTier(services: LauncherService[], tier: string): LauncherService[] {
|
|
32
|
+
const hasProfile = tier === 'hard' || tier === 'creator';
|
|
33
|
+
return services.filter((s) => {
|
|
34
|
+
if (s.visibility === 'internal') return false;
|
|
35
|
+
if (s.visibility === 'public') return true;
|
|
36
|
+
if (s.visibility === 'authenticated') return hasProfile;
|
|
37
|
+
if (s.visibility === 'creator') return hasProfile;
|
|
38
|
+
return false;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function groupByCategory(services: LauncherService[]): { kernel: LauncherService[]; core: LauncherService[]; creator: LauncherService[]; developer: LauncherService[]; meta: LauncherService[] } {
|
|
43
|
+
return {
|
|
44
|
+
kernel: services.filter((s) => s.category === 'kernel'),
|
|
45
|
+
core: services.filter((s) => s.category === 'core'),
|
|
46
|
+
creator: services.filter((s) => s.category === 'creator'),
|
|
47
|
+
developer: services.filter((s) => s.category === 'developer'),
|
|
48
|
+
meta: services.filter((s) => s.category === 'meta'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function scopeIcon(scope: string): string {
|
|
53
|
+
if (scope === 'community') return '🏛️';
|
|
54
|
+
if (scope === 'org') return '🏢';
|
|
55
|
+
if (scope === 'family') return '👨👩👦';
|
|
56
|
+
if (scope === 'node') return '🖥️';
|
|
57
|
+
if (scope === 'agent') return '🤖';
|
|
58
|
+
if (scope === 'device') return '📱';
|
|
59
|
+
return '👤';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function AppLauncher({ registryUrl, currentService, tier = 'anonymous', inline = false, variant = 'list', authUrl, enabledServices }: AppLauncherProps) {
|
|
63
|
+
const [services, setServices] = useState<LauncherService[]>([]);
|
|
64
|
+
const [showPanel, setShowPanel] = useState(false);
|
|
65
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
66
|
+
|
|
67
|
+
const showIdentities = tier === 'hard' || tier === 'creator';
|
|
68
|
+
// profileUrl=null: AppLauncher doesn't use identity config (enabledServices passed from parent)
|
|
69
|
+
const { identities, activeIdentity, setActiveIdentity } = useIdentities(showIdentities && authUrl ? authUrl : null, null);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
fetch(`${registryUrl}/api/specs`)
|
|
73
|
+
.then((r) => r.ok ? r.json() : null)
|
|
74
|
+
.then((data) => {
|
|
75
|
+
if (data?.services) setServices(data.services);
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
}, [registryUrl]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!showPanel) return;
|
|
82
|
+
function handleClickOutside(event: MouseEvent) {
|
|
83
|
+
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
|
84
|
+
setShowPanel(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
88
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
89
|
+
}, [showPanel]);
|
|
90
|
+
|
|
91
|
+
let visible = filterByTier(services, tier);
|
|
92
|
+
if (enabledServices && enabledServices.length > 0) {
|
|
93
|
+
visible = visible.filter((s) => enabledServices.includes(s.name));
|
|
94
|
+
}
|
|
95
|
+
const { kernel, core, creator, developer, meta } = groupByCategory(visible);
|
|
96
|
+
|
|
97
|
+
const wwwUrl = services.find((s) => s.name === 'kernel')?.url || services.find((s) => s.name === 'www')?.url || '#';
|
|
98
|
+
|
|
99
|
+
function renderTile(service: LauncherService) {
|
|
100
|
+
const isCurrent = service.name === currentService;
|
|
101
|
+
const isExternal = !!service.externalUrl;
|
|
102
|
+
return (
|
|
103
|
+
<a
|
|
104
|
+
key={service.name}
|
|
105
|
+
href={service.url}
|
|
106
|
+
{...(isExternal && { target: '_blank', rel: 'noopener noreferrer' })}
|
|
107
|
+
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition no-underline ${
|
|
108
|
+
isCurrent
|
|
109
|
+
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 font-medium'
|
|
110
|
+
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
111
|
+
}`}
|
|
112
|
+
>
|
|
113
|
+
<span className="text-lg flex-shrink-0">{service.icon}</span>
|
|
114
|
+
<span>{service.label}</span>
|
|
115
|
+
</a>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderGridTile(service: LauncherService) {
|
|
120
|
+
const isCurrent = service.name === currentService;
|
|
121
|
+
return (
|
|
122
|
+
<a
|
|
123
|
+
key={service.name}
|
|
124
|
+
href={service.url}
|
|
125
|
+
className={`flex flex-col items-center justify-center w-16 h-16 rounded-lg text-center transition no-underline ${
|
|
126
|
+
isCurrent
|
|
127
|
+
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
|
128
|
+
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
129
|
+
}`}
|
|
130
|
+
>
|
|
131
|
+
<span className="text-2xl">{service.icon}</span>
|
|
132
|
+
<span className="text-[10px] mt-0.5 leading-tight">{service.label}</span>
|
|
133
|
+
</a>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const identitiesSection = showIdentities && (identities.length > 0 || authUrl) ? (
|
|
138
|
+
<div className="border-t border-gray-200 dark:border-gray-800 mt-1 pt-1">
|
|
139
|
+
<div className="px-3 pt-1 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
140
|
+
Switch To
|
|
141
|
+
</div>
|
|
142
|
+
{identities.map((ident) => {
|
|
143
|
+
const isActive = ident.groupDid === activeIdentity;
|
|
144
|
+
return (
|
|
145
|
+
<button
|
|
146
|
+
key={ident.groupDid}
|
|
147
|
+
onClick={() => { setActiveIdentity(isActive ? null : ident.groupDid); setShowPanel(false); }}
|
|
148
|
+
className={`w-full text-left flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition ${
|
|
149
|
+
isActive
|
|
150
|
+
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 font-medium'
|
|
151
|
+
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
152
|
+
}`}
|
|
153
|
+
>
|
|
154
|
+
<span className="text-lg flex-shrink-0">{scopeIcon(ident.scope)}</span>
|
|
155
|
+
<span>{ident.name || ident.handle || ident.groupDid.slice(0, 12)}</span>
|
|
156
|
+
{isActive && <span className="ml-auto text-amber-600 dark:text-amber-400 font-bold text-xs">✓</span>}
|
|
157
|
+
</button>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</div>
|
|
161
|
+
) : null;
|
|
162
|
+
|
|
163
|
+
const footer = (
|
|
164
|
+
<div className="border-t border-gray-200 dark:border-gray-800 mt-1 pt-1">
|
|
165
|
+
<a
|
|
166
|
+
href={`${wwwUrl}/`}
|
|
167
|
+
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition no-underline"
|
|
168
|
+
>
|
|
169
|
+
See all apps →
|
|
170
|
+
</a>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const content = variant === 'grid' ? (
|
|
175
|
+
<>
|
|
176
|
+
{kernel.length > 0 && (
|
|
177
|
+
<div>
|
|
178
|
+
<div className="col-span-4 px-3 pt-1 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
179
|
+
Kernel Services
|
|
180
|
+
</div>
|
|
181
|
+
<div className="grid grid-cols-4 gap-1 px-2">
|
|
182
|
+
{kernel.map(renderGridTile)}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
{core.length > 0 && (
|
|
187
|
+
<div className="grid grid-cols-4 gap-1 px-2">
|
|
188
|
+
{core.map(renderGridTile)}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
{creator.length > 0 && (
|
|
192
|
+
<div>
|
|
193
|
+
<div className="col-span-4 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
194
|
+
Creator Tools
|
|
195
|
+
</div>
|
|
196
|
+
<div className="grid grid-cols-4 gap-1 px-2">
|
|
197
|
+
{creator.map(renderGridTile)}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
{developer.length > 0 && (
|
|
202
|
+
<div>
|
|
203
|
+
<div className="col-span-4 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
204
|
+
Developers
|
|
205
|
+
</div>
|
|
206
|
+
<div className="grid grid-cols-4 gap-1 px-2">
|
|
207
|
+
{developer.map(renderGridTile)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
{identitiesSection}
|
|
212
|
+
{footer}
|
|
213
|
+
</>
|
|
214
|
+
) : (
|
|
215
|
+
<>
|
|
216
|
+
{kernel.length > 0 && (
|
|
217
|
+
<div>
|
|
218
|
+
<div className="px-3 pt-1 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
219
|
+
Kernel Services
|
|
220
|
+
</div>
|
|
221
|
+
{kernel.map(renderTile)}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
{core.length > 0 && (
|
|
225
|
+
<div>
|
|
226
|
+
{core.map(renderTile)}
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
{creator.length > 0 && (
|
|
230
|
+
<div>
|
|
231
|
+
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
232
|
+
Creator Tools
|
|
233
|
+
</div>
|
|
234
|
+
{creator.map(renderTile)}
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
{developer.length > 0 && (
|
|
238
|
+
<div>
|
|
239
|
+
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
240
|
+
Developers
|
|
241
|
+
</div>
|
|
242
|
+
{developer.map(renderTile)}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
{meta.length > 0 && (
|
|
246
|
+
<div>
|
|
247
|
+
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
|
248
|
+
Project
|
|
249
|
+
</div>
|
|
250
|
+
{meta.map(renderTile)}
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
{identitiesSection}
|
|
254
|
+
{footer}
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Inline mode for mobile menu
|
|
259
|
+
if (inline) {
|
|
260
|
+
return <div className="space-y-0.5">{content}</div>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="relative" ref={panelRef}>
|
|
265
|
+
<button
|
|
266
|
+
onClick={() => setShowPanel(!showPanel)}
|
|
267
|
+
className={`px-3 py-1.5 rounded-lg text-sm transition flex items-center gap-1.5 ${
|
|
268
|
+
showPanel
|
|
269
|
+
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
|
270
|
+
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
|
271
|
+
}`}
|
|
272
|
+
>
|
|
273
|
+
{variant === 'grid' ? (
|
|
274
|
+
<span className="grid grid-cols-2 gap-0.5 w-4 h-4">
|
|
275
|
+
<span className="w-1.5 h-1.5 rounded-sm bg-current" />
|
|
276
|
+
<span className="w-1.5 h-1.5 rounded-sm bg-current" />
|
|
277
|
+
<span className="w-1.5 h-1.5 rounded-sm bg-current" />
|
|
278
|
+
<span className="w-1.5 h-1.5 rounded-sm bg-current" />
|
|
279
|
+
</span>
|
|
280
|
+
) : (
|
|
281
|
+
<>
|
|
282
|
+
<span>🚀</span>
|
|
283
|
+
<span className="hidden sm:inline">Launcher</span>
|
|
284
|
+
</>
|
|
285
|
+
)}
|
|
286
|
+
</button>
|
|
287
|
+
{showPanel && (
|
|
288
|
+
<div className={`absolute left-0 mt-2 ${variant === 'grid' ? 'w-72' : 'w-56'} bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-xl py-2 z-50`}>
|
|
289
|
+
{visible.length === 0 ? (
|
|
290
|
+
<div className="px-4 py-3 text-sm text-gray-400">Loading...</div>
|
|
291
|
+
) : (
|
|
292
|
+
content
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
}
|