T102: web frontend SPA (search, listing, wallet, economy, governance)

This commit is contained in:
m2 (AI Agent) 2026-07-02 05:46:06 +02:00
parent e896a60bc0
commit b27c5d1981
9 changed files with 3084 additions and 0 deletions

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ __pycache__/
.venv/
uv.lock
.pytest_cache/
web/frontend/node_modules/
web/frontend/dist/

13
web/frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load diff

22
web/frontend/package.json Normal file
View 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
View 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
View 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
View 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;
}
}

View 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": []
}

View 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',
},
},
});