217 lines
34 KiB
TypeScript
217 lines
34 KiB
TypeScript
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><backup.json></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>;
|
||
}
|