docs+web: refresh CLAUDE.md/README to current state; operator /admin console
- CLAUDE.md: replaced stale "scaffold only" with an accurate Built / In progress / Not-built status; added a Secrets section; updated dev commands + pm2/.env notes. - README.md: roadmap reflects v0.1–v0.4 working (clean→burn→fee→Essence→Prometheus), sell=detection-only, v1.0 pending; quick-start + secrets accurate. - apps/web /admin: operator console — paste admin token (sessionStorage, never baked/committed), generate Spawns (chaos/seed/receiptId) + record manual Pump.fun launches; 403 re-prompts. Public route, gated by the API token. web build green (+/admin). Status site already redeployed (Phase 4 updated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
630
apps/web/src/app/admin/page.tsx
Normal file
630
apps/web/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { PrometheusGenerateResponse, SpawnRecord } from "@pyre/core";
|
||||
import { Footer } from "../../components/Footer";
|
||||
|
||||
// Same-origin by default so production hits "/api/…" behind the same host.
|
||||
// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev).
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
// The admin token is operator-supplied at runtime and kept ONLY in sessionStorage
|
||||
// (cleared when the tab closes). It is NEVER read from an env var, NEVER baked
|
||||
// into the build, and NEVER committed. It travels only as the `x-admin-token`
|
||||
// header on admin calls.
|
||||
const TOKEN_KEY = "pyre.adminToken";
|
||||
|
||||
/** Pull a string field out of the loosely-typed generation metadata blob. */
|
||||
function metaString(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const v = metadata?.[key];
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
// Humanize a risk flag (e.g. "PROFANITY_DETECTED" -> "profanity detected").
|
||||
function humanizeRiskFlag(flag: string): string {
|
||||
return flag.toLowerCase().replace(/_/g, " ").trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TokenGate({
|
||||
token,
|
||||
onSave,
|
||||
onClear,
|
||||
authError,
|
||||
}: {
|
||||
token: string | null;
|
||||
onSave: (t: string) => void;
|
||||
onClear: () => void;
|
||||
authError: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const save = () => {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed) {
|
||||
onSave(trimmed);
|
||||
setDraft("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card" aria-labelledby="admin-token-heading">
|
||||
<h2 className="admin-card__heading" id="admin-token-heading">
|
||||
Operator token
|
||||
</h2>
|
||||
{token ? (
|
||||
<div className="admin-token-set">
|
||||
<span className="admin-token-set__badge">admin token set</span>
|
||||
<span className="admin-token-set__note">
|
||||
Held in this tab only (sessionStorage). Sent as{" "}
|
||||
<code>x-admin-token</code>. Never stored on the server or in the build.
|
||||
</span>
|
||||
<button type="button" className="admin-btn admin-btn--ghost" onClick={onClear}>
|
||||
Clear token
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-token-form">
|
||||
<p className="admin-card__hint">
|
||||
Paste your admin token to enable operator actions. It stays in this
|
||||
browser tab only and is sent as the <code>x-admin-token</code> header.
|
||||
</p>
|
||||
<div className="admin-field-row">
|
||||
<input
|
||||
type="password"
|
||||
className="admin-input"
|
||||
placeholder="admin token"
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") save();
|
||||
}}
|
||||
aria-label="Admin token"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn"
|
||||
onClick={save}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{authError && (
|
||||
<p className="error admin-error" role="alert">
|
||||
invalid or missing admin token — paste a valid token to continue.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GeneratePanel({
|
||||
token,
|
||||
onAuthError,
|
||||
onGenerated,
|
||||
generation,
|
||||
}: {
|
||||
token: string | null;
|
||||
onAuthError: () => void;
|
||||
onGenerated: (g: PrometheusGenerateResponse) => void;
|
||||
generation: PrometheusGenerateResponse | null;
|
||||
}) {
|
||||
const [chaos, setChaos] = useState(0.5);
|
||||
const [operatorSeed, setOperatorSeed] = useState("");
|
||||
const [receiptId, setReceiptId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: {
|
||||
receiptId?: string;
|
||||
chaos?: number;
|
||||
operatorSeed?: string;
|
||||
} = { chaos };
|
||||
if (operatorSeed.trim()) body.operatorSeed = operatorSeed.trim();
|
||||
if (receiptId.trim()) body.receiptId = receiptId.trim();
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/prometheus/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-admin-token": token,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 403) {
|
||||
onAuthError();
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Generation failed (${res.status})`);
|
||||
const data = (await res.json()) as PrometheusGenerateResponse;
|
||||
onGenerated(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Generation failed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, chaos, operatorSeed, receiptId, onAuthError, onGenerated]);
|
||||
|
||||
const tagline = useMemo(
|
||||
() => metaString(generation?.metadata, "tagline"),
|
||||
[generation],
|
||||
);
|
||||
const description = useMemo(
|
||||
() => metaString(generation?.metadata, "description"),
|
||||
[generation],
|
||||
);
|
||||
const imageUrl = useMemo(
|
||||
() => metaString(generation?.metadata, "imageUrl"),
|
||||
[generation],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="admin-card" aria-labelledby="admin-generate-heading">
|
||||
<h2 className="admin-card__heading" id="admin-generate-heading">
|
||||
Generate a Spawn
|
||||
</h2>
|
||||
<p className="admin-card__hint">
|
||||
Drive Prometheus to produce a Spawn package for manual review. Generation
|
||||
does nothing on-chain — it only proposes a name, ticker, lore, and image.
|
||||
</p>
|
||||
|
||||
<fieldset className="admin-fieldset" disabled={!token || loading}>
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">
|
||||
Chaos <span className="admin-field__value">{chaos.toFixed(2)}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
className="admin-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={chaos}
|
||||
onChange={(e) => setChaos(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">Operator seed (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="e.g. molten phoenix, cursed embers…"
|
||||
value={operatorSeed}
|
||||
onChange={(e) => setOperatorSeed(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">Receipt id (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="burn receipt id to seed from"
|
||||
value={receiptId}
|
||||
onChange={(e) => setReceiptId(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="button" className="admin-btn" onClick={generate}>
|
||||
{loading ? "Generating…" : "Generate Spawn"}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
{!token && (
|
||||
<p className="admin-card__hint admin-card__hint--muted">
|
||||
Set an admin token above to generate.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="admin-loading" role="status" aria-live="polite">
|
||||
<span className="scanner__spinner" aria-hidden="true" />
|
||||
Summoning a Spawn from the embers…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="error admin-error" role="alert">
|
||||
Something went wrong: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{generation && (
|
||||
<div className="admin-result">
|
||||
<div className="admin-result__head">
|
||||
{imageUrl ? (
|
||||
// Pollinations / generated image URLs render directly.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="admin-result__img"
|
||||
src={imageUrl}
|
||||
alt={`${generation.spawnName} artwork`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="admin-result__img admin-result__img--placeholder"
|
||||
aria-hidden="true"
|
||||
>
|
||||
🔥
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-result__titles">
|
||||
<h3 className="admin-result__name">
|
||||
{generation.spawnName}{" "}
|
||||
<span className="admin-result__ticker">${generation.ticker}</span>
|
||||
</h3>
|
||||
{tagline && <p className="admin-result__tagline">{tagline}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(description || generation.lore) && (
|
||||
<p className="admin-result__lore">{description ?? generation.lore}</p>
|
||||
)}
|
||||
{description && generation.lore && description !== generation.lore && (
|
||||
<p className="admin-result__lore admin-result__lore--muted">
|
||||
{generation.lore}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="admin-result__meta">
|
||||
<div className="admin-result__row">
|
||||
<dt>Generation id</dt>
|
||||
<dd>
|
||||
<code>{generation.generationId}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="admin-result__row">
|
||||
<dt>Image prompt</dt>
|
||||
<dd>{generation.imagePrompt}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{generation.riskFlags.length > 0 ? (
|
||||
<div className="admin-riskflags admin-riskflags--warn">
|
||||
<span className="admin-riskflags__label">
|
||||
⚠ Review these risk flags before launching:
|
||||
</span>
|
||||
<div className="admin-riskflags__chips">
|
||||
{generation.riskFlags.map((f) => (
|
||||
<span key={f} className="admin-chip admin-chip--warn" title={f}>
|
||||
{humanizeRiskFlag(f)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="admin-riskflags__none">No risk flags raised.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncate(addr: string): string {
|
||||
if (addr.length <= 10) return addr;
|
||||
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
function LaunchPanel({
|
||||
token,
|
||||
generation,
|
||||
onAuthError,
|
||||
}: {
|
||||
token: string | null;
|
||||
generation: PrometheusGenerateResponse;
|
||||
onAuthError: () => void;
|
||||
}) {
|
||||
const [mint, setMint] = useState("");
|
||||
const [pumpfunUrl, setPumpfunUrl] = useState("");
|
||||
const [launchTx, setLaunchTx] = useState("");
|
||||
const [metadataUri, setMetadataUri] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [record, setRecord] = useState<SpawnRecord | null>(null);
|
||||
|
||||
// A fresh generation invalidates any previous launch result/inputs.
|
||||
useEffect(() => {
|
||||
setRecord(null);
|
||||
setError(null);
|
||||
}, [generation.generationId]);
|
||||
|
||||
const record_launch = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: {
|
||||
generationId: string;
|
||||
mint: string;
|
||||
metadataUri?: string;
|
||||
pumpfunUrl?: string;
|
||||
launchTx?: string;
|
||||
} = { generationId: generation.generationId, mint: mint.trim() };
|
||||
if (metadataUri.trim()) body.metadataUri = metadataUri.trim();
|
||||
if (pumpfunUrl.trim()) body.pumpfunUrl = pumpfunUrl.trim();
|
||||
if (launchTx.trim()) body.launchTx = launchTx.trim();
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/spawn/launch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-admin-token": token,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 403) {
|
||||
onAuthError();
|
||||
return;
|
||||
}
|
||||
if (res.status === 404) {
|
||||
throw new Error("generation not found (404) — re-generate and retry.");
|
||||
}
|
||||
if (!res.ok) throw new Error(`Record launch failed (${res.status})`);
|
||||
const data = (await res.json()) as SpawnRecord;
|
||||
setRecord(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Record launch failed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, generation.generationId, mint, metadataUri, pumpfunUrl, launchTx, onAuthError]);
|
||||
|
||||
return (
|
||||
<section className="admin-card" aria-labelledby="admin-launch-heading">
|
||||
<h2 className="admin-card__heading" id="admin-launch-heading">
|
||||
Record a Pump.fun launch
|
||||
</h2>
|
||||
<p className="admin-card__hint">
|
||||
After you create the token on Pump.fun by hand, record its identifiers
|
||||
here. PYRE never signs or creates the launch — this only files the manual
|
||||
result against the generation.
|
||||
</p>
|
||||
|
||||
<fieldset className="admin-fieldset" disabled={!token || loading}>
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">Generation id</span>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input admin-input--readonly"
|
||||
value={generation.generationId}
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">Mint (required)</span>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="SPL mint address (base58)"
|
||||
value={mint}
|
||||
onChange={(e) => setMint(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">Pump.fun URL (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="https://pump.fun/…"
|
||||
value={pumpfunUrl}
|
||||
onChange={(e) => setPumpfunUrl(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">Launch tx (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="create transaction signature"
|
||||
value={launchTx}
|
||||
onChange={(e) => setLaunchTx(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-field">
|
||||
<span className="admin-field__label">Metadata URI (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-input"
|
||||
placeholder="ipfs:// or https:// metadata JSON"
|
||||
value={metadataUri}
|
||||
onChange={(e) => setMetadataUri(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn"
|
||||
onClick={record_launch}
|
||||
disabled={!mint.trim()}
|
||||
>
|
||||
{loading ? "Recording…" : "Record launch"}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
{error && (
|
||||
<p className="error admin-error" role="alert">
|
||||
Something went wrong: {error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{record && (
|
||||
<div className="admin-record">
|
||||
<p className="admin-record__headline">Launch recorded 🔥</p>
|
||||
<dl className="admin-result__meta">
|
||||
<div className="admin-result__row">
|
||||
<dt>Record id</dt>
|
||||
<dd>
|
||||
<code>{record.id}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="admin-result__row">
|
||||
<dt>Spawn</dt>
|
||||
<dd>
|
||||
{record.spawnName} <span>${record.ticker}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="admin-result__row">
|
||||
<dt>Status</dt>
|
||||
<dd>{record.status}</dd>
|
||||
</div>
|
||||
{record.mint && (
|
||||
<div className="admin-result__row">
|
||||
<dt>Mint</dt>
|
||||
<dd title={record.mint}>
|
||||
<code>{truncate(record.mint)}</code>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{record.pumpfunUrl && (
|
||||
<a
|
||||
className="admin-link"
|
||||
href={record.pumpfunUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
View on Pump.fun ↗
|
||||
</a>
|
||||
)}
|
||||
<a className="admin-link" href="/spawn">
|
||||
See it on the public Spawn record →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Operator-only admin console. Public route, but every useful action is gated
|
||||
* server-side by the `x-admin-token` header — the page does nothing without a
|
||||
* valid token. The token is pasted at runtime and kept ONLY in sessionStorage.
|
||||
*/
|
||||
export default function AdminPage() {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [authError, setAuthError] = useState(false);
|
||||
const [generation, setGeneration] = useState<PrometheusGenerateResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Hydrate the token from sessionStorage on mount (tab-scoped, never persisted
|
||||
// beyond the session, never read from env or build output).
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (stored) setToken(stored);
|
||||
} catch {
|
||||
// sessionStorage may be unavailable (e.g. privacy mode) — ignore.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveToken = useCallback((t: string) => {
|
||||
try {
|
||||
sessionStorage.setItem(TOKEN_KEY, t);
|
||||
} catch {
|
||||
// ignore storage failures; still keep it in memory for this session.
|
||||
}
|
||||
setToken(t);
|
||||
setAuthError(false);
|
||||
}, []);
|
||||
|
||||
const clearToken = useCallback(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setToken(null);
|
||||
}, []);
|
||||
|
||||
// A 403 means the stored token is invalid/missing: drop it and re-prompt.
|
||||
const handleAuthError = useCallback(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setToken(null);
|
||||
setAuthError(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="admin" aria-labelledby="admin-heading">
|
||||
<header className="admin__head">
|
||||
<p className="hero__eyebrow">Operator console</p>
|
||||
<h1 className="section-heading" id="admin-heading">
|
||||
Prometheus admin
|
||||
</h1>
|
||||
<p className="admin__intro">
|
||||
Operator-only. Drive Prometheus generation and record manual Pump.fun
|
||||
launches. The flow is intentionally manual: Prometheus proposes a
|
||||
Spawn, you review it, create the token on Pump.fun in your own wallet,
|
||||
then file the result here. PYRE never holds keys and never signs a
|
||||
launch.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<TokenGate
|
||||
token={token}
|
||||
onSave={saveToken}
|
||||
onClear={clearToken}
|
||||
authError={authError}
|
||||
/>
|
||||
|
||||
<GeneratePanel
|
||||
token={token}
|
||||
onAuthError={handleAuthError}
|
||||
onGenerated={(g) => {
|
||||
setGeneration(g);
|
||||
setAuthError(false);
|
||||
}}
|
||||
generation={generation}
|
||||
/>
|
||||
|
||||
{generation && (
|
||||
<LaunchPanel
|
||||
token={token}
|
||||
generation={generation}
|
||||
onAuthError={handleAuthError}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1057,3 +1057,342 @@ body {
|
||||
.spawn-card__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Operator admin console (/admin) ---- */
|
||||
.admin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.admin__head {
|
||||
text-align: center;
|
||||
}
|
||||
.admin__intro {
|
||||
max-width: 42rem;
|
||||
margin: 0.5rem auto 0;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
border: 1px solid rgba(255, 138, 61, 0.22);
|
||||
background: linear-gradient(180deg, rgba(255, 87, 34, 0.06), rgba(26, 20, 18, 0.6));
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem 1.5rem 1.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.admin-card__heading {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.admin-card__hint {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.admin-card__hint--muted {
|
||||
font-style: italic;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.admin-card code,
|
||||
.admin-result code,
|
||||
.admin-record code {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82em;
|
||||
color: var(--color-ember-bright);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Inputs / fields */
|
||||
.admin-fieldset {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.admin-fieldset:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.admin-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.admin-field__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: #f5ede6;
|
||||
}
|
||||
.admin-field__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-ember-bright);
|
||||
}
|
||||
.admin-field-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
.admin-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
appearance: none;
|
||||
background: var(--color-coal);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.5rem;
|
||||
color: #f5ede6;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-ember);
|
||||
box-shadow: 0 0 0 2px rgba(255, 87, 34, 0.2);
|
||||
}
|
||||
.admin-input::placeholder {
|
||||
color: rgba(184, 169, 156, 0.6);
|
||||
}
|
||||
.admin-input--readonly {
|
||||
color: var(--color-smoke);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--color-ember);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.admin-btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--color-ember);
|
||||
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
|
||||
color: #1a0d06;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
padding: 0 1.25rem;
|
||||
height: 46px;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
transition: filter 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.admin-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.admin-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.admin-btn--ghost {
|
||||
background: transparent;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: var(--color-smoke);
|
||||
height: 40px;
|
||||
}
|
||||
.admin-btn--ghost:hover:not(:disabled) {
|
||||
filter: none;
|
||||
border-color: rgba(255, 138, 61, 0.5);
|
||||
color: #f5ede6;
|
||||
}
|
||||
|
||||
/* Token gate */
|
||||
.admin-token-form,
|
||||
.admin-token-set {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.admin-token-set {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.admin-token-set__badge {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
color: #7be3a3;
|
||||
background: rgba(60, 200, 120, 0.12);
|
||||
border: 1px solid rgba(60, 200, 120, 0.35);
|
||||
}
|
||||
.admin-token-set__note {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.admin-error {
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--color-smoke);
|
||||
}
|
||||
|
||||
/* Generation / launch result */
|
||||
.admin-result,
|
||||
.admin-record {
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid rgba(255, 138, 61, 0.4);
|
||||
background: linear-gradient(180deg, rgba(255, 87, 34, 0.1), rgba(26, 20, 18, 0.7));
|
||||
border-radius: 0.85rem;
|
||||
padding: 1.25rem 1.4rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.admin-result__head {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.admin-result__img {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 0.6rem;
|
||||
border: 1px solid rgba(255, 138, 61, 0.3);
|
||||
}
|
||||
.admin-result__img--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
background: radial-gradient(80% 80% at 50% 30%, rgba(255, 87, 34, 0.2), var(--color-ash));
|
||||
}
|
||||
.admin-result__name {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
color: #f5ede6;
|
||||
}
|
||||
.admin-result__ticker {
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.admin-result__tagline {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.admin-result__lore {
|
||||
margin: 0;
|
||||
color: #f5ede6;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.admin-result__lore--muted {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.admin-result__meta {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.admin-result__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.admin-result__row dt {
|
||||
color: var(--color-smoke);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-result__row dd {
|
||||
margin: 0;
|
||||
color: #f5ede6;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Risk flag chips */
|
||||
.admin-riskflags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.admin-riskflags--warn {
|
||||
border: 1px solid rgba(255, 60, 40, 0.45);
|
||||
background: rgba(255, 60, 40, 0.08);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.admin-riskflags__label {
|
||||
color: #ff9a7a;
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.admin-riskflags__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.admin-riskflags__none {
|
||||
margin: 0;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.admin-chip {
|
||||
display: inline-block;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
padding: 0.14rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-chip--warn {
|
||||
color: #ff9a7a;
|
||||
background: rgba(255, 60, 40, 0.12);
|
||||
border: 1px solid rgba(255, 60, 40, 0.4);
|
||||
}
|
||||
|
||||
/* Recorded launch */
|
||||
.admin-record {
|
||||
border-color: rgba(60, 200, 120, 0.4);
|
||||
background: linear-gradient(180deg, rgba(60, 200, 120, 0.1), rgba(26, 20, 18, 0.7));
|
||||
}
|
||||
.admin-record__headline {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 800;
|
||||
color: #7be3a3;
|
||||
}
|
||||
.admin-link {
|
||||
align-self: flex-start;
|
||||
color: var(--color-ember-bright);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.admin-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function Footer() {
|
||||
</a>
|
||||
<a href="#scanner">Scanner</a>
|
||||
<a href="/spawn">The Spawn</a>
|
||||
<a href="/admin" rel="nofollow">Operator</a>
|
||||
</nav>
|
||||
|
||||
<p className="footer__disclaimer">
|
||||
|
||||
Reference in New Issue
Block a user