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