mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
492 lines
31 KiB
TypeScript
492 lines
31 KiB
TypeScript
/**
|
|
* @file SeedBlender.tsx
|
|
* @summary Main component for the Seed Blending feature.
|
|
* @description This component provides a full UI for the multi-step seed blending process,
|
|
* handling various input formats, per-row decryption, and final output actions.
|
|
*/
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Key, ArrowRight } from 'lucide-react';
|
|
import QRScanner from './QRScanner';
|
|
import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp';
|
|
import { decryptFromKrux } from '../lib/krux';
|
|
import { decodeSeedQR } from '../lib/seedqr'; // New import
|
|
import { QrDisplay } from './QrDisplay';
|
|
import {
|
|
blendMnemonicsAsync,
|
|
checkXorStrength,
|
|
mnemonicToEntropy,
|
|
DiceStats,
|
|
calculateDiceStats,
|
|
detectBadPatterns,
|
|
diceToBytes,
|
|
hkdfExtractExpand,
|
|
entropyToMnemonic,
|
|
mixWithDiceAsync,
|
|
} from '../lib/seedblend';
|
|
|
|
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
return (...args: Parameters<F>): void => {
|
|
if (timeout) clearTimeout(timeout);
|
|
timeout = setTimeout(() => func(...args), waitFor);
|
|
};
|
|
}
|
|
|
|
interface MnemonicEntry {
|
|
id: number;
|
|
rawInput: string;
|
|
decryptedMnemonic: string | null;
|
|
isEncrypted: boolean;
|
|
inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr';
|
|
passwordRequired: boolean;
|
|
passwordInput: string;
|
|
error: string | null;
|
|
isValid: boolean | null;
|
|
}
|
|
|
|
let nextId = 0;
|
|
const createNewEntry = (): MnemonicEntry => ({
|
|
id: nextId++, rawInput: '', decryptedMnemonic: null, isEncrypted: false,
|
|
inputType: 'text', passwordRequired: false, passwordInput: '', error: null, isValid: null,
|
|
});
|
|
|
|
interface SeedBlenderProps {
|
|
onDirtyStateChange: (isDirty: boolean) => void;
|
|
setMnemonicForBackup: (mnemonic: string) => void;
|
|
requestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender') => void;
|
|
incomingSeed?: string; // NEW: seed from Create tab
|
|
onSeedReceived?: () => void; // NEW: callback after seed added
|
|
}
|
|
|
|
export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestTabChange, incomingSeed, onSeedReceived }: SeedBlenderProps) {
|
|
const processedSeedsRef = useRef<Set<string>>(new Set());
|
|
const [entries, setEntries] = useState<MnemonicEntry[]>([createNewEntry()]);
|
|
const [showQRScanner, setShowQRScanner] = useState(false);
|
|
const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null);
|
|
const [blendedResult, setBlendedResult] = useState<{ blendedEntropy: Uint8Array; blendedMnemonic12: string; blendedMnemonic24?: string; } | null>(null);
|
|
const [xorStrength, setXorStrength] = useState<{ isWeak: boolean; uniqueBytes: number; } | null>(null);
|
|
const [blendError, setBlendError] = useState<string>('');
|
|
const [blending, setBlending] = useState(false);
|
|
const [diceRolls, setDiceRolls] = useState('');
|
|
const [diceStats, setDiceStats] = useState<DiceStats | null>(null);
|
|
const [dicePatternWarning, setDicePatternWarning] = useState<string | null>(null);
|
|
const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState<string | null>(null);
|
|
const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null);
|
|
const [mixing, setMixing] = useState(false);
|
|
const [showFinalQR, setShowFinalQR] = useState(false);
|
|
const [copiedFinal, setCopiedFinal] = useState(false);
|
|
|
|
const [targetWordCount, setTargetWordCount] = useState<12 | 24>(24);
|
|
useEffect(() => {
|
|
const isDirty = entries.some(e => e.rawInput.length > 0) || diceRolls.length > 0;
|
|
onDirtyStateChange(isDirty);
|
|
}, [entries, diceRolls, onDirtyStateChange]);
|
|
|
|
const addSeedEntry = (seed: string) => {
|
|
setEntries(currentEntries => {
|
|
const emptyEntryIndex = currentEntries.findIndex(e => !e.rawInput.trim());
|
|
if (emptyEntryIndex !== -1) {
|
|
return currentEntries.map((entry, index) =>
|
|
index === emptyEntryIndex
|
|
? { ...entry, rawInput: seed, decryptedMnemonic: seed, isValid: null, error: null }
|
|
: entry
|
|
);
|
|
} else {
|
|
const newEntry = createNewEntry();
|
|
newEntry.rawInput = seed;
|
|
newEntry.decryptedMnemonic = seed;
|
|
return [...currentEntries, newEntry];
|
|
}
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (incomingSeed && incomingSeed.trim()) {
|
|
// Check if we've already processed this exact seed
|
|
if (!processedSeedsRef.current.has(incomingSeed)) {
|
|
const isDuplicate = entries.some(e => e.decryptedMnemonic === incomingSeed);
|
|
if (!isDuplicate) {
|
|
addSeedEntry(incomingSeed);
|
|
processedSeedsRef.current.add(incomingSeed);
|
|
}
|
|
}
|
|
// Always notify parent to clear the incoming seed
|
|
onSeedReceived?.();
|
|
}
|
|
}, [incomingSeed]);
|
|
|
|
useEffect(() => {
|
|
const processEntries = async () => {
|
|
setBlending(true);
|
|
setBlendError('');
|
|
const validMnemonics = entries.map(e => e.decryptedMnemonic).filter((m): m is string => m !== null && m.length > 0);
|
|
|
|
const validityPromises = entries.map(async (entry) => {
|
|
if (!entry.rawInput.trim()) return { isValid: null, error: null };
|
|
if (entry.isEncrypted && !entry.decryptedMnemonic) return { isValid: null, error: null };
|
|
|
|
const textToValidate = entry.decryptedMnemonic || entry.rawInput;
|
|
try {
|
|
await mnemonicToEntropy(textToValidate.trim());
|
|
return { isValid: true, error: null };
|
|
} catch (e: any) {
|
|
return { isValid: false, error: e.message || "Invalid mnemonic" };
|
|
}
|
|
});
|
|
const newValidationResults = await Promise.all(validityPromises);
|
|
setEntries(currentEntries => currentEntries.map((e, i) => ({
|
|
...e,
|
|
isValid: newValidationResults[i]?.isValid ?? e.isValid,
|
|
error: newValidationResults[i]?.error ?? e.error,
|
|
})));
|
|
|
|
if (validMnemonics.length > 0) {
|
|
try {
|
|
const result = await blendMnemonicsAsync(validMnemonics);
|
|
setBlendedResult(result);
|
|
setXorStrength(checkXorStrength(result.blendedEntropy));
|
|
} catch (e: any) { setBlendError(e.message); setBlendedResult(null); }
|
|
} else {
|
|
setBlendedResult(null);
|
|
}
|
|
setBlending(false);
|
|
};
|
|
debounce(processEntries, 300)();
|
|
}, [JSON.stringify(entries.map(e => e.decryptedMnemonic))]);
|
|
|
|
useEffect(() => {
|
|
const processDice = async () => {
|
|
setDiceStats(calculateDiceStats(diceRolls));
|
|
setDicePatternWarning(detectBadPatterns(diceRolls).message || null);
|
|
if (diceRolls.length >= 50) {
|
|
try {
|
|
const outputByteLength = (blendedResult && blendedResult.blendedEntropy.length >= 32) ? 32 : 16;
|
|
const diceOnlyEntropy = await hkdfExtractExpand(diceToBytes(diceRolls), outputByteLength, new TextEncoder().encode('dice-only'));
|
|
setDiceOnlyMnemonic(await entropyToMnemonic(diceOnlyEntropy));
|
|
} catch { setDiceOnlyMnemonic(null); }
|
|
} else { setDiceOnlyMnemonic(null); }
|
|
};
|
|
debounce(processDice, 200)();
|
|
}, [diceRolls, blendedResult]);
|
|
|
|
const updateEntry = (index: number, newProps: Partial<MnemonicEntry>) => {
|
|
setEntries(currentEntries => currentEntries.map((entry, i) => i === index ? { ...entry, ...newProps } : entry));
|
|
};
|
|
const handleAddEntry = () => setEntries([...entries, createNewEntry()]);
|
|
const handleRemoveEntry = (id: number) => {
|
|
if (entries.length > 1) setEntries(entries.filter(e => e.id !== id));
|
|
else setEntries([createNewEntry()]);
|
|
};
|
|
|
|
const handleScan = (index: number) => {
|
|
setScanTargetIndex(index);
|
|
setShowQRScanner(true);
|
|
};
|
|
|
|
const handleScanSuccess = useCallback(async (scannedData: string | Uint8Array) => {
|
|
if (scanTargetIndex === null) return;
|
|
|
|
const scannedText = typeof scannedData === 'string'
|
|
? scannedData
|
|
: Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
const mode = detectEncryptionMode(scannedText);
|
|
let mnemonic = scannedText;
|
|
let error: string | null = null;
|
|
let inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr' = 'text';
|
|
|
|
try {
|
|
if (mode === 'seedqr') {
|
|
mnemonic = await decodeSeedQR(scannedText);
|
|
inputType = 'seedqr';
|
|
updateEntry(scanTargetIndex, {
|
|
rawInput: mnemonic,
|
|
decryptedMnemonic: mnemonic,
|
|
isEncrypted: false,
|
|
passwordRequired: false,
|
|
inputType,
|
|
error: null,
|
|
});
|
|
} else if (mode === 'pgp' || mode === 'krux') {
|
|
inputType = (mode === 'pgp' ? 'seedpgp' : mode);
|
|
updateEntry(scanTargetIndex, {
|
|
rawInput: scannedText,
|
|
decryptedMnemonic: null,
|
|
isEncrypted: true,
|
|
passwordRequired: true,
|
|
inputType,
|
|
error: null,
|
|
});
|
|
} else { // text or un-recognized
|
|
updateEntry(scanTargetIndex, {
|
|
rawInput: scannedText,
|
|
decryptedMnemonic: scannedText,
|
|
isEncrypted: false,
|
|
passwordRequired: false,
|
|
inputType: 'text',
|
|
error: null,
|
|
});
|
|
}
|
|
} catch (e: any) {
|
|
error = e.message || "Failed to process QR code";
|
|
updateEntry(scanTargetIndex, { rawInput: scannedText, error });
|
|
}
|
|
setShowQRScanner(false);
|
|
}, [scanTargetIndex]);
|
|
|
|
const handleScanClose = useCallback(() => {
|
|
setShowQRScanner(false);
|
|
}, []);
|
|
|
|
const handleScanError = useCallback((errMsg: string) => {
|
|
if (scanTargetIndex !== null) {
|
|
updateEntry(scanTargetIndex, { error: errMsg });
|
|
}
|
|
}, [scanTargetIndex]);
|
|
|
|
const handleDecrypt = async (index: number) => {
|
|
const entry = entries[index];
|
|
if (!entry.isEncrypted || !entry.passwordInput) return;
|
|
try {
|
|
let mnemonic: string;
|
|
if (entry.inputType === 'krux') {
|
|
mnemonic = (await decryptFromKrux({ kefData: entry.rawInput, passphrase: entry.passwordInput })).mnemonic;
|
|
} else { // seedpgp
|
|
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
|
|
}
|
|
updateEntry(index, { rawInput: mnemonic, decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
|
|
} catch (e: any) {
|
|
updateEntry(index, { error: e.message || "Decryption failed" });
|
|
}
|
|
};
|
|
|
|
const handleFinalMix = async () => {
|
|
if (!blendedResult) return;
|
|
setMixing(true);
|
|
try {
|
|
const outputBits = targetWordCount === 12 ? 128 : 256;
|
|
const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits);
|
|
setFinalMnemonic(result.finalMnemonic);
|
|
} catch (e) { setFinalMnemonic(null); } finally { setMixing(false); }
|
|
};
|
|
|
|
const handleTransfer = () => {
|
|
if (!finalMnemonic) return;
|
|
// Set mnemonic for backup
|
|
setMnemonicForBackup(finalMnemonic);
|
|
// Switch to backup tab
|
|
requestTabChange('backup');
|
|
// DON'T auto-clear - user can use "Reset All" button if they want to start fresh
|
|
// This preserves the blended seed in case user wants to come back and export QR
|
|
};
|
|
|
|
const copyFinalMnemonic = async () => {
|
|
if (!finalMnemonic) return;
|
|
try {
|
|
await navigator.clipboard.writeText(finalMnemonic);
|
|
setCopiedFinal(true);
|
|
window.setTimeout(() => setCopiedFinal(false), 1200);
|
|
} catch {
|
|
// fallback: select manually
|
|
const el = document.getElementById("final-mnemonic");
|
|
if (el) {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(el);
|
|
const sel = window.getSelection();
|
|
sel?.removeAllRanges();
|
|
sel?.addRange(range);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getBorderColor = (isValid: boolean | null) => {
|
|
if (isValid === true) return 'border-[#39ff14] focus:ring-[#39ff14]';
|
|
if (isValid === false) return 'border-[#ff006e] focus:ring-[#ff006e]';
|
|
return 'border-[#00f0ff]/50 focus:ring-[#00f0ff]';
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-4 md:space-y-6 pb-10 md:pb-20">
|
|
<div className="mb-3 md:mb-6">
|
|
<h2 className="text-base md:text-lg font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
|
|
Seed Blender
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
|
|
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 1: Input Mnemonics</h3>
|
|
<div className="space-y-3 md:space-y-4">
|
|
{entries.map((entry, index) => (
|
|
<div key={entry.id} className="p-2 md:p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/20">
|
|
{entry.passwordRequired ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between"><label className="text-sm font-semibold text-[#00f0ff]">Decrypt {entry.inputType.toUpperCase()} Mnemonic</label><button onClick={() => updateEntry(index, createNewEntry())} className="text-xs text-[#6ef3f7] hover:text-[#00f0ff]">× Cancel</button></div>
|
|
<p className="text-xs text-[#6ef3f7] truncate">Payload: <code className="text-[#9d84b7]">{entry.rawInput.substring(0, 40)}...</code></p>
|
|
<div className="flex gap-2"><input type="password" placeholder="Enter passphrase to decrypt..." value={entry.passwordInput} onChange={(e) => updateEntry(index, { passwordInput: e.target.value })} className="w-full p-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-sm font-mono text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)]" /><button onClick={() => handleDecrypt(index)} className="px-4 bg-[#ff006e] text-white rounded-lg font-semibold hover:bg-[#ff4d8f] hover:shadow-[0_0_15px_rgba(255,0,110,0.5)]"><Key size={16} /></button></div>
|
|
{entry.error && <p className="text-xs text-[#ff006e]">{entry.error}</p>}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{/* Row 1: Textarea only */}
|
|
<textarea
|
|
value={entry.rawInput}
|
|
onChange={(e) => updateEntry(index, { rawInput: e.target.value, decryptedMnemonic: e.target.value, isValid: null, error: null })}
|
|
onFocus={(e) => e.target.classList.remove('blur-sensitive')}
|
|
onBlur={(e) => entry.rawInput && e.target.classList.add('blur-sensitive')}
|
|
placeholder={`Mnemonic #${index + 1} (12 or 24 words)`}
|
|
className={`w-full h-20 md:h-24 p-2 md:p-3 bg-[#0a0a0f] border-2 rounded-lg font-mono text-xs text-[#00f0ff] placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all ${getBorderColor(entry.isValid)} ${entry.rawInput ? 'blur-sensitive' : ''
|
|
}`}
|
|
/>
|
|
{/* Row 2: QR button (left) and X button (right) */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => handleScan(index)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#16213e] border border-[#00f0ff] text-[#00f0ff] text-xs rounded-lg hover:bg-[#00f0ff20] transition-all"
|
|
title="Scan QR code"
|
|
>
|
|
<QrCode size={14} />
|
|
<span>Scan QR</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => handleRemoveEntry(entry.id)}
|
|
className="p-1.5 bg-[#16213e] border border-[#ff006e] text-[#ff006e] rounded-lg hover:bg-[#ff006e20] transition-all"
|
|
title="Remove mnemonic"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
{entry.error && <p className="text-xs text-[#ff006e] px-1">{entry.error}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button onClick={handleAddEntry} className="w-full py-2 bg-[#1a1a2e] hover:bg-[#16213e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50"><Plus size={16} /> Add Another Mnemonic</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 min-h-[10rem]">
|
|
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 2: Blended Preview</h3>
|
|
{blending ? <p className="text-sm text-[#6ef3f7]">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-3 md:space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (12-word)</label><p data-sensitive="Blended Mnemonic (12-word)" className="p-2 md:p-3 bg-[#1a1a2e] rounded-md font-mono text-xs md:text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (24-word)</label><p data-sensitive="Blended Mnemonic (24-word)" className="p-2 md:p-3 bg-[#1a1a2e] rounded-md font-mono text-xs md:text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-[#6ef3f7]">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
|
|
</div>
|
|
|
|
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
|
|
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 3: Input Dice Rolls</h3>
|
|
<div className="space-y-3 md:space-y-4">
|
|
<textarea
|
|
value={diceRolls}
|
|
onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))}
|
|
placeholder="99+ dice rolls (e.g., 16345...)"
|
|
className="w-full h-24 md:h-32 p-2 md:p-3 bg-[#0a0a0f] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs text-[#00f0ff] placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all"
|
|
/>
|
|
{dicePatternWarning && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><p><span className="font-bold">Warning:</span> {dicePatternWarning}</p></div>)}
|
|
{diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-2 md:gap-4 text-center"><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Rolls</p><p className="text-base md:text-lg font-bold text-[#00f0ff]">{diceStats.length}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Entropy (bits)</p><p className="text-base md:text-lg font-bold text-[#00f0ff]">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Mean</p><p className="text-base md:text-lg font-bold text-[#00f0ff]">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Chi-Square</p><p className={`text-base md:text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-[#ff006e]' : 'text-[#00f0ff]'}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
|
|
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Dice-Only Preview Mnemonic</label><p data-sensitive="Dice-Only Preview Mnemonic" className="p-2 md:p-3 bg-[#1a1a2e] rounded-md font-mono text-xs md:text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{diceOnlyMnemonic}</p></div>)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 md:p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/50 shadow-[0_0_20px_rgba(0,240,255,0.3)]">
|
|
<h3 className="font-semibold text-base md:text-lg mb-2 md:mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 4: Generate Final Mnemonic</h3>
|
|
{finalMnemonic ? (
|
|
<div className="p-4 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-2xl shadow-[0_0_20px_rgba(57,255,20,0.3)] animate-in fade-in">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<span className="font-bold text-[#39ff14] flex items-center gap-2 text-lg" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}><CheckCircle2 size={22} /> Final Mnemonic Generated</span>
|
|
<button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-[#16213e] rounded-xl transition-all text-[#39ff14] hover:shadow-[0_0_15px_rgba(57,255,20,0.5)] flex items-center gap-2"><EyeOff size={22} /> Hide</button>
|
|
</div>
|
|
<div className="p-6 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_20px_rgba(57,255,20,0.3)]">
|
|
<div className="flex items-center justify-end mb-3">
|
|
<button
|
|
type="button"
|
|
onClick={copyFinalMnemonic}
|
|
className="px-3 py-1.5 bg-[#16213e] border-2 border-[#39ff14]/50 text-[#39ff14] rounded-lg text-xs font-semibold hover:shadow-[0_0_15px_rgba(57,255,20,0.35)] transition-all"
|
|
title="Copy final mnemonic"
|
|
>
|
|
{copiedFinal ? "Copied" : "Copy"}
|
|
</button>
|
|
</div>
|
|
<p
|
|
id="final-mnemonic"
|
|
data-sensitive="Final Blended Mnemonic"
|
|
className="font-mono text-center text-sm md:text-base break-words text-[#39ff14] leading-relaxed select-text cursor-text"
|
|
onClick={(e) => {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(e.currentTarget);
|
|
const sel = window.getSelection();
|
|
sel?.removeAllRanges();
|
|
sel?.addRange(range);
|
|
}}
|
|
>{finalMnemonic}</p>
|
|
<p className="text-[9px] text-[#6ef3f7] mt-2 text-center">Click the words to select all, or use Copy.</p>
|
|
</div>
|
|
<div className="mt-4 p-3 bg-[#ff006e]/10 text-[#ff006e] rounded-lg text-xs flex gap-2 border-2 border-[#ff006e]/30"><AlertTriangle size={16} className="shrink-0 mt-0.5" /><span><strong>Security Warning:</strong> Write this down immediately. Do not save it digitally.</span></div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
|
|
<button
|
|
onClick={() => setShowFinalQR(true)}
|
|
className="w-full py-2.5 bg-[#16213e] border-2 border-[#00f0ff] text-[#00f0ff] text-xs md:text-sm rounded-lg font-bold uppercase tracking-wide flex items-center justify-center gap-2 hover:bg-[#00f0ff]/20 active:scale-95 transition-all"
|
|
style={{ textShadow: '0 0 8px rgba(0,240,255,0.7)' }}
|
|
>
|
|
<QrCode size={16} /> Export as QR
|
|
</button> <button
|
|
onClick={handleTransfer}
|
|
className="w-full py-2.5 bg-[#1a1a2e] border-2 border-[#ff006e] text-[#00f0ff] text-xs md:text-sm rounded-lg font-bold uppercase tracking-wide flex items-center justify-center gap-2 hover:shadow-[0_0_25px_rgba(255,0,110,0.7)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-[0_0_15px_rgba(255,0,110,0.5)]"
|
|
style={{ textShadow: '0 0 8px rgba(0,240,255,0.7)' }}
|
|
disabled={!finalMnemonic}
|
|
>
|
|
<ArrowRight size={20} />
|
|
Send to Backup
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-xs md:text-sm text-[#6ef3f7] mb-2 md:mb-4">Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final mnemonic.</p>
|
|
<div className="space-y-3 pt-4 mb-4 border-t border-[#00f0ff]/30">
|
|
<label className="text-[10px] font-bold text-[#00f0ff] uppercase tracking-widest block text-center" style={{ textShadow: '0 0 10px rgba(0, 240, 255, 0.7)' }}>
|
|
Target Seed Length
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-3 max-w-sm mx-auto">
|
|
<button
|
|
onClick={() => setTargetWordCount(12)}
|
|
className={`py-2.5 text-sm rounded-lg font-medium transition-all ${targetWordCount === 12
|
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
|
: 'bg-[#16213e] text-[#9d84b7] border-2 border-[#00f0ff]/30 hover:text-[#6ef3f7] hover:border-[#00f0ff]/50'
|
|
}`}
|
|
style={targetWordCount === 12 ? { textShadow: '0 0 10px rgba(0, 240, 255, 0.8)' } : undefined}
|
|
>
|
|
12 Words
|
|
</button>
|
|
<button
|
|
onClick={() => setTargetWordCount(24)}
|
|
className={`py-2.5 text-sm rounded-lg font-medium transition-all ${targetWordCount === 24
|
|
? 'bg-[#1a1a2e] text-[#00f0ff] border-2 border-[#ff006e] shadow-[0_0_15px_rgba(255,0,110,0.5)]'
|
|
: 'bg-[#16213e] text-[#9d84b7] border-2 border-[#00f0ff]/30 hover:text-[#6ef3f7] hover:border-[#00f0ff]/50'
|
|
}`}
|
|
style={targetWordCount === 24 ? { textShadow: '0 0 10px rgba(0, 240, 255, 0.8)' } : undefined}
|
|
>
|
|
24 Words
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button onClick={handleFinalMix} disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing} className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#0066ff] text-[#16213e] rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50 hover:shadow-[0_0_20px_rgba(0,240,255,0.5)]">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showQRScanner && <QRScanner
|
|
onScanSuccess={handleScanSuccess}
|
|
onClose={handleScanClose}
|
|
onError={handleScanError}
|
|
/>}
|
|
{showFinalQR && finalMnemonic && (
|
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowFinalQR(false)}>
|
|
<div className="bg-[#16213e] rounded-2xl p-4 border-2 border-[#00f0ff]/50" onClick={e => e.stopPropagation()}>
|
|
<QrDisplay value={finalMnemonic} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|