T102: web frontend SPA (search, listing, wallet, economy, governance)
This commit is contained in:
parent
e896a60bc0
commit
b27c5d1981
9 changed files with 3084 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ __pycache__/
|
|||
.venv/
|
||||
uv.lock
|
||||
.pytest_cache/
|
||||
|
||||
web/frontend/node_modules/
|
||||
web/frontend/dist/
|
||||
|
|
|
|||
13
web/frontend/index.html
Normal file
13
web/frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f1830" />
|
||||
<title>M2 Market</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1729
web/frontend/package-lock.json
generated
Normal file
1729
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
web/frontend/package.json
Normal file
22
web/frontend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "m2-market-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
130
web/frontend/src/api.ts
Normal file
130
web/frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
export type Price = {
|
||||
amount: number;
|
||||
currency: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ListingStats = {
|
||||
installs?: number;
|
||||
proposals_shown?: number;
|
||||
proposals_accepted?: number;
|
||||
};
|
||||
|
||||
export type ListingSummary = {
|
||||
listing_id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
category?: string;
|
||||
price: Price;
|
||||
seller: string;
|
||||
stats?: ListingStats;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export type ListingDetail = ListingSummary & {
|
||||
evidence_summary: string;
|
||||
permissions: string[];
|
||||
install_ref?: string;
|
||||
install_command: string;
|
||||
solution_version?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export type Transaction = {
|
||||
ts: string;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export type Wallet = {
|
||||
operator_id: string;
|
||||
balance: number;
|
||||
as_of?: string;
|
||||
transactions: Transaction[];
|
||||
};
|
||||
|
||||
export type Economy = {
|
||||
operators: Array<{ operator_id: string; balance: number }>;
|
||||
audit?: { date: string; url: string };
|
||||
};
|
||||
|
||||
export type Governance = {
|
||||
open_prs: Array<{ number: number; title: string; age_days: number; labels: string[]; url: string }>;
|
||||
listings: Array<{ listing_id: string; status: string; url: string }>;
|
||||
};
|
||||
|
||||
export type PanelName = 'search' | 'listing' | 'wallet' | 'economy' | 'governance' | 'login';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
panel?: string;
|
||||
|
||||
constructor(message: string, status: number, panel?: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.panel = panel;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
if (window.location.hash !== '#/login') {
|
||||
window.location.hash = '#/login';
|
||||
}
|
||||
throw new ApiError('Login required', 401);
|
||||
}
|
||||
|
||||
if (response.status === 502) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string; panel?: string } | null;
|
||||
throw new ApiError(body?.error ?? 'Panel is temporarily unavailable', 502, body?.panel);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new ApiError(body?.error ?? `Request failed (${response.status})`, response.status);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login(passcode: string) {
|
||||
return request<void>('/api/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ passcode }),
|
||||
});
|
||||
},
|
||||
search(q: string, limit = 10) {
|
||||
const params = new URLSearchParams({ q, limit: String(limit) });
|
||||
return request<ListingSummary[]>(`/api/search?${params}`);
|
||||
},
|
||||
listing(listingId: string) {
|
||||
return request<ListingDetail>(`/api/listing/${encodeURIComponent(listingId)}`);
|
||||
},
|
||||
wallet(operatorId: string) {
|
||||
return request<Wallet>(`/api/wallet/${encodeURIComponent(operatorId)}`);
|
||||
},
|
||||
economy() {
|
||||
return request<Economy>('/api/economy');
|
||||
},
|
||||
governance() {
|
||||
return request<Governance>('/api/governance');
|
||||
},
|
||||
};
|
||||
630
web/frontend/src/main.tsx
Normal file
630
web/frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
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>,
|
||||
);
|
||||
521
web/frontend/src/styles.css
Normal file
521
web/frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #eaf7ff;
|
||||
background: #0f1830;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
--bg: #0f1830;
|
||||
--bg-soft: #14213d;
|
||||
--panel: #182642;
|
||||
--panel-strong: #203456;
|
||||
--line: rgba(129, 210, 232, 0.22);
|
||||
--cyan: #2ee8ff;
|
||||
--cyan-soft: #9df3ff;
|
||||
--green: #70f1b5;
|
||||
--red: #ff7c9a;
|
||||
--text: #eaf7ff;
|
||||
--muted: #9eb4c8;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(46, 232, 255, 0.08), transparent 34rem),
|
||||
radial-gradient(circle at top right, rgba(112, 241, 181, 0.12), transparent 26rem),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem clamp(1rem, 3vw, 2rem);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(15, 24, 48, 0.9);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--cyan);
|
||||
background: rgba(46, 232, 255, 0.12);
|
||||
color: var(--cyan);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav a,
|
||||
.text-link {
|
||||
color: var(--cyan-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav a.active,
|
||||
.nav a:hover {
|
||||
border-color: var(--line);
|
||||
background: rgba(46, 232, 255, 0.1);
|
||||
}
|
||||
|
||||
.operator-picker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, auto) minmax(10rem, 14rem);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: rgba(7, 12, 24, 0.56);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--cyan);
|
||||
box-shadow: 0 0 0 3px rgba(46, 232, 255, 0.14);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid rgba(46, 232, 255, 0.58);
|
||||
border-radius: 6px;
|
||||
padding: 0.72rem 1rem;
|
||||
background: var(--cyan);
|
||||
color: #08111f;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
width: min(1180px, calc(100vw - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: clamp(1.5rem, 5vw, 4rem) 0;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.view-heading p,
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--cyan);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
max-width: 840px;
|
||||
font-size: clamp(2.15rem, 7vw, 4.75rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cards-grid.compact {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
}
|
||||
|
||||
.listing-card,
|
||||
.surface,
|
||||
.mini-card,
|
||||
.handoff,
|
||||
.balance-strip,
|
||||
.listing-hero,
|
||||
.login-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(32, 52, 86, 0.9), rgba(20, 33, 61, 0.82));
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.listing-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-height: 20rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metrics.wide {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metrics div,
|
||||
.mini-card {
|
||||
padding: 0.85rem;
|
||||
border: 1px solid rgba(157, 243, 255, 0.16);
|
||||
background: rgba(7, 12, 24, 0.34);
|
||||
}
|
||||
|
||||
dt,
|
||||
.mini-card span,
|
||||
.balance-strip span {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
dd,
|
||||
.mini-card strong,
|
||||
.balance-strip strong {
|
||||
display: block;
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--text);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.listing-hero,
|
||||
.surface,
|
||||
.handoff,
|
||||
.balance-strip {
|
||||
padding: clamp(1rem, 3vw, 1.5rem);
|
||||
}
|
||||
|
||||
.listing-hero {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.listing-hero p {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.two-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.plain-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.handoff {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
background: linear-gradient(135deg, rgba(46, 232, 255, 0.13), rgba(112, 241, 181, 0.1)), var(--panel);
|
||||
}
|
||||
|
||||
.handoff code {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(46, 232, 255, 0.28);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
background: rgba(7, 12, 24, 0.72);
|
||||
color: var(--cyan-soft);
|
||||
}
|
||||
|
||||
.handoff button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.balance-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 820px;
|
||||
border-collapse: collapse;
|
||||
background: rgba(20, 33, 61, 0.78);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-bottom: 1px solid rgba(157, 243, 255, 0.12);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--cyan);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.review-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.review-list li {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid rgba(157, 243, 255, 0.14);
|
||||
background: rgba(7, 12, 24, 0.28);
|
||||
}
|
||||
|
||||
.review-list a {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.review-list span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.labels span,
|
||||
.status {
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(46, 232, 255, 0.25);
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.5rem;
|
||||
background: rgba(46, 232, 255, 0.09);
|
||||
color: var(--cyan-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.status.published {
|
||||
border-color: rgba(112, 241, 181, 0.38);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.status.delisted {
|
||||
border-color: rgba(255, 124, 154, 0.4);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.panel-error,
|
||||
.empty-state {
|
||||
border: 1px solid rgba(255, 124, 154, 0.34);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 124, 154, 0.1);
|
||||
color: #ffdbe4;
|
||||
}
|
||||
|
||||
.panel-error strong,
|
||||
.panel-error span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.panel-error span {
|
||||
margin-top: 0.3rem;
|
||||
color: #ffdbe4;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border-color: var(--line);
|
||||
background: rgba(32, 52, 86, 0.58);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
width: min(100%, 420px);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
color: var(--cyan);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.topbar {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.operator-picker,
|
||||
.search-bar,
|
||||
.two-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics.wide {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.balance-strip {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-grid {
|
||||
width: min(100% - 1rem, 1180px);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.metrics.wide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
21
web/frontend/tsconfig.json
Normal file
21
web/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
15
web/frontend/vite.config.ts
Normal file
15
web/frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8000',
|
||||
'/health': 'http://127.0.0.1:8000',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue