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