Skip to content

Commit e8c0f94

Browse files
committed
minor fixes - add plugins support
1 parent e6ed18c commit e8c0f94

65 files changed

Lines changed: 1933 additions & 300 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ WALLET_RPC_PASSWORD=wallet_password_change_me
2222
# init.sh writes this automatically. Edit only to change the network.
2323
WALLET_RPC_CMD=wallet-rpc-daemon mainnet
2424

25+
# Network: mainnet or testnet. Must match WALLET_RPC_CMD above.
26+
# Controls explorer links, address prefixes, and other network-specific behaviour.
27+
NETWORK=mainnet
28+
2529
# ── Web GUI ───────────────────────────────────────────
2630
# Port the Astro web interface listens on (host port).
2731
WEB_GUI_PORT=4321

PLUGINS.md

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,40 @@ tar -czf myplugin.tgz myplugin/plugin.json myplugin/index.mjs
4444

4545
## plugin.json — manifest
4646

47-
| Field | Type | Description |
48-
|---|---|---|
49-
| `id` | string | Unique identifier. **Lowercase letters, digits, and hyphens only** — used as a URL path segment and directory name. |
50-
| `name` | string | Human-readable name shown in the Plugins management page. |
51-
| `navLabel` | string | Short label shown in the top navigation bar when the plugin is enabled. |
52-
| `version` | string | Semver-style version string, shown in the Plugins list. |
53-
| `entry` | string | Filename of the ESM handler module, relative to the archive root. |
47+
| Field | Type | Required | Description |
48+
|---|---|---|---|
49+
| `id` | string || Unique identifier. **Lowercase letters, digits, and hyphens only** — used as a URL path segment and directory name. |
50+
| `name` | string || Human-readable name shown in the Plugins management page. |
51+
| `navLabel` | string || Short label shown in the sidebar when the plugin is enabled. |
52+
| `version` | string || Semver-style version string, shown in the Plugins list. |
53+
| `entry` | string || Filename of the ESM handler module, relative to the archive root. |
54+
| `navSection` | string || Sidebar section to place the plugin in. One of `"wallet"`, `"assets"`, `"trade"`, `"apps"`. Defaults to `"apps"`. |
55+
| `navIcon` | string || SVG `d` attribute of a single Heroicons-outline-style path (`viewBox="0 0 24 24"`, `strokeWidth="1.5"`). Defaults to a wrench icon. |
5456

5557
```json
5658
{
5759
"id": "my-plugin",
5860
"name": "My Plugin",
5961
"navLabel": "My Plugin",
6062
"version": "1.0.0",
61-
"entry": "index.mjs"
63+
"entry": "index.mjs",
64+
"navSection": "apps",
65+
"navIcon": "M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18"
6266
}
6367
```
6468

6569
**ID rules:** must match `/^[a-z0-9][a-z0-9-]*[a-z0-9]$/`. No uppercase, no leading/trailing dash, minimum 2 characters. The ID becomes the URL path (`/plugins/{id}/`) and the directory name on disk.
6670

71+
### Choosing a `navIcon`
72+
73+
The icon is the `d` attribute value of a single SVG path. Use any [Heroicons outline icon](https://heroicons.com/) (24px, strokeWidth 1.5). Copy the `d` value from the SVG source:
74+
75+
```json
76+
"navIcon": "M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18.75-9v9m0 0h-.375a.75.75 0 01-.75-.75V18M18.75 15h.375A.75.75 0 0120.25 15v3.75m-18 0H3.75m0 0a.75.75 0 00-.75.75V20.25m.75-.75H12"
77+
```
78+
79+
The wrench icon is used when `navIcon` is omitted.
80+
6781
---
6882

6983
## index.mjs — handler module

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "mintlayer-web-gui",
33
"type": "module",
4-
"version": "0.99.6",
4+
"version": "0.99.7",
55
"scripts": {
66
"dev": "astro dev",
77
"build": "astro build",
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
2+
3+
export interface NavItem {
4+
key: string;
5+
label: string;
6+
href: string;
7+
section: string;
8+
}
9+
10+
interface Props {
11+
items: NavItem[];
12+
}
13+
14+
function fuzzyMatch(q: string, t: string): boolean {
15+
q = q.toLowerCase();
16+
t = t.toLowerCase();
17+
let qi = 0;
18+
for (let i = 0; i < t.length && qi < q.length; i++) {
19+
if (t[i] === q[qi]) qi++;
20+
}
21+
return qi === q.length;
22+
}
23+
24+
const RECENT_KEY = 'recently_visited';
25+
const MAX_RECENT = 5;
26+
27+
export default function CommandPalette({ items }: Props) {
28+
const [open, setOpen] = useState(false);
29+
const [query, setQuery] = useState('');
30+
const [selectedIndex, setSelectedIndex] = useState(0);
31+
const [recentRoutes, setRecentRoutes] = useState<string[]>([]);
32+
const inputRef = useRef<HTMLInputElement>(null);
33+
const listRef = useRef<HTMLDivElement>(null);
34+
35+
// Record current page visit on mount
36+
useEffect(() => {
37+
try {
38+
const raw = localStorage.getItem(RECENT_KEY);
39+
const prev: string[] = raw ? JSON.parse(raw) : [];
40+
const current = window.location.pathname;
41+
const updated = [current, ...prev.filter(r => r !== current)].slice(0, MAX_RECENT);
42+
localStorage.setItem(RECENT_KEY, JSON.stringify(updated));
43+
setRecentRoutes(updated);
44+
} catch {
45+
// ignore localStorage errors
46+
}
47+
}, []);
48+
49+
// Listen for global Cmd+K and cp:open event
50+
useEffect(() => {
51+
const onKey = (e: KeyboardEvent) => {
52+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
53+
e.preventDefault();
54+
setOpen(true);
55+
}
56+
};
57+
const onOpen = () => setOpen(true);
58+
document.addEventListener('keydown', onKey);
59+
document.addEventListener('cp:open', onOpen);
60+
return () => {
61+
document.removeEventListener('keydown', onKey);
62+
document.removeEventListener('cp:open', onOpen);
63+
};
64+
}, []);
65+
66+
// Focus input when opened, reset state
67+
useEffect(() => {
68+
if (open) {
69+
setQuery('');
70+
setSelectedIndex(0);
71+
setTimeout(() => inputRef.current?.focus(), 0);
72+
}
73+
}, [open]);
74+
75+
const displayItems = useMemo(() => {
76+
if (!query.trim()) {
77+
const recent = recentRoutes
78+
.map(route => items.find(i => i.href === route || (route.startsWith(i.href) && i.href !== '/')))
79+
.filter((x): x is NavItem => x !== undefined);
80+
// Deduplicate
81+
const seen = new Set<string>();
82+
return recent.filter(i => seen.has(i.key) ? false : (seen.add(i.key), true));
83+
}
84+
return items.filter(
85+
item => fuzzyMatch(query, item.label) || fuzzyMatch(query, item.section)
86+
);
87+
}, [query, items, recentRoutes]);
88+
89+
const grouped = useMemo(() => {
90+
const result: Record<string, NavItem[]> = {};
91+
for (const item of displayItems) {
92+
(result[item.section] ??= []).push(item);
93+
}
94+
return result;
95+
}, [displayItems]);
96+
97+
// Flat ordered list for keyboard nav
98+
const flat = useMemo(() => displayItems, [displayItems]);
99+
100+
const navigate = useCallback((item: NavItem) => {
101+
setOpen(false);
102+
window.location.href = item.href;
103+
}, []);
104+
105+
const onKeyDown = (e: React.KeyboardEvent) => {
106+
if (e.key === 'Escape') {
107+
setOpen(false);
108+
} else if (e.key === 'ArrowDown') {
109+
e.preventDefault();
110+
setSelectedIndex(i => Math.min(i + 1, flat.length - 1));
111+
} else if (e.key === 'ArrowUp') {
112+
e.preventDefault();
113+
setSelectedIndex(i => Math.max(i - 1, 0));
114+
} else if (e.key === 'Enter') {
115+
const item = flat[selectedIndex];
116+
if (item) navigate(item);
117+
}
118+
};
119+
120+
// Scroll selected item into view
121+
useEffect(() => {
122+
const el = listRef.current?.querySelector(`[data-idx="${selectedIndex}"]`);
123+
el?.scrollIntoView({ block: 'nearest' });
124+
}, [selectedIndex]);
125+
126+
// Reset selection when results change
127+
useEffect(() => {
128+
setSelectedIndex(0);
129+
}, [query]);
130+
131+
if (!open) return null;
132+
133+
const sectionOrder = ['WALLET', 'ASSETS', 'TRADE', 'PLUGINS'];
134+
const sections = [
135+
...sectionOrder.filter(s => grouped[s]),
136+
...Object.keys(grouped).filter(s => !sectionOrder.includes(s)),
137+
];
138+
139+
let flatIdx = 0;
140+
const sectionItems = sections.map(section => {
141+
const sectionNavItems = grouped[section].map(item => {
142+
const idx = flatIdx++;
143+
return { item, idx };
144+
});
145+
return { section, items: sectionNavItems };
146+
});
147+
148+
const emptyState = flat.length === 0;
149+
150+
return (
151+
<div
152+
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
153+
role="dialog"
154+
aria-modal="true"
155+
aria-label="Command palette"
156+
onKeyDown={onKeyDown}
157+
onClick={e => { if (e.target === e.currentTarget) setOpen(false); }}
158+
>
159+
{/* Backdrop */}
160+
<div className="absolute inset-0 bg-black/60" onClick={() => setOpen(false)} />
161+
162+
{/* Modal */}
163+
<div className="relative w-full max-w-lg mx-4 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl overflow-hidden">
164+
{/* Search input */}
165+
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
166+
<svg
167+
className="w-4 h-4 text-gray-500 shrink-0"
168+
fill="none"
169+
viewBox="0 0 24 24"
170+
strokeWidth={1.5}
171+
stroke="currentColor"
172+
>
173+
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
174+
</svg>
175+
<input
176+
ref={inputRef}
177+
type="text"
178+
role="combobox"
179+
aria-autocomplete="list"
180+
aria-controls="cp-listbox"
181+
aria-activedescendant={flat[selectedIndex] ? `cp-option-${selectedIndex}` : undefined}
182+
placeholder="Go to..."
183+
value={query}
184+
onChange={e => setQuery(e.target.value)}
185+
className="flex-1 bg-transparent text-gray-100 placeholder-gray-500 text-sm outline-none"
186+
/>
187+
<kbd className="text-xs text-gray-600 border border-gray-700 rounded px-1.5 py-0.5 font-mono">
188+
esc
189+
</kbd>
190+
</div>
191+
192+
{/* Results */}
193+
<div
194+
ref={listRef}
195+
id="cp-listbox"
196+
role="listbox"
197+
className="max-h-80 overflow-y-auto py-2"
198+
>
199+
{!query.trim() && flat.length > 0 && (
200+
<p className="px-4 pb-1 text-xs text-gray-600 uppercase tracking-wider">Recent</p>
201+
)}
202+
{emptyState && (
203+
<p className="px-4 py-6 text-center text-sm text-gray-500">No results found.</p>
204+
)}
205+
{sectionItems.map(({ section, items: sectionNavItems }) => (
206+
<div key={section} role="group" aria-label={section}>
207+
{query.trim() && (
208+
<p className="px-4 pt-2 pb-1 text-xs text-gray-600 uppercase tracking-wider">
209+
{section}
210+
</p>
211+
)}
212+
{sectionNavItems.map(({ item, idx }) => (
213+
<button
214+
key={item.key}
215+
id={`cp-option-${idx}`}
216+
role="option"
217+
aria-selected={idx === selectedIndex}
218+
data-idx={idx}
219+
onClick={() => navigate(item)}
220+
onMouseEnter={() => setSelectedIndex(idx)}
221+
className={[
222+
'w-full flex items-center gap-3 px-4 py-2 text-sm transition-colors text-left',
223+
idx === selectedIndex
224+
? 'bg-mint-600/20 text-mint-400'
225+
: 'text-gray-300 hover:bg-gray-800',
226+
].join(' ')}
227+
>
228+
<span className="flex-1">{item.label}</span>
229+
<span className="text-xs text-gray-600">{item.section}</span>
230+
</button>
231+
))}
232+
</div>
233+
))}
234+
</div>
235+
236+
{/* Footer hint */}
237+
<div className="border-t border-gray-800 px-4 py-2 flex items-center gap-4 text-xs text-gray-600">
238+
<span><kbd className="font-mono">↑↓</kbd> navigate</span>
239+
<span><kbd className="font-mono"></kbd> go</span>
240+
<span><kbd className="font-mono">esc</kbd> close</span>
241+
</div>
242+
</div>
243+
</div>
244+
);
245+
}

app/src/components/DelegationPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ export default function DelegationPanel({ poolId, initialDelegations, network }:
274274
/>
275275
))}
276276

277-
{/* Create form only shown when no delegation exists */}
277+
{/* Create form - only shown when no delegation exists */}
278278
{!hasDelegation && (
279279
<div className="rounded-lg bg-gray-800/50 border border-gray-700/50 p-4 space-y-3">
280280
<p className="text-xs text-gray-400">

app/src/components/IssueNFTModal.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props)
101101
const uploadPromise = ipfsEnabled ? uploadToIPFS(file) : Promise.resolve('');
102102
const [hash, url] = await Promise.all([hashPromise, uploadPromise]);
103103
if (ipfsEnabled && !url) {
104-
throw new Error('IPFS upload succeeded but returned no URL cannot proceed without media URI');
104+
throw new Error('IPFS upload succeeded but returned no URL - cannot proceed without media URI');
105105
}
106106
// Chain max is 32 bytes; SHA-256 hex is 64 chars → truncate to 32
107107
setMediaHash(hash.slice(0, 32));
@@ -257,7 +257,7 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props)
257257
<p className="text-xs text-gray-500 mt-1">Short identifier, up to 5 characters</p>
258258
</div>
259259

260-
{/* Media file always shown, but label differs by mode */}
260+
{/* Media file - always shown, but label differs by mode */}
261261
<div>
262262
<label className="block text-xs text-gray-400 mb-1">
263263
Media file <span className="text-red-400">*</span>
@@ -276,15 +276,15 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props)
276276
<p className="text-xs text-gray-500 font-mono">SHA-256: {mediaHash.slice(0, 16)}{mediaHash.slice(-8)}</p>
277277
)}
278278
{mediaUri && <p className="text-xs text-mint-400">Uploaded to IPFS</p>}
279-
<p className="text-xs text-gray-600 mt-1">{mediaFile?.name} Click to replace</p>
279+
<p className="text-xs text-gray-600 mt-1">{mediaFile?.name} - Click to replace</p>
280280
</div>
281281
) : (
282282
<div className="space-y-1">
283283
<p className="text-sm text-gray-400">{mediaUploading ? 'Processing…' : 'Click to select media file'}</p>
284284
<p className="text-xs text-gray-600">
285285
{ipfsEnabled
286286
? 'Hash computed + uploaded to IPFS automatically'
287-
: 'Hash computed locally enter IPFS URL manually below if needed'}
287+
: 'Hash computed locally - enter IPFS URL manually below if needed'}
288288
</p>
289289
</div>
290290
)}
@@ -363,7 +363,7 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props)
363363
<label className="block text-xs text-gray-400 mb-1">Icon image <span className="text-gray-500">(optional)</span></label>
364364
<label className={`flex items-center gap-3 rounded-lg border border-dashed border-gray-700 px-4 py-3 cursor-pointer hover:border-gray-500 hover:bg-gray-800/40 transition-colors ${iconUploading ? 'opacity-60 pointer-events-none' : ''}`}>
365365
{iconUri ? (
366-
<span className="text-xs text-mint-400">Uploaded {iconFile?.name ?? 'icon'}</span>
366+
<span className="text-xs text-mint-400">Uploaded - {iconFile?.name ?? 'icon'}</span>
367367
) : iconUploading ? (
368368
<span className="text-sm text-gray-400">Uploading…</span>
369369
) : (
@@ -396,7 +396,7 @@ export default function IssueNFTModal({ ipfsEnabled, onClose, onIssued }: Props)
396396
required
397397
className={inp}
398398
/>
399-
<p className="text-xs text-gray-500 mt-1">Your address NFT will be sent here.</p>
399+
<p className="text-xs text-gray-500 mt-1">Your address - NFT will be sent here.</p>
400400
</div>
401401

402402
{error && (

app/src/components/IssueTokenModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export default function IssueTokenModal({ ipfsEnabled, onClose, onIssued }: Prop
139139
const stored = JSON.parse(localStorage.getItem('ml_issued_tokens') ?? '[]') as Array<{ tokenId: string; ticker: string; decimals: number; issuedAt: number }>;
140140
stored.unshift({ tokenId: newTokenId, ticker, decimals, issuedAt: Date.now() });
141141
localStorage.setItem('ml_issued_tokens', JSON.stringify(stored));
142-
} catch { /* ignore non-critical */ }
142+
} catch { /* ignore - non-critical */ }
143143
} catch (err) {
144144
setError((err as Error).message);
145145
} finally {
@@ -339,7 +339,7 @@ export default function IssueTokenModal({ ipfsEnabled, onClose, onIssued }: Prop
339339
required
340340
className={input}
341341
/>
342-
<p className="text-xs text-gray-500 mt-1">Your address will have authority to mint, freeze, and modify this token.</p>
342+
<p className="text-xs text-gray-500 mt-1">Your address - will have authority to mint, freeze, and modify this token.</p>
343343
</div>
344344

345345
{error && (

0 commit comments

Comments
 (0)