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:
2026-05-31 07:27:44 +00:00
parent 6ab0f02d06
commit 6dd541b9f4
5 changed files with 1082 additions and 70 deletions

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

View File

@@ -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;
}

View File

@@ -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">