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:
112
CLAUDE.md
112
CLAUDE.md
@@ -40,35 +40,54 @@ These are load-bearing. Do not weaken, work around, or "optimize" them. See
|
||||
|
||||
---
|
||||
|
||||
## Current status & scope guardrail
|
||||
## Current status — Built / In progress / Not yet built
|
||||
|
||||
**MVP v0.1 is a burner/cleaner only.** This repo is currently **scaffold + docs**:
|
||||
**Solana transaction logic and business logic are NOT yet implemented.** Do not
|
||||
add application/business logic unless explicitly asked.
|
||||
This repo is **no longer scaffold + docs** — the burner core is implemented and
|
||||
live at [feedthepyre.com](https://feedthepyre.com). Keep new work aligned with the
|
||||
design doc; the trust rules above are non-negotiable.
|
||||
|
||||
**Explicitly OUT of scope for v0.1** (per §5):
|
||||
**Built (working):**
|
||||
|
||||
- Automatic Pump.fun launch
|
||||
- User-contributed Essence vault
|
||||
- Custom PYRE Solana program (Anchor)
|
||||
- NFT handling (incl. compressed NFTs)
|
||||
- Automatic valuable-token sacrifice
|
||||
- Custodial signing
|
||||
- Background wallet automation
|
||||
- On-chain swap routing (TRANSMUTABLE) and Token-2022 confidential-transfer /
|
||||
fee-harvest flows
|
||||
- **Wallet scan + conservative classifier** (`@pyre/core`): EMPTY_CLOSE_ONLY /
|
||||
INCINERATE_ONLY / TRANSMUTABLE / PROTECTED_SKIP / UNSUPPORTED. "Unknown means
|
||||
skip"; never "safe".
|
||||
- **Token-2022** supported conservatively with account+mint **extension gating**
|
||||
(§7.1): confidential transfer / withheld transfer fees / frozen / unknown
|
||||
extensions skipped; transfer-hook & permanent-delegate mints cleanable but
|
||||
flagged; unverifiable mints → UNSUPPORTED. In `@pyre/core` (`extensions.ts`,
|
||||
`classify.ts`) + `@pyre/solana`.
|
||||
- **Close-empty + burn→close transactions** (`@pyre/solana`): UNSIGNED, server
|
||||
re-validates on-chain, value-gated, rent → user, with a transparent **5%
|
||||
protocol fee** (§3.1) to the treasury. Decoded + matched in the web app before
|
||||
signing. Live-verified on mainnet.
|
||||
- **Sell / transmute DETECTION** via Jupiter (read-only quotes + Shield + dust
|
||||
gate) in `@pyre/core` (`sell.ts`) and the API (`jupiter.ts`). Swap **execution
|
||||
is not built**.
|
||||
- **Essence ledger** (`@pyre/db`, Postgres: `rounds`, `cleanup_receipts`,
|
||||
`essence_contributions`): `/api/receipt` persists the receipt and records the
|
||||
on-chain fee as Essence; public "🔥 fed the PYRE" panel + `/api/essence`.
|
||||
- **Prometheus engine** (`@pyre/prometheus`): meta-mixer + name/ticker/lore/
|
||||
tagline + image-prompt + safety. Provider-abstracted with real providers
|
||||
(Gemini/Anthropic/OpenAI text; Pollinations/fal/DeepInfra/Replicate image;
|
||||
OpenAI moderation) plus a deterministic stub fallback; runs on a free stack
|
||||
today.
|
||||
- **Pump.fun creator workflow** (manual / approval-gated): `spawn_records`,
|
||||
admin-gated generate/launch endpoints, public `/spawn` page.
|
||||
|
||||
**Token-2022 IS in v0.1 scope** (Rev 2 — most new/pump.fun tokens are Token-2022),
|
||||
supported conservatively with account+mint **extension gating**: confidential
|
||||
transfer / withheld transfer fees / frozen / unknown extensions are skipped;
|
||||
transfer-hook & permanent-delegate mints are cleanable but flagged. See
|
||||
[`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) §7.1. Implemented in
|
||||
`@pyre/core` (`extensions.ts` + `classify.ts`) and `@pyre/solana` (account+mint
|
||||
extension reads); unverifiable mints → UNSUPPORTED.
|
||||
**In progress:**
|
||||
|
||||
v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) →
|
||||
classify → close eligible empty ATAs (optionally burn obvious junk) → return rent
|
||||
to user → show receipt.
|
||||
- Admin generation UI (review/approve Spawn packages).
|
||||
- Generation-from-receipt wiring (`/api/prometheus/generate` already pulls
|
||||
receipt context; full receipt → Spawn flow being finished).
|
||||
|
||||
**Not yet built:**
|
||||
|
||||
- Swap **execution** (TRANSMUTABLE detection only today).
|
||||
- On-chain claim program v1.0 (`programs/pyre-core` is still a placeholder).
|
||||
- Background worker jobs (`apps/worker` is a skeleton — no queues wired up).
|
||||
|
||||
Token-2022 close/burn is in scope; confidential-transfer / fee-harvest flows and
|
||||
swapping hook/permanent-delegate tokens remain out of scope.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,8 +100,8 @@ pyre/
|
||||
# cleanup preview, receipt page, Prometheus preview, admin review
|
||||
api/ # Fastify HTTP API: scan, classify, build tx, receipt,
|
||||
# generation, admin endpoints
|
||||
worker/ # BullMQ worker: async metadata lookup, AI generation,
|
||||
# safety checks, tx-confirmation watcher, receipt enrichment
|
||||
worker/ # BullMQ worker (SKELETON — jobs not yet wired): planned async
|
||||
# metadata lookup, AI generation, safety, tx-confirm, enrichment
|
||||
packages/
|
||||
core/ # shared types & logic: classification enums, risk rules,
|
||||
# DTOs, receipt schema, Prometheus I/O schema
|
||||
@@ -127,12 +146,14 @@ Node 22 is required.
|
||||
pnpm install # install workspace deps
|
||||
pnpm -r build # build all packages/apps
|
||||
pnpm -r typecheck # type-check all
|
||||
pnpm -r test # run all tests
|
||||
pnpm -r test # run all test suites (real tests in core/solana/prometheus)
|
||||
pnpm dev # run apps in parallel (dev)
|
||||
```
|
||||
|
||||
> **Nothing is installed yet.** These commands are not runnable until the
|
||||
> skeleton's `package.json` files and dependencies exist.
|
||||
Production runs under **pm2** (`ecosystem.config.cjs`: `pyre-web`, `pyre-api`,
|
||||
`pyre-worker`). The API and worker are launched with
|
||||
`node --env-file-if-exists=<repo>/.env --import tsx`, so the API loads secrets
|
||||
from the gitignored `.env` at the repo root.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,6 +166,17 @@ pnpm dev # run apps in parallel (dev)
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets live in a **gitignored `.env` at the repo root** (chmod 600), loaded via
|
||||
`node --env-file-if-exists`. **Never commit keys** — there is no private-key env
|
||||
var by design (§trust rules), and `.env.example` documents the shape only. Admin
|
||||
endpoints (`/api/prometheus/generate`, Spawn launch) require an `x-admin-token`
|
||||
header equal to `ADMIN_API_TOKEN`; when that token is unset, admin endpoints are
|
||||
**closed** (403), not open.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
- [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) — canonical design (source of truth)
|
||||
@@ -156,19 +188,11 @@ pnpm dev # run apps in parallel (dev)
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap prompts (§20)
|
||||
## Working in this repo
|
||||
|
||||
Use these in order. **Plan first — no code:**
|
||||
|
||||
> Read CLAUDE.md and docs/PYRE_MVP_DESIGN.md. Do not write code yet. Produce an
|
||||
> implementation plan for PYRE MVP v0.1 focused only on wallet scanning, token
|
||||
> account classification, close-empty-ATA transaction building, transaction
|
||||
> preview, and receipt generation. Identify the exact packages, APIs, database
|
||||
> tables, and test cases needed.
|
||||
|
||||
Then, after plan review — **skeleton only:**
|
||||
|
||||
> Create the monorepo skeleton with pnpm workspaces. Add apps/web, apps/api,
|
||||
> apps/worker, packages/core, packages/solana, packages/prometheus, packages/db,
|
||||
> and docs. Add TypeScript configs, package.json files, README files, and
|
||||
> .env.example files. Do not implement Solana transaction logic yet.
|
||||
The skeleton and burner core already exist (see "Current status" above). When
|
||||
extending, stay inside the design doc's scope and the trust rules. Before
|
||||
touching transaction-building or classification code, read the relevant section
|
||||
of [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) (§6, §7, §8, §16) — that
|
||||
document wins on any conflict. Run `pnpm -r typecheck` and `pnpm -r test` after
|
||||
changes.
|
||||
|
||||
70
README.md
70
README.md
@@ -5,11 +5,15 @@
|
||||
**Links:** [feedthepyre.com](https://feedthepyre.com) · repo: `git.lumiai.dev/RogueWave/pyre` · dev status: [feedthepyre.com](https://feedthepyre.com) (status dashboard)
|
||||
|
||||
PYRE is a **Solana wallet-cleanup and ritual meme-rebirth protocol**. You connect
|
||||
a wallet; PYRE scans your SPL token accounts, classifies them conservatively, and
|
||||
helps you safely close empty associated token accounts (ATAs) and burn obvious
|
||||
junk — **returning recovered rent to you** and producing a clear, shareable
|
||||
receipt. A later layer (Prometheus) uses AI to generate a meme-token "Spawn" from
|
||||
burned remnants for **manual, human-reviewed** launch.
|
||||
a wallet; PYRE scans your SPL token accounts (classic **and** Token-2022),
|
||||
classifies them conservatively, and helps you safely close empty associated token
|
||||
accounts (ATAs) and burn obvious junk — **returning recovered rent to you** (minus
|
||||
a transparent 5% protocol fee) and producing a clear, shareable receipt. The
|
||||
Prometheus engine uses AI to generate a meme-token "Spawn" from burned remnants
|
||||
for **manual, human-reviewed** launch on Pump.fun.
|
||||
|
||||
The burner core is **live at [feedthepyre.com](https://feedthepyre.com)** and the
|
||||
5%-fee close/burn flow has been verified end-to-end on mainnet.
|
||||
|
||||
The first emotional win is simple: *"PYRE cleaned my wallet and returned SOL I
|
||||
forgot was trapped in token accounts."*
|
||||
@@ -26,8 +30,10 @@ promises.
|
||||
|
||||
**Trust guarantees:** PYRE never holds your private keys, never signs
|
||||
custodially, and always shows a decoded transaction preview that matches what you
|
||||
sign. Recovered rent goes back to your wallet by default. Anything the system
|
||||
cannot safely reason about is skipped.
|
||||
sign. Recovered rent goes back to your wallet, minus a single **transparent 5%
|
||||
protocol fee** shown before you sign (it funds the Spawn — see the roadmap).
|
||||
Swap proceeds always go to you. Anything the system cannot safely reason about is
|
||||
skipped.
|
||||
|
||||
## The burner flow at a glance
|
||||
|
||||
@@ -35,25 +41,34 @@ cannot safely reason about is skipped.
|
||||
Connect wallet
|
||||
→ scan token accounts
|
||||
→ classify accounts (closeable / burnable / transmutable / protected / unsupported)
|
||||
→ preview the transaction (accounts, rent, destination, fees, warnings)
|
||||
→ preview the transaction (accounts, rent, destination, 5% fee, warnings)
|
||||
→ you sign locally in your wallet
|
||||
→ recovered rent returns to you
|
||||
→ recovered rent (minus the fee) returns to you; the fee feeds the PYRE
|
||||
→ see your PYRE receipt
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
## Roadmap & status
|
||||
|
||||
- **v0.1 — Burner / Cleaner** *(current focus)*: wallet connect, scan, classify,
|
||||
close empty ATAs, optional junk burn, rent return, receipt.
|
||||
- **v0.2 — Prometheus Meta Mixer**: AI generation of a Spawn identity from
|
||||
burned/cleaned token context (candidate package only — no auto-launch).
|
||||
- **v0.3 — Manual Pump.fun Workflow**: human reviews the Spawn package and
|
||||
manually creates the token; PYRE records mint, URL, metadata, and tx.
|
||||
- **v0.4 — Essence Ledger**: record net SOL value of safe scrap swaps as Essence
|
||||
per wallet/round (database-only, experimental, no claim promises).
|
||||
- **v1.0 — PYRE Core Program**: custom Solana (Anchor) program for trust-critical
|
||||
accounting — rounds, Essence vault, contribution receipts, Spawn distribution,
|
||||
claims, and refunds.
|
||||
- **v0.1 — Burner / Cleaner** — **working.** Wallet connect, scan, conservative
|
||||
classification (classic SPL + Token-2022 with extension gating), close empty
|
||||
ATAs, burn-then-close junk, rent return minus the transparent 5% fee, receipt.
|
||||
Live on mainnet.
|
||||
- **v0.2 — Prometheus Meta Mixer** — **working.** AI generation of a Spawn
|
||||
identity (name/ticker/lore/tagline/image-prompt + safety) from burned/cleaned
|
||||
token context; provider-abstracted with a deterministic stub fallback. Produces
|
||||
a candidate package only — no auto-launch. *In progress:* admin review UI and
|
||||
finishing the receipt → generation wiring.
|
||||
- **v0.3 — Manual Pump.fun Workflow** — **working (manual, approval-gated).**
|
||||
Admin-gated generate/launch endpoints record mint, URL, metadata, and tx; public
|
||||
`/spawn` page. Human creates the token.
|
||||
- **v0.4 — Essence Ledger** — **working (database-only).** The on-chain 5% fee is
|
||||
recorded as Essence per round in Postgres; a public "🔥 fed the PYRE" panel and
|
||||
`/api/essence` show round progress. Experimental, no claim promises.
|
||||
- **Sell / Transmute** — **detection only.** Jupiter quotes + Shield + dust gate
|
||||
classify TRANSMUTABLE tokens; swap **execution is not built yet**.
|
||||
- **v1.0 — PYRE Core Program** — **not built.** Future Solana (Anchor) program for
|
||||
trust-critical accounting: rounds, Essence vault, contribution receipts, Spawn
|
||||
distribution, claims, refunds.
|
||||
|
||||
## Repo structure
|
||||
|
||||
@@ -61,7 +76,7 @@ Connect wallet
|
||||
apps/
|
||||
web/ Next.js user app (wallet connect, scanner, preview, receipt)
|
||||
api/ Fastify HTTP API (scan, classify, build tx, receipt, generation)
|
||||
worker/ BullMQ background worker (metadata, AI, safety, confirmations)
|
||||
worker/ BullMQ background worker (skeleton — jobs not yet wired)
|
||||
packages/
|
||||
core/ shared types, classification enums, risk rules, schemas
|
||||
solana/ token-account parsing, close/burn tx builders, decoder, simulation
|
||||
@@ -75,17 +90,20 @@ docs/ design, architecture, security, classification
|
||||
|
||||
## Quick start
|
||||
|
||||
> **This is a scaffold.** The commands below are not yet runnable — the workspace
|
||||
> package definitions and source are still being built out.
|
||||
|
||||
**Prerequisites:** Node 22, pnpm, PostgreSQL, Redis.
|
||||
|
||||
```bash
|
||||
cp .env.example .env # then fill in values (no private keys — by design)
|
||||
pnpm install
|
||||
pnpm dev # (once apps are implemented)
|
||||
pnpm -r build # build all packages/apps
|
||||
pnpm -r test # run the test suites
|
||||
pnpm dev # run web + api in dev
|
||||
```
|
||||
|
||||
Secrets are read from the gitignored `.env` at the repo root; never commit keys.
|
||||
Admin endpoints require an `ADMIN_API_TOKEN` (see `.env.example`). In production
|
||||
the apps run under pm2 (`ecosystem.config.cjs`).
|
||||
|
||||
## Docs
|
||||
|
||||
See [`docs/`](docs/) — start with
|
||||
|
||||
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