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