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.
177 lines
8.3 KiB
TypeScript
177 lines
8.3 KiB
TypeScript
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>
|
||
);
|
||
}
|