dgb-wallet/packages/web-ui/src/WalletSidebar.tsx
Digibyte.X d50286a9a5 DigiByte.X Wallet — recovery-first web wallet monorepo
Packages: core (BIP39/32/44/49/84/86 derivation, WIF), keystore (PBKDF2 +
AES-GCM), psbt, p2p (BIP157/158 SPV daemon, Dandelion++ broadcast with
relay-echo verification), blockbook, rpc, electrum, net, bns, web-ui
(React/Vite), mobile (Expo). ops/ holds the nginx vhost, systemd unit and
push.sh deploy script for digibyte.x/web.
2026-09-20 20:16:52 +02:00

177 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { Keystore, LocalStorageAdapter } from '@dgb-wallet/keystore';
import { useActiveSeed } from './ActiveSeed.js';
import { useOrderedWallets, useListDrag, renameWallet, notifyWalletsChanged } from './wallet-order.js';
const keystore = new Keystore(new LocalStorageAdapter());
// Left sidebar shown on desktop (>=1024px). Lists every saved wallet
// with click-to-switch, plus the Create/Import CTAs. Mirrors the
// WalletMenu dropdown that stays available on mobile — same actions,
// permanent surface. Keeps the current wallet visually highlighted.
// No Explorer / Settings entries here — both live as pills in the
// dashboard's right-side panel, so a copy here was a duplicate.
//
// Rows are drag-and-drop reorderable; the order persists (wallet-order.ts)
// and is shared with the wallet menu and the login picker. The first
// wallet in the list is the one that opens automatically after unlock.
export function WalletSidebar({
onSwitchWallet,
onCreate,
onImport,
}: {
onSwitchWallet: (label: string) => void;
onCreate: () => void;
onImport: () => void;
}): JSX.Element {
const { active, relabel } = useActiveSeed();
const { wallets, move, reload } = useOrderedWallets(active?.setAt);
const drag = useListDrag(move);
// Inline rename: hover a row → pencil → the label becomes an input.
// Enter saves, Escape cancels. Errors (duplicate / empty) show under
// the row until the next keystroke.
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [renameErr, setRenameErr] = useState<string | null>(null);
function startRename(label: string): void { setEditing(label); setDraft(label); setRenameErr(null); }
function cancelRename(): void { setEditing(null); setRenameErr(null); }
async function commitRename(): Promise<void> {
if (!editing) return;
const from = editing;
try {
const to = await renameWallet(from, draft);
if (active?.label === from) relabel(to);
setEditing(null); setRenameErr(null);
reload();
} catch (e) {
setRenameErr((e as Error).message.replace(/^keystore: /, ''));
}
}
// Delete an encrypted seed from the keystore. Guarded elsewhere so it
// never runs on the currently-active wallet — the button is disabled
// for that row and this function double-checks defensively. Uses the
// native confirm dialog because deletion is irreversible and typing
// through a modal for this would just be noise.
async function forgetWallet(label: string): Promise<void> {
if (active?.label === label) return;
if (!confirm(`Delete encrypted seed "${label}"? This can't be undone. If you don't have the seed phrase written down, the funds become unrecoverable.`)) return;
await keystore.delete(label);
notifyWalletsChanged();
reload();
}
return (
<aside className="side-panel side-panel-left">
<div className="side-section">
<div className="side-heading">Wallets</div>
<ul className="side-wallet-list">
{wallets.length === 0 && (
<li className="side-empty">No saved wallets — Create or Import below.</li>
)}
{wallets.map((w, i) => {
const isActive = active?.label === w.label;
const cls = [
'side-wallet-row',
drag.dragging === i ? 'dragging' : '',
drag.over === i && drag.dragging !== null && drag.dragging !== i ? 'drag-over' : '',
].join(' ');
const isEditing = editing === w.label;
return (
<li key={w.label} className={cls} {...(isEditing ? {} : drag.rowProps(i))}>
<span className="drag-handle" aria-hidden="true" title="Drag to reorder">⋮⋮</span>
{isEditing ? (
<div className="side-wallet side-wallet-edit">
<span className={`side-wallet-dot ${isActive ? 'active' : ''}`} aria-hidden="true" />
<input
className="side-wallet-input"
value={draft}
onChange={(e) => { setDraft(e.target.value); setRenameErr(null); }}
onKeyDown={(e) => {
if (e.key === 'Enter') void commitRename();
if (e.key === 'Escape') cancelRename();
}}
onBlur={() => { if (!renameErr) void commitRename(); }}
aria-label="New wallet name"
maxLength={40}
autoFocus
/>
{renameErr && <span className="side-wallet-err">{renameErr}</span>}
</div>
) : (
<>
<button
className={`side-wallet ${isActive ? 'active' : ''}`}
onClick={() => onSwitchWallet(w.label)}
onDoubleClick={() => startRename(w.label)}
disabled={isActive}
title={isActive ? 'Currently open' : `Switch to ${w.label}`}
>
<span className="side-wallet-dot" aria-hidden="true" />
<span className="side-wallet-label">{w.label}</span>
{isActive && <span className="side-wallet-tag">active</span>}
{!isActive && i === 0 && <span className="side-wallet-tag muted">default</span>}
</button>
<button
className="side-wallet-rename"
onClick={() => startRename(w.label)}
aria-label={`Rename ${w.label}`}
title="Rename"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
{/* Forget button — disabled on the active row so you
can't delete the wallet you're currently in from
under yourself. Switch to another wallet first,
then the trash affordance appears on this row. */}
<button
className="side-wallet-forget"
onClick={() => void forgetWallet(w.label)}
disabled={isActive}
aria-label={`Forget ${w.label}`}
title={isActive ? 'Switch to another wallet first to delete this one' : `Forget ${w.label} (delete encrypted seed)`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
</button>
</>
)}
</li>
);
})}
</ul>
</div>
<div className="side-section">
<div className="side-heading">Add wallet</div>
<button className="side-action" onClick={onCreate}>
<span className="side-action-icon" aria-hidden="true">+</span>
<span>Create new</span>
</button>
<button className="side-action" onClick={onImport}>
<span className="side-action-icon" aria-hidden="true">↓</span>
<span>Import existing</span>
</button>
</div>
{/* Parent-relative href — resolves against the runtime <base> the
inline script anchors at <origin>…/web/, so it lands on the
marketing landing page under both digibyte.x/ and the
navigate.st gateway. Plain <a> so it does a full-page nav out
of the SPA into the static site. */}
<div className="side-section side-section-bottom">
<div className="side-heading">About</div>
<a className="side-action" href="../" title="Open the digibyte.x landing page">
<span className="side-action-icon" aria-hidden="true">←</span>
<span>digibyte.x home</span>
</a>
</div>
</aside>
);
}