dgb-wallet/packages/web-ui/src/WalletSidebar.tsx

178 lines
8.3 KiB
TypeScript
Raw Normal View History

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>
);
}