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

192 lines
9.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>}
{i === 0 && <span className="side-wallet-tag muted">default</span>}
</button>
{/* Make default — moves the wallet to the top of the
order, which is the one auto-opened after unlock
(same thing dragging it to the top does). */}
{i > 0 && (
<button
className="side-wallet-default"
onClick={() => move(i, 0)}
aria-label={`Make ${w.label} the default wallet`}
title="Make default (opens first after unlock)"
>
<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 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</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>
);
}