@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/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ima-jin/ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.mjs",
|
|
9
|
+
"require": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/",
|
|
15
|
+
"src/"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@ima-jin/config": "^1.0.0",
|
|
19
|
+
"@mdxeditor/editor": "^3.52.4",
|
|
20
|
+
"react": "^18.2.0",
|
|
21
|
+
"react-markdown": "^10.1.0"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Set NEXT_PUBLIC_VERSION and NEXT_PUBLIC_BUILD_HASH at build time
|
|
2
|
+
// e.g. NEXT_PUBLIC_VERSION=$(git describe --tags --always) NEXT_PUBLIC_BUILD_HASH=$(git rev-parse HEAD)
|
|
3
|
+
|
|
4
|
+
const WWW_URL = process.env.NEXT_PUBLIC_WWW_URL || "https://imajin.ai";
|
|
5
|
+
|
|
6
|
+
export function BuildInfo() {
|
|
7
|
+
const version = process.env.NEXT_PUBLIC_VERSION || "dev";
|
|
8
|
+
const hash = process.env.NEXT_PUBLIC_BUILD_HASH || "local";
|
|
9
|
+
const commitCount = process.env.NEXT_PUBLIC_COMMIT_COUNT || "";
|
|
10
|
+
const isDev = version === "dev" || version.includes("dev");
|
|
11
|
+
const display = commitCount ? `${version}+${commitCount}` : version;
|
|
12
|
+
return (
|
|
13
|
+
<a
|
|
14
|
+
href={`${WWW_URL}/build`}
|
|
15
|
+
className={`text-xs hover:underline ${isDev ? "text-yellow-600" : "text-gray-500"}`}
|
|
16
|
+
>
|
|
17
|
+
imajin {display} · build {hash.slice(0, 7)}
|
|
18
|
+
</a>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import type { DidShareList, DidShareEntry } from '@imajin/fair';
|
|
5
|
+
|
|
6
|
+
interface ResolvedProfile {
|
|
7
|
+
name: string;
|
|
8
|
+
handle?: string;
|
|
9
|
+
avatar?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Connection {
|
|
13
|
+
did: string;
|
|
14
|
+
name: string | null;
|
|
15
|
+
handle: string | null;
|
|
16
|
+
avatar?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DidShareListEditorProps {
|
|
20
|
+
value: DidShareList;
|
|
21
|
+
onChange: (value: DidShareList) => void;
|
|
22
|
+
readOnly?: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
defaultDid?: string;
|
|
25
|
+
showFixed?: boolean;
|
|
26
|
+
connectionsUrl?: string;
|
|
27
|
+
resolveProfile?: (did: string) => Promise<ResolvedProfile | null>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ROLE_OPTIONS = [
|
|
31
|
+
'creator', 'collaborator', 'producer', 'performer',
|
|
32
|
+
'platform', 'venue', 'distributor', 'label', 'other',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const SUM_TOLERANCE = 1e-6;
|
|
36
|
+
|
|
37
|
+
// ─── ResolvedDidChip ───────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function ResolvedDidChip({
|
|
40
|
+
did,
|
|
41
|
+
profile,
|
|
42
|
+
onClear,
|
|
43
|
+
readOnly,
|
|
44
|
+
}: {
|
|
45
|
+
did: string;
|
|
46
|
+
profile: ResolvedProfile | null;
|
|
47
|
+
onClear: () => void;
|
|
48
|
+
readOnly?: boolean;
|
|
49
|
+
}) {
|
|
50
|
+
const displayName = profile?.name || did.slice(0, 16) + '…';
|
|
51
|
+
const handle = profile?.handle;
|
|
52
|
+
const avatar = profile?.avatar;
|
|
53
|
+
|
|
54
|
+
const handleCopy = () => {
|
|
55
|
+
navigator.clipboard.writeText(did).catch(() => {});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className="flex-1 flex items-center gap-2 bg-[#1a1a1a] border border-gray-700 rounded px-2 py-1 min-w-0 cursor-pointer hover:border-gray-600 transition"
|
|
61
|
+
title={did}
|
|
62
|
+
onClick={handleCopy}
|
|
63
|
+
>
|
|
64
|
+
{avatar ? (
|
|
65
|
+
<img
|
|
66
|
+
src={avatar}
|
|
67
|
+
alt=""
|
|
68
|
+
className="w-5 h-5 rounded-full object-cover flex-shrink-0"
|
|
69
|
+
/>
|
|
70
|
+
) : (
|
|
71
|
+
<div className="w-5 h-5 rounded-full bg-gray-700 flex items-center justify-center text-gray-400 text-[10px] font-semibold flex-shrink-0">
|
|
72
|
+
{(displayName).charAt(0).toUpperCase()}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
<span className="text-xs text-gray-200 truncate">{displayName}</span>
|
|
76
|
+
{handle && (
|
|
77
|
+
<span className="text-xs text-gray-500 truncate">@{handle}</span>
|
|
78
|
+
)}
|
|
79
|
+
{!readOnly && (
|
|
80
|
+
<button
|
|
81
|
+
onClick={(e) => {
|
|
82
|
+
e.stopPropagation();
|
|
83
|
+
onClear();
|
|
84
|
+
}}
|
|
85
|
+
className="ml-auto text-gray-600 hover:text-red-400 transition text-xs px-1"
|
|
86
|
+
title="Clear"
|
|
87
|
+
type="button"
|
|
88
|
+
>
|
|
89
|
+
✕
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── InlineDidPicker ───────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function InlineDidPicker({
|
|
99
|
+
connectionsUrl,
|
|
100
|
+
onSelect,
|
|
101
|
+
readOnly,
|
|
102
|
+
}: {
|
|
103
|
+
connectionsUrl?: string;
|
|
104
|
+
onSelect: (did: string) => void;
|
|
105
|
+
readOnly?: boolean;
|
|
106
|
+
}) {
|
|
107
|
+
const [query, setQuery] = useState('');
|
|
108
|
+
const [open, setOpen] = useState(false);
|
|
109
|
+
const [connections, setConnections] = useState<Connection[]>([]);
|
|
110
|
+
const [loading, setLoading] = useState(false);
|
|
111
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
112
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
113
|
+
|
|
114
|
+
// Fetch connections when picker opens and URL is available
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!connectionsUrl || connections.length > 0) return;
|
|
117
|
+
setLoading(true);
|
|
118
|
+
fetch(connectionsUrl)
|
|
119
|
+
.then((r) => r.json())
|
|
120
|
+
.then((data) => {
|
|
121
|
+
const list = (data.connections || []).map((c: Record<string, unknown>) => ({
|
|
122
|
+
did: String(c.did || ''),
|
|
123
|
+
name: c.name ? String(c.name) : null,
|
|
124
|
+
handle: c.handle ? String(c.handle) : null,
|
|
125
|
+
avatar: c.avatar ? String(c.avatar) : null,
|
|
126
|
+
})) as Connection[];
|
|
127
|
+
setConnections(list);
|
|
128
|
+
})
|
|
129
|
+
.catch(() => {
|
|
130
|
+
setConnections([]);
|
|
131
|
+
})
|
|
132
|
+
.finally(() => setLoading(false));
|
|
133
|
+
}, [connectionsUrl, connections.length]);
|
|
134
|
+
|
|
135
|
+
// Close dropdown on outside click
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
function handleClick(e: MouseEvent) {
|
|
138
|
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
|
139
|
+
setOpen(false);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
document.addEventListener('mousedown', handleClick);
|
|
143
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const isRawDid = query.trim().startsWith('did:');
|
|
147
|
+
|
|
148
|
+
const filtered = useMemo(() => {
|
|
149
|
+
const q = query.trim().toLowerCase();
|
|
150
|
+
if (!q) return connections;
|
|
151
|
+
return connections.filter(
|
|
152
|
+
(c) =>
|
|
153
|
+
(c.handle || '').toLowerCase().includes(q) ||
|
|
154
|
+
(c.name || '').toLowerCase().includes(q) ||
|
|
155
|
+
c.did.toLowerCase().includes(q)
|
|
156
|
+
);
|
|
157
|
+
}, [query, connections]);
|
|
158
|
+
|
|
159
|
+
const handleSelect = (did: string) => {
|
|
160
|
+
onSelect(did);
|
|
161
|
+
setQuery('');
|
|
162
|
+
setOpen(false);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
166
|
+
if (e.key === 'Enter') {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
const trimmed = query.trim();
|
|
169
|
+
if (isRawDid) {
|
|
170
|
+
handleSelect(trimmed);
|
|
171
|
+
} else if (filtered.length > 0) {
|
|
172
|
+
handleSelect(filtered[0].did);
|
|
173
|
+
}
|
|
174
|
+
} else if (e.key === 'Escape') {
|
|
175
|
+
setOpen(false);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const showDropdown = open && (filtered.length > 0 || isRawDid || loading);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div ref={wrapperRef} className="flex-1 relative">
|
|
183
|
+
<input
|
|
184
|
+
ref={inputRef}
|
|
185
|
+
type="text"
|
|
186
|
+
value={query}
|
|
187
|
+
onChange={(e) => {
|
|
188
|
+
setQuery(e.target.value);
|
|
189
|
+
setOpen(true);
|
|
190
|
+
}}
|
|
191
|
+
onFocus={() => setOpen(true)}
|
|
192
|
+
onKeyDown={handleKeyDown}
|
|
193
|
+
placeholder={connectionsUrl ? 'Search connections or paste DID…' : 'did:key:...'}
|
|
194
|
+
readOnly={readOnly}
|
|
195
|
+
className="w-full bg-[#1a1a1a] border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-orange-500 read-only:opacity-60"
|
|
196
|
+
/>
|
|
197
|
+
{showDropdown && (
|
|
198
|
+
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-[#1a1a1a] border border-gray-700 rounded max-h-36 overflow-y-auto shadow-lg">
|
|
199
|
+
{loading ? (
|
|
200
|
+
<div className="px-3 py-2 text-xs text-gray-500">Loading…</div>
|
|
201
|
+
) : (
|
|
202
|
+
<>
|
|
203
|
+
{isRawDid && (
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => handleSelect(query.trim())}
|
|
206
|
+
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-[#252525] transition text-left"
|
|
207
|
+
type="button"
|
|
208
|
+
>
|
|
209
|
+
<span className="text-xs text-orange-400">Use raw DID:</span>
|
|
210
|
+
<span className="text-xs text-gray-300 truncate">{query.trim()}</span>
|
|
211
|
+
</button>
|
|
212
|
+
)}
|
|
213
|
+
{filtered.map((conn) => (
|
|
214
|
+
<button
|
|
215
|
+
key={conn.did}
|
|
216
|
+
onClick={() => handleSelect(conn.did)}
|
|
217
|
+
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-[#252525] transition text-left"
|
|
218
|
+
type="button"
|
|
219
|
+
>
|
|
220
|
+
{conn.avatar ? (
|
|
221
|
+
<img
|
|
222
|
+
src={conn.avatar}
|
|
223
|
+
alt=""
|
|
224
|
+
className="w-5 h-5 rounded-full object-cover flex-shrink-0"
|
|
225
|
+
/>
|
|
226
|
+
) : (
|
|
227
|
+
<div className="w-5 h-5 rounded-full bg-gray-700 flex items-center justify-center text-gray-400 text-[10px] font-semibold flex-shrink-0">
|
|
228
|
+
{(conn.name || conn.handle || conn.did).charAt(0).toUpperCase()}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
<div className="min-w-0">
|
|
232
|
+
<span className="text-xs text-gray-200 truncate">
|
|
233
|
+
{conn.name || (conn.handle ? `@${conn.handle}` : conn.did.slice(0, 20) + '…')}
|
|
234
|
+
</span>
|
|
235
|
+
{conn.handle && conn.name && (
|
|
236
|
+
<span className="text-xs text-gray-500 ml-1">@{conn.handle}</span>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
</button>
|
|
240
|
+
))}
|
|
241
|
+
{filtered.length === 0 && !isRawDid && (
|
|
242
|
+
<div className="px-3 py-2 text-xs text-gray-500">
|
|
243
|
+
{connections.length === 0 ? 'No connections available.' : 'No matches.'}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── DidShareListEditor ────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export function DidShareListEditor({
|
|
257
|
+
value,
|
|
258
|
+
onChange,
|
|
259
|
+
readOnly = false,
|
|
260
|
+
className = '',
|
|
261
|
+
defaultDid,
|
|
262
|
+
showFixed = false,
|
|
263
|
+
connectionsUrl,
|
|
264
|
+
resolveProfile,
|
|
265
|
+
}: DidShareListEditorProps) {
|
|
266
|
+
const [resolvedCache, setResolvedCache] = useState<Record<string, ResolvedProfile | null>>({});
|
|
267
|
+
|
|
268
|
+
const totalShare = useMemo(
|
|
269
|
+
() => value.reduce((sum: number, e: DidShareEntry) => sum + e.share, 0),
|
|
270
|
+
[value],
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const isValid = Math.abs(totalShare - 1.0) <= SUM_TOLERANCE;
|
|
274
|
+
const isOver = totalShare > 1.0 + SUM_TOLERANCE;
|
|
275
|
+
|
|
276
|
+
const update = (i: number, patch: Partial<DidShareEntry>) => {
|
|
277
|
+
const next = value.map((e: DidShareEntry, idx: number) => (idx === i ? { ...e, ...patch } : e));
|
|
278
|
+
onChange(next);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const remove = (i: number) => {
|
|
282
|
+
onChange(value.filter((_e: DidShareEntry, idx: number) => idx !== i));
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const add = () => {
|
|
286
|
+
onChange([
|
|
287
|
+
...value,
|
|
288
|
+
{
|
|
289
|
+
did: defaultDid ?? '',
|
|
290
|
+
role: 'collaborator',
|
|
291
|
+
share: 0,
|
|
292
|
+
},
|
|
293
|
+
]);
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Auto-resolve existing DIDs on mount / when resolveProfile changes
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (!resolveProfile) return;
|
|
299
|
+
const dids = value.map((e) => e.did).filter(Boolean);
|
|
300
|
+
const uniqueDids = [...new Set(dids)].filter((did) => !(did in resolvedCache));
|
|
301
|
+
if (uniqueDids.length === 0) return;
|
|
302
|
+
|
|
303
|
+
Promise.all(
|
|
304
|
+
uniqueDids.map(async (did) => {
|
|
305
|
+
try {
|
|
306
|
+
const profile = await resolveProfile(did);
|
|
307
|
+
return { did, profile };
|
|
308
|
+
} catch {
|
|
309
|
+
return { did, profile: null };
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
).then((results) => {
|
|
313
|
+
setResolvedCache((prev) => {
|
|
314
|
+
const next = { ...prev };
|
|
315
|
+
for (const { did, profile } of results) {
|
|
316
|
+
next[did] = profile;
|
|
317
|
+
}
|
|
318
|
+
return next;
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
|
+
}, [resolveProfile, value.map((e) => e.did).join(',')]);
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<div className={`space-y-3 ${className}`}>
|
|
326
|
+
{value.map((entry, i) => (
|
|
327
|
+
<div key={i} className="bg-[#252525] rounded-lg p-3 space-y-2">
|
|
328
|
+
<div className="flex items-center gap-2">
|
|
329
|
+
{entry.did ? (
|
|
330
|
+
<ResolvedDidChip
|
|
331
|
+
did={entry.did}
|
|
332
|
+
profile={resolvedCache[entry.did] ?? null}
|
|
333
|
+
onClear={() => update(i, { did: '' })}
|
|
334
|
+
readOnly={readOnly}
|
|
335
|
+
/>
|
|
336
|
+
) : (
|
|
337
|
+
<InlineDidPicker
|
|
338
|
+
connectionsUrl={connectionsUrl}
|
|
339
|
+
onSelect={(did) => update(i, { did })}
|
|
340
|
+
readOnly={readOnly}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
<select
|
|
344
|
+
value={entry.role}
|
|
345
|
+
onChange={(e) => update(i, { role: e.target.value })}
|
|
346
|
+
disabled={readOnly}
|
|
347
|
+
className="bg-[#1a1a1a] border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none focus:border-orange-500 disabled:opacity-60"
|
|
348
|
+
>
|
|
349
|
+
{ROLE_OPTIONS.map((r) => (
|
|
350
|
+
<option key={r} value={r}>
|
|
351
|
+
{r}
|
|
352
|
+
</option>
|
|
353
|
+
))}
|
|
354
|
+
</select>
|
|
355
|
+
{!readOnly && (
|
|
356
|
+
<button
|
|
357
|
+
onClick={() => remove(i)}
|
|
358
|
+
className="text-gray-600 hover:text-red-400 transition text-sm px-1"
|
|
359
|
+
title="Remove"
|
|
360
|
+
type="button"
|
|
361
|
+
>
|
|
362
|
+
✕
|
|
363
|
+
</button>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
<div className="flex items-center gap-2">
|
|
367
|
+
<input
|
|
368
|
+
type="range"
|
|
369
|
+
min={0}
|
|
370
|
+
max={100}
|
|
371
|
+
step={0.5}
|
|
372
|
+
value={Math.round(entry.share * 1000) / 10}
|
|
373
|
+
onChange={(e) => update(i, { share: parseFloat(e.target.value) / 100 })}
|
|
374
|
+
disabled={readOnly}
|
|
375
|
+
className="flex-1 accent-orange-500 disabled:opacity-60"
|
|
376
|
+
/>
|
|
377
|
+
<input
|
|
378
|
+
type="number"
|
|
379
|
+
min={0}
|
|
380
|
+
max={100}
|
|
381
|
+
step={0.5}
|
|
382
|
+
value={(entry.share * 100).toFixed(1)}
|
|
383
|
+
onChange={(e) => update(i, { share: parseFloat(e.target.value) / 100 })}
|
|
384
|
+
readOnly={readOnly}
|
|
385
|
+
className="w-16 bg-[#1a1a1a] border border-gray-700 rounded px-2 py-1 text-xs text-gray-200 focus:outline-none focus:border-orange-500 text-right read-only:opacity-60"
|
|
386
|
+
/>
|
|
387
|
+
<span className="text-xs text-gray-500">%</span>
|
|
388
|
+
</div>
|
|
389
|
+
<input
|
|
390
|
+
type="text"
|
|
391
|
+
value={entry.name ?? ''}
|
|
392
|
+
onChange={(e) => update(i, { name: e.target.value || undefined })}
|
|
393
|
+
placeholder="Name (optional)"
|
|
394
|
+
readOnly={readOnly}
|
|
395
|
+
className="w-full bg-[#1a1a1a] border border-gray-700 rounded px-2 py-1 text-xs text-gray-500 placeholder-gray-700 focus:outline-none focus:border-orange-500 read-only:opacity-60"
|
|
396
|
+
/>
|
|
397
|
+
</div>
|
|
398
|
+
))}
|
|
399
|
+
|
|
400
|
+
{!readOnly && (
|
|
401
|
+
<button
|
|
402
|
+
onClick={add}
|
|
403
|
+
type="button"
|
|
404
|
+
className="w-full py-1.5 rounded border border-dashed border-gray-700 text-xs text-gray-500 hover:border-orange-500 hover:text-orange-400 transition"
|
|
405
|
+
>
|
|
406
|
+
+ Add contributor
|
|
407
|
+
</button>
|
|
408
|
+
)}
|
|
409
|
+
|
|
410
|
+
<div className="flex items-center justify-between text-xs">
|
|
411
|
+
<span
|
|
412
|
+
className={
|
|
413
|
+
isValid
|
|
414
|
+
? 'text-gray-500'
|
|
415
|
+
: isOver
|
|
416
|
+
? 'text-red-400 font-medium'
|
|
417
|
+
: 'text-orange-400 font-medium'
|
|
418
|
+
}
|
|
419
|
+
>
|
|
420
|
+
Total: {(totalShare * 100).toFixed(1)}%
|
|
421
|
+
{!isValid && (
|
|
422
|
+
<span className="ml-1">
|
|
423
|
+
{isOver ? '(must be ≤ 100%)' : '(must equal 100%)'}
|
|
424
|
+
</span>
|
|
425
|
+
)}
|
|
426
|
+
</span>
|
|
427
|
+
<span className="text-gray-600">
|
|
428
|
+
{value.length} {value.length === 1 ? 'entry' : 'entries'}
|
|
429
|
+
</span>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactMarkdown from 'react-markdown';
|
|
3
|
+
|
|
4
|
+
export interface MarkdownContentProps {
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function MarkdownContent({ content }: MarkdownContentProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="prose dark:prose-invert max-w-none">
|
|
11
|
+
<ReactMarkdown
|
|
12
|
+
components={{
|
|
13
|
+
h1: ({ children }) => (
|
|
14
|
+
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">{children}</h1>
|
|
15
|
+
),
|
|
16
|
+
h2: ({ children }) => (
|
|
17
|
+
<h2 className="text-xl font-bold mb-3 text-gray-900 dark:text-white">{children}</h2>
|
|
18
|
+
),
|
|
19
|
+
h3: ({ children }) => (
|
|
20
|
+
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">{children}</h3>
|
|
21
|
+
),
|
|
22
|
+
p: ({ children }) => (
|
|
23
|
+
<p className="mb-4 leading-relaxed text-gray-700 dark:text-gray-200">{children}</p>
|
|
24
|
+
),
|
|
25
|
+
a: ({ href, children }) => (
|
|
26
|
+
<a
|
|
27
|
+
href={href}
|
|
28
|
+
className="text-orange-500 dark:text-orange-400 hover:text-orange-600 dark:hover:text-orange-300 underline"
|
|
29
|
+
target="_blank"
|
|
30
|
+
rel="noopener noreferrer"
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</a>
|
|
34
|
+
),
|
|
35
|
+
ul: ({ children }) => (
|
|
36
|
+
<ul className="list-disc list-inside mb-4 space-y-1 text-gray-700 dark:text-gray-200">
|
|
37
|
+
{children}
|
|
38
|
+
</ul>
|
|
39
|
+
),
|
|
40
|
+
ol: ({ children }) => (
|
|
41
|
+
<ol className="list-decimal list-inside mb-4 space-y-1 text-gray-700 dark:text-gray-200">
|
|
42
|
+
{children}
|
|
43
|
+
</ol>
|
|
44
|
+
),
|
|
45
|
+
li: ({ children }) => (
|
|
46
|
+
<li className="text-gray-700 dark:text-gray-200">{children}</li>
|
|
47
|
+
),
|
|
48
|
+
blockquote: ({ children }) => (
|
|
49
|
+
<blockquote className="border-l-4 border-orange-500 pl-4 my-4 italic text-gray-500 dark:text-gray-400">
|
|
50
|
+
{children}
|
|
51
|
+
</blockquote>
|
|
52
|
+
),
|
|
53
|
+
strong: ({ children }) => (
|
|
54
|
+
<strong className="font-bold text-gray-900 dark:text-white">{children}</strong>
|
|
55
|
+
),
|
|
56
|
+
em: ({ children }) => (
|
|
57
|
+
<em className="italic text-gray-700 dark:text-gray-200">{children}</em>
|
|
58
|
+
),
|
|
59
|
+
code: ({ children }) => (
|
|
60
|
+
<code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-sm font-mono text-orange-600 dark:text-orange-300">
|
|
61
|
+
{children}
|
|
62
|
+
</code>
|
|
63
|
+
),
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{content}
|
|
67
|
+
</ReactMarkdown>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
MDXEditor,
|
|
6
|
+
headingsPlugin,
|
|
7
|
+
listsPlugin,
|
|
8
|
+
quotePlugin,
|
|
9
|
+
linkPlugin,
|
|
10
|
+
linkDialogPlugin,
|
|
11
|
+
toolbarPlugin,
|
|
12
|
+
markdownShortcutPlugin,
|
|
13
|
+
BoldItalicUnderlineToggles,
|
|
14
|
+
BlockTypeSelect,
|
|
15
|
+
CreateLink,
|
|
16
|
+
ListsToggle,
|
|
17
|
+
Separator,
|
|
18
|
+
} from '@mdxeditor/editor';
|
|
19
|
+
import '@mdxeditor/editor/style.css';
|
|
20
|
+
|
|
21
|
+
export interface MarkdownEditorProps {
|
|
22
|
+
value: string;
|
|
23
|
+
onChange: (value: string) => void;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
maxLength?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function MarkdownEditor({ value, onChange, placeholder, maxLength }: MarkdownEditorProps) {
|
|
29
|
+
const handleChange = (md: string) => {
|
|
30
|
+
if (maxLength !== undefined && md.length > maxLength) return;
|
|
31
|
+
onChange(md);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className="rounded-lg border border-gray-600 overflow-hidden"
|
|
37
|
+
style={
|
|
38
|
+
{
|
|
39
|
+
'--baseBg': '#1a1a1a',
|
|
40
|
+
'--basePageBg': '#1a1a1a',
|
|
41
|
+
'--baseTextContrast': '#e5e7eb',
|
|
42
|
+
'--baseText': '#d1d5db',
|
|
43
|
+
'--baseBorder': '#374151',
|
|
44
|
+
'--accentBase': '#f97316',
|
|
45
|
+
'--accentBgHover': 'rgba(249,115,22,0.15)',
|
|
46
|
+
'--accentTextContrast': '#f97316',
|
|
47
|
+
} as React.CSSProperties
|
|
48
|
+
}
|
|
49
|
+
>
|
|
50
|
+
<MDXEditor
|
|
51
|
+
markdown={value}
|
|
52
|
+
onChange={handleChange}
|
|
53
|
+
placeholder={placeholder}
|
|
54
|
+
className="dark-theme dark-editor"
|
|
55
|
+
plugins={[
|
|
56
|
+
headingsPlugin({ allowedHeadingLevels: [1, 2, 3] }),
|
|
57
|
+
listsPlugin(),
|
|
58
|
+
quotePlugin(),
|
|
59
|
+
linkPlugin(),
|
|
60
|
+
linkDialogPlugin(),
|
|
61
|
+
markdownShortcutPlugin(),
|
|
62
|
+
toolbarPlugin({
|
|
63
|
+
toolbarContents: () => (
|
|
64
|
+
<>
|
|
65
|
+
<BlockTypeSelect />
|
|
66
|
+
<Separator />
|
|
67
|
+
<BoldItalicUnderlineToggles options={['Bold', 'Italic']} />
|
|
68
|
+
<Separator />
|
|
69
|
+
<CreateLink />
|
|
70
|
+
<Separator />
|
|
71
|
+
<ListsToggle />
|
|
72
|
+
</>
|
|
73
|
+
),
|
|
74
|
+
}),
|
|
75
|
+
]}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|