Files
wikideck/src/App.tsx
T
2026-10-20 04:07:37 -04:00

217 lines
34 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 { useEffect, useRef, useState } from 'react';
import { ArrowDownLeft, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp,
Copy, Cpu, Download, ExternalLink, Globe2, Layers3, LayoutDashboard, LoaderCircle,
Pickaxe, Search, Shield, ShoppingBag, Sparkles, Wallet as WalletIcon, X } from 'lucide-react';
import { rarity } from '../shared/protocol.ts';
import { CONSENSUS, NETWORK, isNftTrade, nftTransferMessage, testnetOwnerOf, transactionCardIds,
type TestnetBlock, type TestnetSnapshot } from '../shared/testnet.ts';
import { getWallet, sign, type Wallet } from './wallet.ts';
import { Card, samples, imageCredit, type CardData } from './Card.tsx';
import { WalletPanel } from './WalletPanel.tsx';
import { useModalFocus } from './useModalFocus.ts';
import { ThemePicker } from './ThemePicker.tsx';
import { TradeDesk } from './TradeDesk.tsx';
import { Marketplace } from './Marketplace.tsx';
import { IS_MAINNET, NETWORK_LABEL } from '../shared/network.ts';
import { StateClient } from '../shared/rpc-client.ts';
import './App.css';
import './testnet.css';
type View = 'Discover' | 'My collection' | 'Block explorer' | 'Trade decks' | 'Marketplace';
const short = (s: string) => s ? `${s.slice(0, 6)}…${s.slice(-4)}` : '—';
const asCard = (block: TestnetBlock): CardData => ({ article: block.article, hash: block.powHash, block });
const message = (error: unknown) => error instanceof Error ? error.message : 'Request failed. Please try again.';
async function request<T>(path: string, body?: unknown, expectedStatus?: number): Promise<T> {
const res = await fetch(`/api/${path}`, { cache: 'no-store', signal: AbortSignal.timeout(15000),
...(body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) });
if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The testnet RPC is unavailable. Start the node and web services.');
const data = await res.json();
if (!res.ok) throw new Error(typeof data.error === 'string' ? data.error : 'Request failed');
if (expectedStatus !== undefined && res.status !== expectedStatus) throw new Error('Unexpected testnet RPC response. Refresh before trying again.');
return data;
}
// Only canonical, mined transfers determine ownership and the next signature sequence.
function transferProblem(state: TestnetSnapshot, card: CardData, address: string): string {
const mint = card.block;
const block = mint && state.blocks.find(b => b.tokenId === mint.tokenId && b.hash === mint.hash);
if (!block) return 'This NFT is no longer on the canonical chain. Refresh your collection.';
if (testnetOwnerOf(state, block.tokenId) !== address) return 'This wallet is not the confirmed owner.';
if (state.blocks.length + 1 - block.height < CONSENSUS.coinbaseMaturity)
return 'Immature NFT: transfers require two subsequent blocks at the next inclusion height.';
if (state.pendingTransfers.some(tx => transactionCardIds(tx).includes(block.tokenId))) return 'A transfer or trade involving this NFT is already pending mining.';
return '';
}
export default function App() {
const [view, setView] = useState<View>('Discover');
const [wallet, setWallet] = useState<Wallet>();
const [snapshot, setSnapshot] = useState<TestnetSnapshot>();
const [rpcAvailable, setRpcAvailable] = useState(false);
const [error, setError] = useState('');
const [toast, setToast] = useState('');
const [dialog, setDialog] = useState<'guide' | 'wallet' | 'transfer' | null>(null);
const [selected, setSelected] = useState<CardData>();
const [query, setQuery] = useState('');
const [category, setCategory] = useState('All cards');
const [recipient, setRecipient] = useState('');
const [transferBusy, setTransferBusy] = useState(false);
const [transferError, setTransferError] = useState('');
const transferInFlight = useRef(false);
const refreshVersion = useRef(0);
const minerPanel = useRef<HTMLElement>(null);
const stateClient = useRef(new StateClient());
useModalFocus(Boolean(dialog || selected));
const refresh = async () => {
const version = ++refreshVersion.current;
try {
const data = await stateClient.current.load();
if (data.network !== NETWORK || data.algorithm !== 'RandomX' || typeof data.chainId !== 'string' ||
!Array.isArray(data.blocks) || !Array.isArray(data.transfers) || !Array.isArray(data.pendingTransfers))
throw new Error('Incompatible node. This interface requires the Wikideck RandomX testnet API.');
if (version === refreshVersion.current) { setSnapshot(data); setRpcAvailable(true); setError(''); }
return data;
} catch (e) {
if (version === refreshVersion.current) { setRpcAvailable(false); setError(message(e)); }
throw e;
}
};
useEffect(() => {
let active = true;
getWallet().then(value => { if (active) setWallet(value); }).catch(e => { if (active) setError(message(e)); });
void refresh().catch(() => {});
const timer = setInterval(() => { void refresh().catch(() => {}); }, 8000);
return () => { active = false; clearInterval(timer); refreshVersion.current++; };
}, []);
useEffect(() => {
if (toast) { const timer = setTimeout(() => setToast(''), 8000); return () => clearTimeout(timer); }
}, [toast]);
useEffect(() => {
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !transferInFlight.current) { setDialog(null); setSelected(undefined); setTransferError(''); }
};
document.addEventListener('keydown', escape);
return () => document.removeEventListener('keydown', escape);
}, []);
const transfer = async () => {
if (transferInFlight.current || !wallet || !selected?.block || !snapshot) return;
transferInFlight.current = true; setTransferBusy(true); setTransferError('');
try {
const state = await refresh();
if (state.chainId !== snapshot.chainId) throw new Error('The node chain ID changed. Refresh and review this NFT before signing.');
const problem = transferProblem(state, selected, wallet.address);
if (problem) throw new Error(problem);
const to = recipient.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(to) || to === wallet.address) throw new Error('Enter another wallet’s 64-character receive address.');
const tokenId = selected.block.tokenId;
const t = { network: NETWORK, chainId: state.chainId, cardHash: tokenId,
from: wallet.address, to, sequence: state.transfers.filter(tx => transactionCardIds(tx).includes(tokenId)).length };
const tx = { ...t, publicKey: wallet.publicKey, signature: await sign(wallet, nftTransferMessage(t)) };
await request('transfer', tx, 202);
// A 202 is a mempool acknowledgement, NOT a change of confirmed ownership.
refreshVersion.current++;
setSnapshot(current => current && current.chainId === state.chainId ? { ...current,
pendingTransfers: [...current.pendingTransfers.filter(p => !transactionCardIds(p).includes(tx.cardHash)), tx] } : current);
setDialog(null); setRecipient('');
setToast('Transfer pending. You remain the confirmed owner until it is mined into the canonical chain.');
void refresh().catch(() => {});
} catch (e) { setTransferError(message(e)); }
finally { transferInFlight.current = false; setTransferBusy(false); }
};
const owned = snapshot?.blocks.filter(b => wallet && testnetOwnerOf(snapshot, b.tokenId) === wallet.address) ?? [];
const visibleCards = view === 'My collection' ? owned.map(asCard)
: snapshot?.blocks.length ? [...snapshot.blocks].reverse().slice(0, 12).map(asCard) : samples;
const filtered = visibleCards.filter(c => (category === 'All cards' || c.article.category === category) &&
`${c.article.title} ${c.article.extract}`.toLowerCase().includes(query.toLowerCase()));
const selectedMint = selected?.block;
const selectedBlock = selectedMint && snapshot?.blocks.find(b => b.tokenId === selectedMint.tokenId && b.hash === selectedMint.hash);
const confirmations = selectedBlock && snapshot ? snapshot.blocks.length - selectedBlock.height + 1 : 0;
const selectedOwner = selectedBlock && snapshot ? testnetOwnerOf(snapshot, selectedBlock.tokenId) : undefined;
const pending = !!selectedBlock && !!snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(selectedBlock.tokenId));
const transferDisabledReason = selected && wallet && snapshot ? transferProblem(snapshot, selected, wallet.address) : 'Wallet and node state are required.';
const peerLabel = !rpcAvailable ? 'Peers unknown' : snapshot?.peers ? `${snapshot.peers} peers` : 'Isolated node';
const nativeCommand = `npm run miner -- --wallet <backup.json> --consent --node ${window.location.origin}`;
const close = () => { if (!transferInFlight.current) { setDialog(null); setSelected(undefined); setTransferError(''); } };
const openWallet = () => { setSelected(undefined); setDialog('wallet'); };
const goMine = () => {
setView('Discover');
setTimeout(() => minerPanel.current?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' }), 50);
};
const copyAddress = async () => {
if (!wallet) return;
try { await navigator.clipboard.writeText(wallet.address); setToast('Wallet address copied.'); }
catch { setToast('Clipboard unavailable. Select and copy your receive address below.'); }
};
const updateWallet = (restored: Wallet) => {
setWallet(restored); setSelected(undefined); setRecipient(''); setTransferError('');
setToast('Wallet restored locally. Refreshing confirmed NFT ownership.');
void refresh().catch(() => {});
};
const retry = async () => {
try { setWallet(await getWallet()); await refresh(); }
catch (e) { setError(message(e)); }
};
return <div className="app-shell">
<aside className="sidebar">
<a href="#" className="brand" onClick={e => { e.preventDefault(); setView('Discover'); }}><img className="brand-logo" src="/Wikideck.png" alt="" />wikideck</a>
<div className="sidebar-label">YOUR NEXT DISCOVERY</div>
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer', 'Trade decks', 'Marketplace'] as View[]).map((item, i) => {
const Icon = [LayoutDashboard, Layers3, Globe2, ArrowLeftRight, ShoppingBag][i];
return <button key={item} aria-label={item} aria-current={view === item ? 'page' : undefined} className={`nav-item ${view === item ? 'active' : ''}`} onClick={() => { setView(item); setQuery(''); setCategory('All cards'); }}><Icon size={18} />{item}{item === 'My collection' && <span className="nav-count">{owned.length}</span>}</button>;
})}</nav>
<div className="sidebar-note"><span className="tiny-spark">✦</span><h3>A little curiosity.<br />An independent chain.</h3><p>Collect knowledge as native NFTs on a sovereign RandomX testnet.</p><button onClick={() => setDialog('guide')}>How it works <ArrowUpRight size={15} /></button></div>
<div className="sidebar-bottom"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} /><span>{peerLabel}</span><span className="prototype">{NETWORK_LABEL}</span></div>
<button className="help-link" onClick={() => setDialog('guide')}><CircleHelp size={16} /> The field guide <ArrowUpRight size={14} /></button>
</aside>
<div className="main-shell">
<header className="topbar"><div className="breadcrumbs">Explore <ChevronRight size={13} /><span>{view}</span></div><div className="top-actions"><span className="testnet-brand">RandomX <b>{NETWORK_LABEL}</b></span><ThemePicker /><span className="network-pill"><span className={`network-dot ${!rpcAvailable || !snapshot?.peers ? 'disconnected' : ''}`} />{peerLabel}</span><button className="wallet-button" onClick={openWallet}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Open wallet'}<ChevronRight size={14} /></button></div></header>
<main>
<div className="testnet-status" role="status"><span><span className={`network-dot ${rpcAvailable ? '' : 'disconnected'}`} /> RPC {rpcAvailable ? 'available' : 'unavailable'}</span><span>{peerLabel}</span><span>{IS_MAINNET?'Gated mainnet candidate · unaudited · mock payments only':'Unbootstrapped testnet · no public mainnet'}</span>{snapshot && !rpcAvailable && <strong>Showing last known state</strong>}</div>
{error && <div className="error-banner" role="alert"><CircleHelp size={17} /><span>{error}</span><button onClick={retry}>Retry</button><button aria-label="Dismiss error" onClick={() => setError('')}><X size={16} /></button></div>}
{view === 'Discover' && <>
<section className="hero">
<div className="hero-copy"><span className="eyebrow"><span /> KNOWLEDGE, COLLECTED ON ITS OWN CHAIN.</span><h1>Mine knowledge.<br />Collect the <span>extraordinary.</span></h1><p>Random Wikipedia articles, each with a picture. A chain of new discoveries. Mine native NFTs with an opt-in RandomX miner, and keep the keys to your collection.</p><div className="hero-actions"><button className="button dark" onClick={goMine}><Pickaxe size={17} /> Explore native mining <ArrowUpRight size={17} /></button><button className="text-button" onClick={() => setDialog('guide')}>How it works <ArrowRight size={16} /></button></div><div className="hero-foot"><span><Check size={13} /> Independent testnet</span><span><Check size={13} /> No browser mining</span></div></div>
<div className="hero-visual"><div className="orbit orbit-one" /><div className="orbit orbit-two" /><span className="orbit-spark spark-one">✳</span><span className="orbit-spark spark-two">+</span><div className="floating-label label-top"><Sparkles size={12} /> Preview cards · not owned</div><div className="hero-card back-card"><Card card={samples[2]} onClick={() => setSelected(samples[2])} /></div><div className="hero-card front-card"><Card card={samples[0]} onClick={() => setSelected(samples[0])} /></div><div className="floating-label label-bottom"><Sparkles size={15} /> A world worth collecting <ArrowUpRight size={13} /></div></div>
</section>
<section className="metrics" aria-label="Canonical chain statistics">
<div><span className="metric-icon"><Layers3 size={19} /></span><div><span>Native NFTs minted</span><strong>{snapshot?.blocks.length.toLocaleString() ?? '—'}<small>canonical chain</small></strong></div></div>
<div><span className="metric-icon"><Globe2 size={19} /></span><div><span>Block height</span><strong>{snapshot ? `#${snapshot.blocks.at(-1)?.height ?? 0}` : '—'}<small>current tip</small></strong></div></div>
<div><span className="metric-icon"><WalletIcon size={19} /></span><div><span>Confirmed ownership</span><strong>{wallet && snapshot ? owned.length : '—'}<small>in your wallet</small></strong></div></div>
<div><span className="metric-icon"><Cpu size={19} /></span><div><span>Proof of work</span><strong className="algorithm">{snapshot?.algorithm ?? 'RandomX'}<small>native CPU mining</small></strong></div></div>
</section>
<section className="mining-panel native-mining" ref={minerPanel}>
<div className="mining-description"><div className="section-eyebrow"><span className="idle-dot" /> EXPLICIT OPT-IN · OUTSIDE YOUR BROWSER</div><h2>Native RandomX mining</h2><p>This page never runs mining code or launches a native program. Build the trusted native binary, download an encrypted wallet backup, then start the miner yourself in an interactive terminal.</p><div className="mining-meta"><span><Cpu size={13} /> One native worker</span><span><Shield size={13} /> Independent validating peers</span></div><div className="native-memory"><strong>Light · default</strong><span>256 MiB cache + scratchpad and overhead.</span><strong>Fast · explicit --fast</strong><span>~2 GiB dataset plus cache; budget over 2.3 GiB per process. Higher CPU, energy and memory use. RandomX is not a promise of ASIC-proof mining.</span></div></div>
<div className="miner-controls"><div className="miner-display"><div><span>NEXT WIKIPEDIA ARTICLE</span><strong>{snapshot ? 'Chosen when the miner requests work' : 'Waiting for testnet RPC…'}</strong></div></div><p className="muted">Each mining template requests a random English Wikipedia article with a Wikimedia picture. Pages may repeat; every minted card carries its own statistics snapshot.</p><ol className="native-steps"><li>Build <code>wikideck-randomx</code> using the repository’s native build instructions.</li><li>Save your encrypted backup and keep its password separate.</li><li>Replace <code>&lt;backup.json&gt;</code> with your local file path (quote paths with spaces), then run:</li></ol><pre className="native-command"><code>{nativeCommand}</code></pre><p className="miner-footnote">Node: {window.location.origin}. Same-origin RPC, including the development proxy. Use HTTPS for remote nodes. Password entry stays local; never put it in the command. Defaults to one worker and at most one block. Ctrl+C stops the native miner; closing this page does not.</p><button className="button dark full" onClick={openWallet}><Download size={16} /> Download wallet backup</button><button className="text-button" onClick={() => setDialog('guide')}>Read the testnet field guide <ArrowRight size={14} /></button></div>
</section>
</>}
{view === 'Marketplace' ? <Marketplace wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view === 'Trade decks' ? <TradeDesk wallet={wallet} snapshot={snapshot} rpcAvailable={rpcAvailable} onNotice={setToast} /> : view !== 'Block explorer' ? <section className="discover-section">
<div className="section-heading"><div><div className="section-eyebrow">{view === 'My collection' ? 'YOUR PERSONAL ENCYCLOPEDIA' : 'AN ENCYCLOPEDIA, REIMAGINED'}</div><h2>{view === 'My collection' ? 'Your cabinet of curiosities' : 'A world of possible discoveries'}<span className="count-badge">{visibleCards.length}</span></h2><p>{view === 'My collection' ? 'Confirmed native NFT ownership. Pending transfers stay here until mined.' : snapshot?.blocks.length ? 'Recent native NFTs on this node’s canonical testnet chain.' : 'Sample previews only; mined NFTs use random Wikipedia pages with pictures. The same page may be minted more than once.'}</p></div>{view === 'Discover' && <button className="text-button" onClick={() => setView('My collection')}>My collection <ArrowRight size={16} /></button>}</div>
<div className="filter-bar"><div className="category-tabs">{['All cards', 'Nature', 'Space', 'Science', 'People', 'History', 'Culture'].map(c => <button key={c} className={category === c ? 'selected' : ''} onClick={() => setCategory(c)}>{c}</button>)}</div><label className="search-input"><Search size={15} /><input aria-label="Search cards" placeholder="Find a discovery…" value={query} onChange={e => setQuery(e.target.value)} /></label></div>
{filtered.length ? <div className="card-grid">{filtered.map(c => <div className="card-wrap" key={c.block?.tokenId ?? c.hash}><Card card={c} onClick={() => { setSelected(c); setTransferError(''); }} /><div className="card-list-caption"><span>{c.block ? `Block #${c.block.height}` : 'Example discovery'}</span><span className="caption-dot" />{c.block && snapshot ? short(testnetOwnerOf(snapshot, c.block.tokenId) ?? '') : 'Not owned'}</div>{c.block && snapshot?.pendingTransfers.some(tx => transactionCardIds(tx).includes(c.block!.tokenId)) && <span className="pending-label">Transfer or trade pending mining</span>}</div>)}</div> : <div className="empty-state"><img className="empty-state-logo" src="/Wikideck.png" alt="Wikideck" /><h3>{query || category !== 'All cards' ? 'No discoveries here. Yet.' : 'Your first discovery is waiting.'}</h3><p>{query || category !== 'All cards' ? 'Try another search or explore a different category.' : 'Use the native miner or receive a mined transfer on this chain. Restore a backup to access an existing wallet.'}</p><button className="button dark" onClick={goMine}><Pickaxe size={16} /> Explore native mining</button></div>}
</section> : <section className="explorer-section"><div className="section-eyebrow">FOLLOW THE CANONICAL CHAIN</div><div className="section-heading"><div><h1>Block explorer<span className="count-badge">{snapshot?.blocks.length ?? 0}</span></h1><p>Native NFT mints, RandomX proofs, and mined ownership transfers.</p></div><a className="button outline" href="/api/chain" target="_blank" rel="noreferrer">View full chain JSON <ExternalLink size={15} /></a></div>
<div className="explorer-note"><Shield size={18} /><p>Sovereign testnet with independently validating peers and cumulative-work chain selection. This network is unbootstrapped, not a public mainnet. Zero peers means an isolated node, not a connected network. Canonical ownership can change during a reorganization.</p></div>
<dl className="chain-summary"><div><dt>Cumulative work</dt><dd>{snapshot?.cumulativeWork ?? '—'}</dd></div><div><dt>Peer count</dt><dd>{rpcAvailable ? snapshot?.peers ?? '—' : 'Unknown'} · {peerLabel}</dd></div><div><dt>Chain ID · pinned genesis</dt><dd>{snapshot?.chainId ?? '—'}</dd></div><div><dt>Network</dt><dd>{NETWORK}</dd></div><div><dt>Tip block ID</dt><dd>{snapshot?.tip ?? '—'}</dd></div><div><dt>Mint maturity</dt><dd>{snapshot?.maturity ?? CONSENSUS.coinbaseMaturity} subsequent blocks at inclusion height</dd></div></dl>
{snapshot?.blocks.length ? <div className="table-scroll"><table><thead><tr><th>Block / confirmations</th><th>Discovery</th><th>Block ID / NFT token ID</th><th>Confirmed owner</th><th>RandomX proof / rarity</th></tr></thead><tbody>{[...snapshot.blocks].reverse().map(b => <tr key={b.hash}><td><button className="explorer-card-link" onClick={() => setSelected(asCard(b))}>#{b.height.toString().padStart(4, '0')}</button><small>{snapshot.blocks.length - b.height + 1} confirmations</small></td><td><strong>{b.article.title}</strong><small>Wikipedia #{b.article.pageId}</small></td><td className="mono"><span title={b.hash}>Block {short(b.hash)}</span><a className="nft-link" href={`/api/nfts/${encodeURIComponent(b.tokenId)}`} target="_blank" rel="noreferrer" title={b.tokenId}>NFT {short(b.tokenId)} <ExternalLink size={11} /></a></td><td className="mono" title={testnetOwnerOf(snapshot, b.tokenId)}>{short(testnetOwnerOf(snapshot, b.tokenId) ?? '')}</td><td><span className="proof-caption mono" title={b.powHash}>{short(b.powHash)}</span><span className={`rarity-tag ${rarity(b.powHash).toLowerCase()}`}>{rarity(b.powHash)}</span></td></tr>)}</tbody></table></div> : <div className="empty-state"><img className="empty-state-logo" src="/Wikideck.png" alt="Wikideck" /><h3>A new chain. A blank page.</h3><p>No canonical NFT blocks loaded. Native mining is an explicit, separate step.</p><button className="button dark" onClick={goMine}>Explore native mining <ArrowRight size={16} /></button></div>}
<h2 className="transfer-title">Mined transfers and trades</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((tx, i) => isNftTrade(tx)
? <div key={`trade-${tx.proposerSignature}`}><ArrowLeftRight size={18}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span>Atomic trade · mined #{snapshot.blocks.find(b => b.transactions.some(item => isNftTrade(item) && item.proposerSignature === tx.proposerSignature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined trade ${i + 1}`}/></div>
: <div key={`${tx.cardHash}-${tx.sequence}`}><ArrowDownLeft size={18}/><a href={`/api/nfts/${encodeURIComponent(tx.cardHash)}`} target="_blank" rel="noreferrer" title={tx.cardHash}>{short(tx.cardHash)}</a><span>{short(tx.from)} → {short(tx.to)}</span><span>Sequence {tx.sequence} · mined #{snapshot.blocks.find(b => b.transactions.some(item => !isNftTrade(item) && item.signature === tx.signature))?.height ?? '—'}</span><Check size={14} aria-label={`Mined transfer ${i + 1}`}/></div>)}</div> : <p className="muted">No mined transfers or trades.</p>}
<h2 className="transfer-title">Pending transfers and trades <span className="count-badge">{snapshot?.pendingTransfers.length ?? 0}</span></h2><p className="muted">Mempool only · not confirmed ownership. A trade bundle is included in full or not at all; pending transactions may be dropped or invalidated by a reorganization.</p><div className="journal">{snapshot?.pendingTransfers.map((tx, i) => isNftTrade(tx)
? <div key={`pending-trade-${tx.proposerSignature}`}><LoaderCircle size={16}/><span>{tx.offered.length} cards {short(tx.proposer)} ⇄ {tx.requested.length} cards {short(tx.counterparty)}</span><span className="pending-label">Atomic trade · awaiting mining</span></div>
: <div key={`${tx.cardHash}-${tx.sequence}`}><LoaderCircle size={16}/><span title={tx.cardHash}>{short(tx.cardHash)}</span><span>{short(tx.from)} → {short(tx.to)}</span><span className="pending-label">Awaiting mining</span></div>)}</div>
</section>}
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Built on the shoulders of Wikipedia.</a><span>Knowledge, not financial promises.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Mined article snapshots are selected from Wikipedia; attribution and image licenses appear on each card. Native NFTs are not ERC-721 tokens, fiat, or rights to article content. No promised monetary value.</p></footer>
</main>
</div>
{toast && <div className="toast" role="status"><span><Check size={16} /></span>{toast}<button onClick={() => setToast('')} aria-label="Dismiss notification"><X size={14} /></button></div>}
{(dialog || selected) && <div className="modal-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}><section className={`modal ${dialog === 'wallet' ? 'wallet-modal' : selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? 'Local testnet wallet' : dialog === 'transfer' ? 'Transfer native NFT' : dialog === 'guide' ? 'How Wikideck works' : 'Card details'}><button className="modal-close" aria-label="Close dialog" onClick={close} disabled={transferBusy}><X size={20} /></button>
{dialog === 'guide' ? <><span className="modal-icon"><Sparkles size={24} /></span><div className="section-eyebrow">THE TESTNET FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Mine natively. Only by choice.</h3><p>No browser mining or automatic native execution. Build the trusted RandomX binary and run the miner yourself with an encrypted wallet backup and explicit --consent. One worker defaults to light mode (256 MiB cache plus overhead); --fast adds a ~2 GiB dataset. Mining uses CPU and energy, may warm your device, and is not guaranteed ASIC-proof. Ctrl+C stops it.</p></div></div><div><span>02</span><div><h3>A page, a snapshot. A sovereign chain.</h3><p>Each mining template asks Wikipedia for a random English article with a picture. A page can appear on multiple NFTs; each block commits its own article and mint-time statistics snapshot. Validators verify the snapshot's structure and commitments without contacting Wikimedia during consensus validation. Independent peers validate RandomX work, native NFT mints, and signed transactions, choosing the cumulative-work canonical chain. This testnet is unbootstrapped: there is no public mainnet, and an isolated node has no peer-backed network security.</p></div></div><div><span>03</span><div><h3>Keep your keys. Trade as a team.</h3><p>Your P-256 wallet is a local signing identity, not a server login. Download a password-encrypted backup and restore it locally to control your NFTs. Use Trade decks to review a public wallet’s confirmed holdings and prepare a signed multi-card offer. The other player signs before submission; all cards exchange together when the single trade transaction is mined. Offers do not reserve cards and can go stale. A pending transfer or trade does not change confirmed ownership. Mints require two subsequent blocks at inclusion height; reorganizations can change confirmations and ownership.</p></div></div></div><div className="info-box"><strong>Native NFTs, not content ownership.</strong><p>Token IDs identify native assets on this chain, not ERC-721 tokens on another network. RandomX proof hashes determine game rarity and stats; block IDs identify blocks. An NFT does not grant copyright or other intellectual-property rights to Wikipedia text or images. Follow their licenses and attribution. No fiat, financial return, or monetary value is promised.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Explore native mining <ArrowRight size={16} /></button></>
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL TESTNET IDENTITY</div><h2>A wallet for your wonders.</h2><p className="muted">No server login. This recoverable wallet controls native NFTs through local signatures. It is separate from the original demo wallet.</p><label className="field-label">YOUR RECEIVE ADDRESS</label><div className="address-box">{wallet?.address ?? 'Wallet unavailable. Enable local browser storage and retry.'}</div><button className="button dark full" onClick={copyAddress} disabled={!wallet}><Copy size={16} /> Copy wallet address</button><div className="wallet-recovery"><WalletPanel wallet={wallet} onRestore={updateWallet} onNotice={setToast} /></div><p className="wallet-count"><Layers3 size={17} /> {owned.length} confirmed native NFTs in your collection</p></>
: dialog === 'transfer' ? <><span className="modal-icon"><ArrowUpRight size={24} /></span><div className="section-eyebrow">PASS ON THE CURIOSITY</div><h2>Send {selected?.article.title}.</h2><p className="muted">Use the recipient’s 64-character testnet wallet address on this same chain. Signing submits a pending transaction; it does not immediately transfer ownership.</p><label className="field-label">NFT TOKEN ID</label><div className="address-box">{selected?.block?.tokenId}</div><label className="field-label" htmlFor="recipient">RECIPIENT ADDRESS</label><input className="recipient-input" id="recipient" value={recipient} disabled={transferBusy} onChange={e => setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" maxLength={64} /><div className="info-box"><strong>Review before signing.</strong><p>Transfers take effect only when mined into the canonical chain. Once confirmed, there is no undo button or recovery for a wrong address. Mints require two subsequent blocks at inclusion height; only one pending transfer per NFT is allowed.</p></div>{(!rpcAvailable || transferDisabledReason) && <p className="transfer-error" role="alert">{!rpcAvailable ? 'RPC unavailable. Reconnect before signing.' : transferDisabledReason}</p>}{transferError && <p className="transfer-error" role="alert">{transferError}</p>}<button className="button dark full" disabled={transferBusy || !rpcAvailable || !!transferDisabledReason || !/^[a-fA-F0-9]{64}$/.test(recipient.trim()) || recipient.trim().toLowerCase() === wallet?.address} onClick={transfer}>{transferBusy ? <LoaderCircle className="spin" size={16} /> : <ArrowUpRight size={16} />}{transferBusy ? 'Signing & submitting…' : 'Sign & submit pending transfer'}</button></>
: selected && <><div className="detail-card"><Card card={selected} /></div><div className="detail-copy"><div className="section-eyebrow">{selected.block ? `MINTED IN BLOCK #${selected.block.height}` : 'EXAMPLE CARD · NOT OWNED'}</div><h2>{selected.article.title}</h2><p>{selected.article.extract}</p><div className="detail-facts"><span>Page ID<strong>#{selected.article.pageId}</strong></span><span>Rarity<strong>{rarity(selected.hash)}</strong></span><span>Content<strong>{selected.article.source === 'live' ? `Pinned revision ${selected.article.revisionId}` : 'Sample preview'}</strong></span></div>{selected.block && <><label className="field-label">NATIVE NFT TOKEN ID</label><div className="address-box">{selected.block.tokenId}</div><label className="field-label">BLOCK ID</label><div className="address-box">{selected.block.hash}</div><label className="field-label">RANDOMX PROOF HASH · GAME STATS</label><div className="address-box">{selected.hash}</div><p className="muted">{confirmations} canonical confirmations{!rpcAvailable ? ' · last known state' : ''}. {selectedBlock ? `Confirmed owner: ${selectedOwner}` : 'This mint is not on the loaded canonical chain.'}</p>{pending && <p className="pending-label">Transfer pending mining · confirmed owner unchanged.</p>}</>}<div className="detail-links"><a href={selected.article.url} target="_blank" rel="noreferrer">Read on Wikipedia <ExternalLink size={14} /></a><a href={imageCredit(selected.article)} target="_blank" rel="noreferrer">Image & license <ExternalLink size={14} /></a>{selected.block && <a href={`/api/nfts/${encodeURIComponent(selected.block.tokenId)}`} target="_blank" rel="noreferrer">NFT metadata <ExternalLink size={14} /></a>}</div><p className="attribution">{selected.article.source === 'live' ? 'Pinned text by Wikipedia contributors, CC BY-SA 4.0. Excerpt shortened for card display.' : 'Original fixed starter summary, linked to the real Wikipedia article; no live revision captured.'} Stats are game values, not encyclopedia facts. NFT ownership grants no rights to article text or images.</p>{selected.block && selectedOwner === wallet?.address && <>{transferDisabledReason && <p className="muted">{transferDisabledReason}</p>}<button className="button dark full" disabled={!rpcAvailable || !!transferDisabledReason} onClick={() => { setRecipient(''); setTransferError(''); setDialog('transfer'); }}>Transfer native NFT <ArrowUpRight size={16} /></button></>}</div></>}
</section></div>}
</div>;
}