v2
This commit is contained in:
+185
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowDownLeft, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronRight, CircleHelp,
|
||||
Copy, Cpu, ExternalLink, Globe2, Layers3, LayoutDashboard, Leaf, LoaderCircle,
|
||||
Pickaxe, Search, Shield, Sparkles, Square, Wallet as WalletIcon, X, Zap } from 'lucide-react';
|
||||
import { claimMessage, ownerOf, rarity, transferMessage, type Block, type ChainState, type Challenge } from '../shared/protocol.ts';
|
||||
import { getWallet, sign, type Wallet } from './wallet.ts';
|
||||
import { Card, samples, imageCredit, type CardData } from './Card.tsx';
|
||||
import { useModalFocus } from './useModalFocus.ts';
|
||||
import { ThemePicker } from './ThemePicker.tsx';
|
||||
import './App.css';
|
||||
|
||||
type Snapshot = ChainState & { challenge: Challenge };
|
||||
type View = 'Discover' | 'My collection' | 'Block explorer';
|
||||
const short = (s: string) => `${s.slice(0, 6)}…${s.slice(-4)}`;
|
||||
async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`/api/${path}`, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
|
||||
if (!res.headers.get('content-type')?.includes('application/json')) throw new Error('The API is unavailable. Start both Wikideck services.');
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<View>('Discover');
|
||||
const [wallet, setWallet] = useState<Wallet>();
|
||||
const [snapshot, setSnapshot] = useState<Snapshot>();
|
||||
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 [mining, setMining] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [duty, setDuty] = useState(0.5);
|
||||
const [progress, setProgress] = useState({ attempts: 0, rate: 0, hash: '' });
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [transferBusy, setTransferBusy] = useState(false);
|
||||
useModalFocus(Boolean(dialog || selected));
|
||||
const worker = useRef<Worker | null>(null);
|
||||
const runId = useRef(0);
|
||||
const activeSeed = useRef('');
|
||||
const minerPanel = useRef<HTMLElement>(null);
|
||||
const stop = () => { worker.current?.terminate(); worker.current = null; activeSeed.current = ''; runId.current++; setMining(false); };
|
||||
const refresh = async () => {
|
||||
const data = await request<Snapshot>('state');
|
||||
setSnapshot(data); setError('');
|
||||
if (activeSeed.current && activeSeed.current !== data.challenge.seed) {
|
||||
stop(); setToast('A new block was mined. Start again to discover the next article.');
|
||||
}
|
||||
return data;
|
||||
};
|
||||
useEffect(() => {
|
||||
getWallet().then(setWallet).catch(e => setError(e.message));
|
||||
refresh().catch(e => setError(e.message));
|
||||
const timer = setInterval(() => refresh().catch(e => setError(e.message)), 8000);
|
||||
const visibility = () => { if (document.hidden) stop(); };
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
return () => { clearInterval(timer); worker.current?.terminate(); document.removeEventListener('visibilitychange', visibility); };
|
||||
}, []);
|
||||
useEffect(() => { if (toast) { const t = setTimeout(() => setToast(''), 6000); return () => clearTimeout(t); } }, [toast]);
|
||||
useEffect(() => {
|
||||
const escape = (e: KeyboardEvent) => { if (e.key === 'Escape' && !transferBusy) { setDialog(null); setSelected(undefined); } };
|
||||
document.addEventListener('keydown', escape);
|
||||
return () => document.removeEventListener('keydown', escape);
|
||||
}, [transferBusy]);
|
||||
const start = async () => {
|
||||
if (!wallet || !consent || mining || submitting) return;
|
||||
setError('');
|
||||
stop();
|
||||
const id = runId.current;
|
||||
setMining(true);
|
||||
setProgress({ attempts: 0, rate: 0, hash: '' });
|
||||
try {
|
||||
const data = await refresh();
|
||||
if (id !== runId.current || document.hidden) { stop(); return; }
|
||||
activeSeed.current = data.challenge.seed;
|
||||
const w = new Worker(new URL('./miner.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.current = w;
|
||||
w.onerror = () => { stop(); setError('Mining worker could not start. Check WebAssembly support.'); };
|
||||
w.onmessage = async ({ data: event }) => {
|
||||
if (id !== runId.current) return;
|
||||
if (event.type === 'progress') setProgress(event);
|
||||
if (event.type === 'error') { stop(); setError(event.message); }
|
||||
if (event.type === 'found') {
|
||||
stop(); setSubmitting(true);
|
||||
try {
|
||||
const signature = await sign(wallet, claimMessage(data.challenge, wallet.address, event.nonce, event.hash));
|
||||
const { block } = await request<{ block: Block }>('mine', { owner: wallet.address, publicKey: wallet.publicKey,
|
||||
signature, nonce: event.nonce, hash: event.hash, seed: data.challenge.seed });
|
||||
await refresh();
|
||||
setSelected({ article: block.article, hash: block.hash, block });
|
||||
setToast(`Block #${block.height} verified. ${block.article.title} is yours!`);
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
finally { setSubmitting(false); }
|
||||
}
|
||||
};
|
||||
w.postMessage({ challenge: data.challenge, owner: wallet.address, duty });
|
||||
} catch (e) { stop(); setError((e as Error).message); }
|
||||
};
|
||||
const transfer = async () => {
|
||||
if (!wallet || !selected?.block || !snapshot) return;
|
||||
setTransferBusy(true);
|
||||
try {
|
||||
const t = { cardHash: selected.hash, from: wallet.address, to: recipient.trim().toLowerCase(),
|
||||
sequence: snapshot.transfers.filter(t => t.cardHash === selected.hash).length };
|
||||
await request('transfer', { ...t, publicKey: wallet.publicKey, signature: await sign(wallet, transferMessage(t)) });
|
||||
await refresh(); setDialog(null); setSelected(undefined); setRecipient(''); setToast('Card transferred. Ownership has been updated.');
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
finally { setTransferBusy(false); }
|
||||
};
|
||||
const owned = snapshot?.blocks.filter(b => ownerOf(snapshot, b.hash) === wallet?.address) ?? [];
|
||||
const visibleCards: CardData[] = view === 'My collection'
|
||||
? owned.map(block => ({ article: block.article, hash: block.hash, block }))
|
||||
: snapshot?.blocks.length ? [...snapshot.blocks].reverse().slice(0, 12).map(block => ({ article: block.article, hash: block.hash, block })) : samples;
|
||||
const filtered = visibleCards.filter(c => (category === 'All cards' || c.article.category === category) && `${c.article.title} ${c.article.extract}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const goMine = () => { setView('Discover'); setTimeout(() => minerPanel.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 50); };
|
||||
const copyAddress = async () => {
|
||||
if (!wallet) return;
|
||||
try { await navigator.clipboard.writeText(wallet.address); setToast('Wallet address copied.'); }
|
||||
catch { setDialog('wallet'); setToast('Copy your address from the wallet panel.'); }
|
||||
};
|
||||
const retry = async () => {
|
||||
setError('');
|
||||
try { setWallet(await getWallet()); await refresh(); }
|
||||
catch (e) { setError((e as Error).message); }
|
||||
};
|
||||
const close = () => { if (!transferBusy) { setDialog(null); setSelected(undefined); } };
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<a href="#" className="brand" onClick={e => { e.preventDefault(); setView('Discover'); }}><span className="brand-mark">w<span>✦</span></span>wikideck<span className="brand-dot">.</span></a>
|
||||
<div className="sidebar-label">YOUR NEXT DISCOVERY</div>
|
||||
<nav aria-label="Main navigation">{(['Discover', 'My collection', 'Block explorer'] as View[]).map((item, i) => {
|
||||
const Icon = [LayoutDashboard, Layers3, Globe2][i];
|
||||
return <button key={item} 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 entire universe.</h3><p>Turn the world’s knowledge into something you can collect.</p><button onClick={() => setDialog('guide')}>How it works <ArrowUpRight size={15} /></button></div>
|
||||
<div className="sidebar-bottom"><span className="network-dot" /><span>Local network</span><span className="prototype">ALPHA</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"><ThemePicker /><span className="network-pill"><span className={`network-dot ${!snapshot ? 'disconnected' : ''}`} />{snapshot ? 'Network online' : 'Connecting'}</span><button className="wallet-button" onClick={() => setDialog('wallet')}><span className="wallet-avatar"><WalletIcon size={14} /></span>{wallet ? short(wallet.address) : 'Creating wallet'}<ChevronRight size={14} /></button></div></header>
|
||||
<main>
|
||||
{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 /> THE KNOWLEDGE IS REAL. SO ARE THE CARDS.</span><h1>Mine knowledge.<br />Collect the <span>extraordinary.</span></h1><p>One encyclopedia. Endless discoveries. Put your curiosity to work and turn Wikipedia into your next great collection.</p><div className="hero-actions"><button className="button dark" onClick={goMine}><Pickaxe size={17} /> Start your discovery <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} /> No purchase needed</span><span><Check size={13} /> Mined in your browser</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"><span className="network-dot" /> Powered by curiosity</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="Network statistics">
|
||||
<div><span className="metric-icon"><Layers3 size={19} /></span><div><span>Cards discovered</span><strong>{snapshot?.blocks.length.toLocaleString() ?? '—'}<small>on this network</small></strong></div></div>
|
||||
<div><span className="metric-icon"><Globe2 size={19} /></span><div><span>Next block</span><strong>#{snapshot?.challenge.height.toString().padStart(4, '0') ?? '—'}<small>a new discovery</small></strong></div></div>
|
||||
<div><span className="metric-icon"><WalletIcon size={19} /></span><div><span>Your collection</span><strong>{owned.length}<small>{owned.length === 1 ? 'unique story' : 'unique stories'}</small></strong></div></div>
|
||||
<div><span className="metric-icon leaf"><Leaf size={19} /></span><div><span>Proof of curiosity</span><strong className="algorithm">Argon2id<small>memory-hard · 16 MiB</small></strong></div></div>
|
||||
</section>
|
||||
<section className="mining-panel" ref={minerPanel}>
|
||||
<div className="mining-description"><div className="section-eyebrow"><span className={mining ? 'network-dot' : 'idle-dot'} /> {mining ? 'YOUR MINER IS RUNNING' : submitting ? 'VERIFYING YOUR DISCOVERY' : 'YOUR NEXT CARD STARTS HERE'}</div><h2>{mining ? 'A little work. A new world.' : submitting ? 'Making it official.' : 'Ready to uncover something?'}</h2><p>{mining ? 'Your browser is solving a memory-hard puzzle. Every attempt is another chance to discover.' : 'Solve a block in your browser. Get a one-of-a-kind card from a real Wikipedia article.'}</p><div className="mining-meta"><span><Cpu size={13} /> One local worker</span><span><Shield size={13} /> Server-verified proof</span><span><Zap size={13} /> No gas fees</span></div></div>
|
||||
<div className="miner-controls"><div className="miner-display"><div><span>{mining ? 'HASH RATE' : 'NEXT ARTICLE'}</span><strong>{mining ? `${progress.rate.toFixed(1)} H/s` : snapshot?.challenge.article.title ?? 'Finding an article…'}</strong></div><div className={`signal-bars ${mining ? 'running' : ''}`}>{Array.from({ length: 18 }, (_, i) => <i key={i} style={{ height: `${10 + ((i * 13 + 7) % 23)}px`, animationDelay: `${i * 0.07}s` }} />)}</div></div>
|
||||
{(mining || submitting) ? <><div className="hash-line">{submitting ? 'Proof found. Verifying and saving…' : `${progress.attempts} attempts · ${progress.hash.slice(0, 22) || 'Initializing WebAssembly'}…`}</div><button className="button mining-stop" disabled={submitting} onClick={stop}>{submitting ? <LoaderCircle size={16} className="spin" /> : <Square size={14} />}{submitting ? 'Verifying block' : 'Stop mining'}</button></> : <><div className="intensity"><label htmlFor="duty">Worker duty cycle</label><select id="duty" value={duty} onChange={e => setDuty(Number(e.target.value))}><option value={0.25}>Gentle · 25%</option><option value={0.5}>Balanced · 50%</option><option value={0.85}>Focused · 85%</option></select></div><label className="consent"><input type="checkbox" checked={consent} onChange={e => setConsent(e.target.checked)} />I agree to use this device’s CPU and battery.</label><button className="button lime" onClick={start} disabled={!consent || !wallet || !snapshot}><Pickaxe size={17} /> Start mining <ArrowRight size={16} /></button></>}
|
||||
<small className="miner-footnote">Always opt-in. Stops when you switch tabs. No background mining.</small>
|
||||
</div>
|
||||
</section>
|
||||
</>}
|
||||
{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' ? 'Every card has a story. These ones belong to you.' : snapshot?.blocks.length ? 'Real cards recently mined on your local network.' : 'A taste of what’s out there. Preview cards are examples, not owned assets.'}</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.hash}><Card card={c} onClick={() => setSelected(c)} /><div className="card-list-caption"><span>{c.block ? `Block #${c.block.height}` : 'Example discovery'}</span><span className="caption-dot" />{c.block ? short(ownerOf(snapshot!, c.hash) ?? '') : 'Not yet mined'}</div></div>)}</div> : <div className="empty-state"><Layers3 size={38} /><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.' : 'Mine your first block to turn a Wikipedia article into a card you own.'}</p><button className="button dark" onClick={goMine}><Pickaxe size={16} /> Discover your first card</button></div>}
|
||||
</section> : <section className="explorer-section"><div className="section-eyebrow">FOLLOW THE KNOWLEDGE</div><div className="section-heading"><div><h1>Block explorer<span className="count-badge">{snapshot?.blocks.length ?? 0}</span></h1><p>Every article. Every proof. Independently verifiable.</p></div><a className="button outline" href="/api/chain" target="_blank" rel="noreferrer">View chain JSON <ExternalLink size={15} /></a></div><div className="explorer-note"><Shield size={18} /><p>This is a local, single-coordinator proof-of-work chain. It is not a decentralized mainnet. Transfers are signed ownership records, not mined blocks.</p></div>
|
||||
{snapshot?.blocks.length ? <div className="table-scroll"><table><thead><tr><th>Block</th><th>Discovery</th><th>Proof hash</th><th>Current owner</th><th>Rarity</th></tr></thead><tbody>{[...snapshot.blocks].reverse().map(b => <tr key={b.hash} onClick={() => setSelected({ article: b.article, hash: b.hash, block: b })}><td>#{b.height.toString().padStart(4, '0')}</td><td><strong>{b.article.title}</strong><small>Wikipedia #{b.article.pageId}</small></td><td className="mono">{short(b.hash)}</td><td className="mono">{short(ownerOf(snapshot, b.hash) ?? '')}</td><td><span className={`rarity-tag ${rarity(b.hash).toLowerCase()}`}>{rarity(b.hash)}</span></td></tr>)}</tbody></table></div> : <div className="empty-state"><Globe2 size={40} /><h3>A new chain. A blank page.</h3><p>Be the first to mine a block and make a discovery.</p><button className="button dark" onClick={goMine}>Mine the first block <ArrowRight size={16} /></button></div>}
|
||||
<h2 className="transfer-title">Ownership journal</h2>{snapshot?.transfers.length ? <div className="journal">{snapshot.transfers.map((t, i) => <div key={i}><ArrowDownLeft size={18} /><span>{short(t.cardHash)}</span><span>{short(t.from)} → {short(t.to)}</span><span>Transfer #{t.sequence + 1}</span><Check size={14} /></div>)}</div> : <p className="muted">No transfers yet. Open a card you own to send it to another wallet.</p>}
|
||||
</section>}
|
||||
<footer><a href="https://en.wikipedia.org" target="_blank" rel="noreferrer"><BookOpen size={16} /> Built on the shoulders of Wikipedia.</a><span>Curiosity is the currency.<span className="footer-star">✦</span></span><p>Independent experiment, not affiliated with Wikimedia. Live text: <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noreferrer">CC BY-SA 4.0</a>. Image credits on each card. No 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 ${selected && !dialog ? 'card-modal' : ''}`} role="dialog" aria-modal="true" aria-label={dialog === 'wallet' ? 'Local wallet' : dialog === 'transfer' ? 'Transfer card' : 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 FIELD GUIDE</div><h2>A universe in every card.</h2><div className="guide-steps"><div><span>01</span><div><h3>Put curiosity to work.</h3><p>Opt in to one browser worker running memory-hard Argon2id in WebAssembly. It uses 16 MiB per hash, two passes, and a low demo difficulty.</p></div></div><div><span>02</span><div><h3>Discover a little of everything.</h3><p>Each block commits to a Wikipedia page ID, revision, and text snapshot. The server checks your proof and P-256 wallet signature before awarding a card.</p></div></div><div><span>03</span><div><h3>Make it your collection.</h3><p>Rarity and game stats come from the mined hash, not scientific importance. Send cards with signed transfers. There is no combat mode or real-money marketplace yet.</p></div></div></div><div className="info-box"><strong>An honest experiment.</strong><p>Argon2id is memory-hard, not Monero’s RandomX and not proven ASIC-proof. This local coordinator is not a decentralized blockchain. Cards are not public-chain NFTs and have no promised monetary value. Mining consumes energy and may warm your device. On Wikipedia outages, clearly labeled offline starter articles are used.</p></div><button className="button dark full" onClick={() => { close(); goMine(); }}>Let’s discover something <ArrowRight size={16} /></button></>
|
||||
: dialog === 'wallet' ? <><span className="modal-icon"><WalletIcon size={24} /></span><div className="section-eyebrow">YOUR LOCAL IDENTITY</div><h2>A wallet for your wonders.</h2><p className="muted">Your browser created a P-256 signing key. No extension, account, or cryptocurrency required.</p><label className="field-label">YOUR RECEIVE ADDRESS</label><div className="address-box">{wallet?.address ?? 'Creating your wallet…'}</div><button className="button dark full" onClick={copyAddress}><Copy size={16} /> Copy wallet address</button><div className="info-box"><strong>This wallet lives in this browser.</strong><p>The non-extractable private key is stored in IndexedDB. Clearing site data or losing this browser profile permanently loses access. There is no recovery or backup in this prototype. Use only for testing.</p></div><p className="wallet-count"><Layers3 size={17} /> {owned.length} cards 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">The recipient must create a Wikideck wallet on this same local network and share their 64-character receive address.</p><label className="field-label" htmlFor="recipient">RECIPIENT ADDRESS</label><input className="recipient-input" id="recipient" value={recipient} onChange={e => setRecipient(e.target.value)} placeholder="Paste a 64-character wallet address" autoComplete="off" /><div className="info-box"><strong>Transfers cannot be undone.</strong><p>This will sign an ownership transfer. Double-check the address; there is no recovery for mistakes.</p></div>{error && <p className="transfer-error" role="alert">{error}</p>}<button className="button dark full" disabled={transferBusy || !/^[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 transfer…' : 'Confirm & send card'}</button></>
|
||||
: selected && <><div className="detail-card"><Card card={selected} /></div><div className="detail-copy"><div className="section-eyebrow">{selected.block ? `DISCOVERED 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' ? `Revision ${selected.article.revisionId}` : 'Offline starter'}</strong></span></div>{selected.block && <><label className="field-label">VERIFIED PROOF HASH</label><div className="address-box">{selected.hash}</div><p className="muted">Current owner: {short(ownerOf(snapshot!, selected.hash) ?? '')}</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></div><p className="attribution">{selected.article.source === 'live' ? 'Text by Wikipedia contributors, CC BY-SA 4.0. Excerpt shortened for card display.' : 'Original starter summary; linked to the real Wikipedia article. No live revision captured.'} Stats are game values, not encyclopedia facts.</p>{selected.block && ownerOf(snapshot!, selected.hash) === wallet?.address && <button className="button dark full" onClick={() => setDialog('transfer')}>Transfer this card <ArrowUpRight size={16} /></button>}</div></>}
|
||||
</section></div>}
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user