719 lines
21 KiB
TypeScript
719 lines
21 KiB
TypeScript
import React, { FormEvent, useEffect, useMemo, useState } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import {
|
|
ApiError,
|
|
AuthMode,
|
|
Economy,
|
|
Governance,
|
|
ListingDetail,
|
|
ListingSummary,
|
|
PanelName,
|
|
Session,
|
|
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]);
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(OPERATOR_STORAGE_KEY, operator);
|
|
}, [operator]);
|
|
|
|
useEffect(() => {
|
|
if (route.name === 'login') return;
|
|
let cancelled = false;
|
|
api
|
|
.session()
|
|
.then((payload) => {
|
|
if (cancelled) return;
|
|
setSession(payload);
|
|
if (!payload.fleet_admin) setOperator(payload.operator_id);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setSession(null);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [route.name]);
|
|
|
|
const walletOperator = session && !session.fleet_admin ? session.operator_id : 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>
|
|
{session?.fleet_admin && <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={walletOperator} />}
|
|
{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 [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [authMode, setAuthMode] = useState<AuthMode>('both');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState<'passcode' | 'gpt' | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api
|
|
.config()
|
|
.then((payload) => {
|
|
if (!cancelled) setAuthMode(payload.auth_mode);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setAuthMode('both');
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
async function submitPasscode(event: FormEvent) {
|
|
event.preventDefault();
|
|
setBusy('passcode');
|
|
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(null);
|
|
}
|
|
}
|
|
|
|
async function submitGpt(event: FormEvent) {
|
|
event.preventDefault();
|
|
setBusy('gpt');
|
|
setError(null);
|
|
|
|
try {
|
|
await api.loginGpt(email, password);
|
|
window.location.hash = '#/';
|
|
} catch (err) {
|
|
setError(err instanceof ApiError && err.status === 401 ? 'Credentials rejected.' : 'Login failed.');
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
const showPasscode = authMode === 'passcode' || authMode === 'both';
|
|
const showGpt = authMode === 'gpt' || authMode === 'both';
|
|
|
|
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>
|
|
<div className="login-options">
|
|
{showGpt && (
|
|
<form onSubmit={submitGpt} className="login-form">
|
|
<label htmlFor="email">m2-gpt email</label>
|
|
<input
|
|
id="email"
|
|
autoFocus={showGpt}
|
|
type="email"
|
|
value={email}
|
|
onChange={(event) => setEmail(event.target.value)}
|
|
autoComplete="username"
|
|
/>
|
|
<label htmlFor="password">m2-gpt password</label>
|
|
<input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
autoComplete="current-password"
|
|
/>
|
|
<button disabled={busy !== null || email.trim().length === 0 || password.length === 0}>
|
|
{busy === 'gpt' ? 'Checking...' : 'Sign in'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
{showPasscode && (
|
|
<form onSubmit={submitPasscode} className="login-form">
|
|
<label htmlFor="passcode">Fleet passcode</label>
|
|
<input
|
|
id="passcode"
|
|
autoFocus={!showGpt}
|
|
type="password"
|
|
value={passcode}
|
|
onChange={(event) => setPasscode(event.target.value)}
|
|
autoComplete="current-password"
|
|
/>
|
|
<button disabled={busy !== null || passcode.trim().length === 0}>
|
|
{busy === 'passcode' ? 'Checking...' : 'Enter'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
{error && <p className="form-error">{error}</p>}
|
|
</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>,
|
|
);
|