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**:
|
This repo is **no longer scaffold + docs** — the burner core is implemented and
|
||||||
**Solana transaction logic and business logic are NOT yet implemented.** Do not
|
live at [feedthepyre.com](https://feedthepyre.com). Keep new work aligned with the
|
||||||
add application/business logic unless explicitly asked.
|
design doc; the trust rules above are non-negotiable.
|
||||||
|
|
||||||
**Explicitly OUT of scope for v0.1** (per §5):
|
**Built (working):**
|
||||||
|
|
||||||
- Automatic Pump.fun launch
|
- **Wallet scan + conservative classifier** (`@pyre/core`): EMPTY_CLOSE_ONLY /
|
||||||
- User-contributed Essence vault
|
INCINERATE_ONLY / TRANSMUTABLE / PROTECTED_SKIP / UNSUPPORTED. "Unknown means
|
||||||
- Custom PYRE Solana program (Anchor)
|
skip"; never "safe".
|
||||||
- NFT handling (incl. compressed NFTs)
|
- **Token-2022** supported conservatively with account+mint **extension gating**
|
||||||
- Automatic valuable-token sacrifice
|
(§7.1): confidential transfer / withheld transfer fees / frozen / unknown
|
||||||
- Custodial signing
|
extensions skipped; transfer-hook & permanent-delegate mints cleanable but
|
||||||
- Background wallet automation
|
flagged; unverifiable mints → UNSUPPORTED. In `@pyre/core` (`extensions.ts`,
|
||||||
- On-chain swap routing (TRANSMUTABLE) and Token-2022 confidential-transfer /
|
`classify.ts`) + `@pyre/solana`.
|
||||||
fee-harvest flows
|
- **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),
|
**In progress:**
|
||||||
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.
|
|
||||||
|
|
||||||
v0.1 ships: wallet connect → scan token accounts (classic SPL + Token-2022) →
|
- Admin generation UI (review/approve Spawn packages).
|
||||||
classify → close eligible empty ATAs (optionally burn obvious junk) → return rent
|
- Generation-from-receipt wiring (`/api/prometheus/generate` already pulls
|
||||||
to user → show receipt.
|
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
|
# cleanup preview, receipt page, Prometheus preview, admin review
|
||||||
api/ # Fastify HTTP API: scan, classify, build tx, receipt,
|
api/ # Fastify HTTP API: scan, classify, build tx, receipt,
|
||||||
# generation, admin endpoints
|
# generation, admin endpoints
|
||||||
worker/ # BullMQ worker: async metadata lookup, AI generation,
|
worker/ # BullMQ worker (SKELETON — jobs not yet wired): planned async
|
||||||
# safety checks, tx-confirmation watcher, receipt enrichment
|
# metadata lookup, AI generation, safety, tx-confirm, enrichment
|
||||||
packages/
|
packages/
|
||||||
core/ # shared types & logic: classification enums, risk rules,
|
core/ # shared types & logic: classification enums, risk rules,
|
||||||
# DTOs, receipt schema, Prometheus I/O schema
|
# DTOs, receipt schema, Prometheus I/O schema
|
||||||
@@ -127,12 +146,14 @@ Node 22 is required.
|
|||||||
pnpm install # install workspace deps
|
pnpm install # install workspace deps
|
||||||
pnpm -r build # build all packages/apps
|
pnpm -r build # build all packages/apps
|
||||||
pnpm -r typecheck # type-check all
|
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)
|
pnpm dev # run apps in parallel (dev)
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Nothing is installed yet.** These commands are not runnable until the
|
Production runs under **pm2** (`ecosystem.config.cjs`: `pyre-web`, `pyre-api`,
|
||||||
> skeleton's `package.json` files and dependencies exist.
|
`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
|
||||||
|
|
||||||
- [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) — canonical design (source of truth)
|
- [`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:**
|
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
|
||||||
> Read CLAUDE.md and docs/PYRE_MVP_DESIGN.md. Do not write code yet. Produce an
|
touching transaction-building or classification code, read the relevant section
|
||||||
> implementation plan for PYRE MVP v0.1 focused only on wallet scanning, token
|
of [`docs/PYRE_MVP_DESIGN.md`](docs/PYRE_MVP_DESIGN.md) (§6, §7, §8, §16) — that
|
||||||
> account classification, close-empty-ATA transaction building, transaction
|
document wins on any conflict. Run `pnpm -r typecheck` and `pnpm -r test` after
|
||||||
> preview, and receipt generation. Identify the exact packages, APIs, database
|
changes.
|
||||||
> 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.
|
|
||||||
|
|||||||
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)
|
**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
|
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
|
a wallet; PYRE scans your SPL token accounts (classic **and** Token-2022),
|
||||||
helps you safely close empty associated token accounts (ATAs) and burn obvious
|
classifies them conservatively, and helps you safely close empty associated token
|
||||||
junk — **returning recovered rent to you** and producing a clear, shareable
|
accounts (ATAs) and burn obvious junk — **returning recovered rent to you** (minus
|
||||||
receipt. A later layer (Prometheus) uses AI to generate a meme-token "Spawn" from
|
a transparent 5% protocol fee) and producing a clear, shareable receipt. The
|
||||||
burned remnants for **manual, human-reviewed** launch.
|
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
|
The first emotional win is simple: *"PYRE cleaned my wallet and returned SOL I
|
||||||
forgot was trapped in token accounts."*
|
forgot was trapped in token accounts."*
|
||||||
@@ -26,8 +30,10 @@ promises.
|
|||||||
|
|
||||||
**Trust guarantees:** PYRE never holds your private keys, never signs
|
**Trust guarantees:** PYRE never holds your private keys, never signs
|
||||||
custodially, and always shows a decoded transaction preview that matches what you
|
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
|
sign. Recovered rent goes back to your wallet, minus a single **transparent 5%
|
||||||
cannot safely reason about is skipped.
|
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
|
## The burner flow at a glance
|
||||||
|
|
||||||
@@ -35,25 +41,34 @@ cannot safely reason about is skipped.
|
|||||||
Connect wallet
|
Connect wallet
|
||||||
→ scan token accounts
|
→ scan token accounts
|
||||||
→ classify accounts (closeable / burnable / transmutable / protected / unsupported)
|
→ 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
|
→ 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
|
→ see your PYRE receipt
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap & status
|
||||||
|
|
||||||
- **v0.1 — Burner / Cleaner** *(current focus)*: wallet connect, scan, classify,
|
- **v0.1 — Burner / Cleaner** — **working.** Wallet connect, scan, conservative
|
||||||
close empty ATAs, optional junk burn, rent return, receipt.
|
classification (classic SPL + Token-2022 with extension gating), close empty
|
||||||
- **v0.2 — Prometheus Meta Mixer**: AI generation of a Spawn identity from
|
ATAs, burn-then-close junk, rent return minus the transparent 5% fee, receipt.
|
||||||
burned/cleaned token context (candidate package only — no auto-launch).
|
Live on mainnet.
|
||||||
- **v0.3 — Manual Pump.fun Workflow**: human reviews the Spawn package and
|
- **v0.2 — Prometheus Meta Mixer** — **working.** AI generation of a Spawn
|
||||||
manually creates the token; PYRE records mint, URL, metadata, and tx.
|
identity (name/ticker/lore/tagline/image-prompt + safety) from burned/cleaned
|
||||||
- **v0.4 — Essence Ledger**: record net SOL value of safe scrap swaps as Essence
|
token context; provider-abstracted with a deterministic stub fallback. Produces
|
||||||
per wallet/round (database-only, experimental, no claim promises).
|
a candidate package only — no auto-launch. *In progress:* admin review UI and
|
||||||
- **v1.0 — PYRE Core Program**: custom Solana (Anchor) program for trust-critical
|
finishing the receipt → generation wiring.
|
||||||
accounting — rounds, Essence vault, contribution receipts, Spawn distribution,
|
- **v0.3 — Manual Pump.fun Workflow** — **working (manual, approval-gated).**
|
||||||
claims, and refunds.
|
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
|
## Repo structure
|
||||||
|
|
||||||
@@ -61,7 +76,7 @@ Connect wallet
|
|||||||
apps/
|
apps/
|
||||||
web/ Next.js user app (wallet connect, scanner, preview, receipt)
|
web/ Next.js user app (wallet connect, scanner, preview, receipt)
|
||||||
api/ Fastify HTTP API (scan, classify, build tx, receipt, generation)
|
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/
|
packages/
|
||||||
core/ shared types, classification enums, risk rules, schemas
|
core/ shared types, classification enums, risk rules, schemas
|
||||||
solana/ token-account parsing, close/burn tx builders, decoder, simulation
|
solana/ token-account parsing, close/burn tx builders, decoder, simulation
|
||||||
@@ -75,17 +90,20 @@ docs/ design, architecture, security, classification
|
|||||||
|
|
||||||
## Quick start
|
## 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.
|
**Prerequisites:** Node 22, pnpm, PostgreSQL, Redis.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # then fill in values (no private keys — by design)
|
cp .env.example .env # then fill in values (no private keys — by design)
|
||||||
pnpm install
|
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
|
## Docs
|
||||||
|
|
||||||
See [`docs/`](docs/) — start with
|
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 {
|
.spawn-card__link:hover {
|
||||||
text-decoration: underline;
|
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>
|
||||||
<a href="#scanner">Scanner</a>
|
<a href="#scanner">Scanner</a>
|
||||||
<a href="/spawn">The Spawn</a>
|
<a href="/spawn">The Spawn</a>
|
||||||
|
<a href="/admin" rel="nofollow">Operator</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<p className="footer__disclaimer">
|
<p className="footer__disclaimer">
|
||||||
|
|||||||
Reference in New Issue
Block a user