m2-market/web/frontend/src/main.tsx

630 lines
19 KiB
TypeScript

import React, { FormEvent, useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
ApiError,
Economy,
Governance,
ListingDetail,
ListingSummary,
PanelName,
Wallet,
api,
} from './api';
import './styles.css';
const OPERATOR_PRESETS = ['m2bd', 'sdjs-operator', 'chris-operator', 'gunnar-operator'];
const OPERATOR_STORAGE_KEY = 'm2-market.operator';
type Route =
| { name: 'login' }
| { name: 'search' }
| { name: 'listing'; id: string }
| { name: 'wallet' }
| { name: 'economy' }
| { name: 'governance' };
function routeFromHash(): Route {
const hash = window.location.hash.replace(/^#/, '') || '/';
const [path] = hash.split('?');
const parts = path.split('/').filter(Boolean);
if (parts[0] === 'login') return { name: 'login' };
if (parts[0] === 'listing' && parts[1]) return { name: 'listing', id: decodeURIComponent(parts[1]) };
if (parts[0] === 'wallet') return { name: 'wallet' };
if (parts[0] === 'economy') return { name: 'economy' };
if (parts[0] === 'governance') return { name: 'governance' };
return { name: 'search' };
}
function useRoute(): Route {
const [route, setRoute] = useState(routeFromHash);
useEffect(() => {
const onChange = () => setRoute(routeFromHash());
window.addEventListener('hashchange', onChange);
return () => window.removeEventListener('hashchange', onChange);
}, []);
return route;
}
function formatM2cr(value: number): string {
return `${new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value)} m2cr`;
}
function formatDate(value?: string): string {
if (!value) return 'unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function getPanelError(error: unknown, panel: PanelName): string | null {
const panelAliases: Record<PanelName, string[]> = {
search: ['search', 'catalog'],
listing: ['listing', 'catalog'],
wallet: ['wallet', 'ledger'],
economy: ['economy', 'ledger'],
governance: ['governance', 'registry', 'forgejo'],
login: ['login'],
};
if (
error instanceof ApiError &&
error.status === 502 &&
(!error.panel || panelAliases[panel].includes(error.panel))
) {
return error.message;
}
if (error instanceof ApiError && error.status === 502) return null;
if (error instanceof ApiError && error.status !== 401) return error.message;
if (error instanceof Error) return error.message;
return null;
}
function PanelError({ panel, error }: { panel: PanelName; error: unknown }) {
const message = getPanelError(error, panel);
if (!message) return null;
return (
<div className="panel-error" role="status">
<strong>{panel} rail unavailable</strong>
<span>{message}</span>
</div>
);
}
function App() {
const route = useRoute();
const [operator, setOperator] = useState(() => localStorage.getItem(OPERATOR_STORAGE_KEY) || OPERATOR_PRESETS[0]);
useEffect(() => {
localStorage.setItem(OPERATOR_STORAGE_KEY, operator);
}, [operator]);
if (route.name === 'login') {
return <LoginPage />;
}
return (
<div className="app-shell">
<header className="topbar">
<a className="brand" href="#/">
<span className="brand-mark">m2</span>
<span>
<strong>market</strong>
<small>machine.machine</small>
</span>
</a>
<nav className="nav" aria-label="Primary">
<NavLink href="#/" active={route.name === 'search'} label="Search" />
<NavLink href="#/wallet" active={route.name === 'wallet'} label="Wallet" />
<NavLink href="#/economy" active={route.name === 'economy'} label="Economy" />
<NavLink href="#/governance" active={route.name === 'governance'} label="Governance" />
</nav>
<OperatorPicker value={operator} onChange={setOperator} />
</header>
<main className="main-grid">
{route.name === 'search' && <SearchPage />}
{route.name === 'listing' && <ListingPage listingId={route.id} />}
{route.name === 'wallet' && <WalletPage operator={operator} />}
{route.name === 'economy' && <EconomyPage />}
{route.name === 'governance' && <GovernancePage />}
</main>
</div>
);
}
function NavLink({ href, active, label }: { href: string; active: boolean; label: string }) {
return (
<a className={active ? 'active' : ''} href={href}>
{label}
</a>
);
}
function LoginPage() {
const [passcode, setPasscode] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
try {
await api.login(passcode);
window.location.hash = '#/';
} catch (err) {
setError(err instanceof ApiError && err.status === 401 ? 'Passcode rejected.' : 'Login failed.');
} finally {
setBusy(false);
}
}
return (
<main className="login-page">
<section className="login-panel">
<div className="brand login-brand">
<span className="brand-mark">m2</span>
<span>
<strong>market</strong>
<small>machine.machine</small>
</span>
</div>
<form onSubmit={submit} className="login-form">
<label htmlFor="passcode">Fleet passcode</label>
<input
id="passcode"
autoFocus
type="password"
value={passcode}
onChange={(event) => setPasscode(event.target.value)}
autoComplete="current-password"
/>
{error && <p className="form-error">{error}</p>}
<button disabled={busy || passcode.trim().length === 0}>{busy ? 'Checking...' : 'Enter'}</button>
</form>
</section>
</main>
);
}
function OperatorPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const presetValue = OPERATOR_PRESETS.includes(value) ? value : '';
return (
<section className="operator-picker" aria-label="Operator picker">
<select
value={presetValue}
onChange={(event) => {
if (event.target.value) onChange(event.target.value);
}}
>
<option value="" disabled>
custom
</option>
{OPERATOR_PRESETS.map((operator) => (
<option key={operator} value={operator}>
{operator}
</option>
))}
</select>
<input value={value} onChange={(event) => onChange(event.target.value)} aria-label="Operator id" />
</section>
);
}
function SearchPage() {
const [query, setQuery] = useState('pdf report');
const [submittedQuery, setSubmittedQuery] = useState('pdf report');
const [results, setResults] = useState<ListingSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.search(submittedQuery, 10)
.then((payload) => {
if (!cancelled) setResults(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [submittedQuery]);
function submit(event: FormEvent) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
return (
<section className="view">
<div className="view-heading">
<p>Catalog</p>
<h1>Search listings</h1>
</div>
<form className="search-bar" onSubmit={submit}>
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search catalog" />
<button>Search</button>
</form>
<PanelError panel="search" error={error} />
{loading && <div className="empty-state">Loading catalog results...</div>}
{!loading && !error && results.length === 0 && <div className="empty-state">No visible listings matched.</div>}
<div className="cards-grid">
{results.map((listing) => (
<ListingCard key={listing.listing_id} listing={listing} />
))}
</div>
</section>
);
}
function ListingCard({ listing }: { listing: ListingSummary }) {
return (
<article className="listing-card">
<div>
<p className="eyebrow">{listing.category || 'solution'}</p>
<h2>{listing.name}</h2>
<p>{listing.summary}</p>
</div>
<dl className="metrics">
<div>
<dt>price</dt>
<dd>{formatM2cr(listing.price.amount)}</dd>
</div>
<div>
<dt>installs</dt>
<dd>{listing.stats?.installs ?? 0}</dd>
</div>
</dl>
<a className="text-link" href={`#/listing/${encodeURIComponent(listing.listing_id)}`}>
Open listing
</a>
</article>
);
}
function ListingPage({ listingId }: { listingId: string }) {
const [listing, setListing] = useState<ListingDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.listing(listingId)
.then((payload) => {
if (!cancelled) setListing(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [listingId]);
async function copyCommand(command: string) {
await navigator.clipboard.writeText(command);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
}
return (
<section className="view">
<a className="text-link back-link" href="#/">
Back to search
</a>
<PanelError panel="listing" error={error} />
{loading && <div className="empty-state">Loading listing...</div>}
{listing && (
<>
<div className="listing-hero">
<p className="eyebrow">{listing.category || listing.listing_id}</p>
<h1>{listing.name}</h1>
<p>{listing.summary}</p>
<dl className="metrics wide">
<div>
<dt>seller</dt>
<dd>{listing.seller}</dd>
</div>
<div>
<dt>version</dt>
<dd>{listing.version || listing.solution_version || 'unknown'}</dd>
</div>
<div>
<dt>price</dt>
<dd>{formatM2cr(listing.price.amount)}</dd>
</div>
<div>
<dt>installs</dt>
<dd>{listing.stats?.installs ?? 0}</dd>
</div>
</dl>
</div>
<div className="two-column">
<section className="surface">
<p className="eyebrow">Evidence</p>
<p>{listing.evidence_summary}</p>
</section>
<section className="surface">
<p className="eyebrow">Permissions</p>
{listing.permissions.length === 0 ? (
<p>No extra permissions declared.</p>
) : (
<ul className="plain-list">
{listing.permissions.map((permission) => (
<li key={permission}>{permission}</li>
))}
</ul>
)}
</section>
</div>
<section className="handoff">
<div>
<p className="eyebrow">Install handoff</p>
<h2>Run this on your desktop terminal</h2>
{listing.install_ref && <p>Install ref: {listing.install_ref}</p>}
</div>
<code>{listing.install_command}</code>
<button onClick={() => copyCommand(listing.install_command)}>{copied ? 'Copied' : 'Copy command'}</button>
</section>
</>
)}
</section>
);
}
function WalletPage({ operator }: { operator: string }) {
const [wallet, setWallet] = useState<Wallet | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.wallet(operator.trim() || 'unknown')
.then((payload) => {
if (!cancelled) setWallet(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [operator]);
return (
<section className="view">
<div className="view-heading">
<p>Wallet</p>
<h1>{operator || 'unknown operator'}</h1>
</div>
<PanelError panel="wallet" error={error} />
{loading && <div className="empty-state">Loading wallet...</div>}
{wallet && (
<>
<section className="balance-strip">
<div>
<span>balance</span>
<strong>{formatM2cr(wallet.balance)}</strong>
</div>
<p>as of {formatDate(wallet.as_of)}</p>
</section>
{wallet.balance === 0 && wallet.transactions.length === 0 && (
<div className="empty-state">No such wallet yet, or the wallet has no activity.</div>
)}
<TransactionTable transactions={wallet.transactions} />
</>
)}
</section>
);
}
function TransactionTable({ transactions }: { transactions: Wallet['transactions'] }) {
if (transactions.length === 0) return null;
return (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>From</th>
<th>To</th>
<th>Amount</th>
<th>Reason</th>
<th>Ref</th>
</tr>
</thead>
<tbody>
{transactions.map((tx, index) => (
<tr key={`${tx.ts}-${tx.ref ?? index}`}>
<td>{formatDate(tx.ts)}</td>
<td>{tx.from}</td>
<td>{tx.to}</td>
<td>{formatM2cr(tx.amount)}</td>
<td>{tx.reason}</td>
<td>{tx.ref || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function EconomyPage() {
const [economy, setEconomy] = useState<Economy | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.economy()
.then((payload) => {
if (!cancelled) setEconomy(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const total = useMemo(() => economy?.operators.reduce((sum, operator) => sum + operator.balance, 0) ?? 0, [economy]);
return (
<section className="view">
<div className="view-heading">
<p>Economy</p>
<h1>Fleet balances</h1>
</div>
<PanelError panel="economy" error={error} />
{loading && <div className="empty-state">Loading economy...</div>}
{economy && (
<>
<section className="balance-strip">
<div>
<span>total</span>
<strong>{formatM2cr(total)}</strong>
</div>
{economy.audit ? (
<a className="text-link" href={economy.audit.url} target="_blank" rel="noreferrer">
Audit snapshot {economy.audit.date}
</a>
) : (
<p>No audit snapshot linked.</p>
)}
</section>
<div className="cards-grid compact">
{economy.operators.map((operator) => (
<article className="mini-card" key={operator.operator_id}>
<span>{operator.operator_id}</span>
<strong>{formatM2cr(operator.balance)}</strong>
</article>
))}
</div>
</>
)}
</section>
);
}
function GovernancePage() {
const [governance, setGovernance] = useState<Governance | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api
.governance()
.then((payload) => {
if (!cancelled) setGovernance(payload);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<section className="view">
<div className="view-heading">
<p>Governance</p>
<h1>Registry review</h1>
</div>
<PanelError panel="governance" error={error} />
{loading && <div className="empty-state">Loading governance...</div>}
{governance && (
<div className="two-column">
<section className="surface">
<p className="eyebrow">Open PRs</p>
{governance.open_prs.length === 0 ? (
<p>No open listing PRs.</p>
) : (
<ul className="review-list">
{governance.open_prs.map((pr) => (
<li key={pr.number}>
<a href={pr.url} target="_blank" rel="noreferrer">
#{pr.number} {pr.title}
</a>
<span>{pr.age_days}d open</span>
<div className="labels">
{pr.labels.map((label) => (
<span key={label}>{label}</span>
))}
</div>
</li>
))}
</ul>
)}
</section>
<section className="surface">
<p className="eyebrow">Listings</p>
{governance.listings.length === 0 ? (
<p>No listings returned.</p>
) : (
<ul className="review-list">
{governance.listings.map((listing) => (
<li key={listing.listing_id}>
<a href={listing.url} target="_blank" rel="noreferrer">
{listing.listing_id}
</a>
<span className={`status ${listing.status}`}>{listing.status}</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
</section>
);
}
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);