diff --git a/app/db/schema.sql b/app/db/schema.sql index 72f2ef12..00cc689c 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -318,3 +318,8 @@ CREATE TABLE IF NOT EXISTS bb_quote_tokens ( checked_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (chain_id, address) ); + +-- ── token banners (2026-10-05) ───────────────────────────────────────────── +-- An optional wide image the creator uploads beside the logo. Same rules as image_url (https only; uploads are +-- re-encoded server-side, here to a 1500×500 WebP). NULL means no banner: the market cards draw one from the logo. +ALTER TABLE bb_launch_meta ADD COLUMN IF NOT EXISTS banner_url text; diff --git a/app/package-lock.json b/app/package-lock.json index f6ee7dd6..32a04018 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -14,6 +14,7 @@ "@number-flow/react": "^0.6.2", "@tanstack/react-query": "^5.91.3", "clsx": "^2.1.1", + "d3-force": "^3.0.0", "lightweight-charts": "^5.2.1", "lucide-react": "^1.42.0", "motion": "^13.2.0", @@ -30,6 +31,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/d3-force": "^3.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -2808,6 +2810,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4018,6 +4027,47 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/app/package.json b/app/package.json index fc1afe1a..c253c112 100644 --- a/app/package.json +++ b/app/package.json @@ -18,6 +18,7 @@ "@number-flow/react": "^0.6.2", "@tanstack/react-query": "^5.91.3", "clsx": "^2.1.1", + "d3-force": "^3.0.0", "lightweight-charts": "^5.2.1", "lucide-react": "^1.42.0", "motion": "^13.2.0", @@ -34,6 +35,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/d3-force": "^3.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/app/src/app/(home)/loading.tsx b/app/src/app/(home)/loading.tsx index 9d91e2ea..1dc8a34a 100644 --- a/app/src/app/(home)/loading.tsx +++ b/app/src/app/(home)/loading.tsx @@ -3,7 +3,7 @@ import { Sk, SkPost, SkRow } from "@/components/Skeleton"; /** Home skeleton: hero + totals, list header, rows, side column. Same grid as page.tsx so nothing shifts. */ export default function Loading() { return ( -
+
diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx index 2ef26d8e..a1e9ba6c 100644 --- a/app/src/app/(home)/page.tsx +++ b/app/src/app/(home)/page.tsx @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import { BRAND_GITHUB } from "@/lib/brand"; -import Link from "next/link"; +import { cookies } from "next/headers"; import LaunchHero from "@/components/launchpad/LaunchHero"; import LaunchList from "@/components/launchpad/LaunchList"; import LiveRiver from "@/components/launchpad/LiveRiver"; @@ -16,6 +15,10 @@ import { isFilter } from "@/lib/launchpad/search"; import { nowMs } from "@/lib/launchpad/time"; import { inRiverWindow, riverCoverage } from "@/lib/launchpad/river"; import TrendingStrip from "@/components/launchpad/TrendingStrip"; +import WhyFree from "@/components/launchpad/WhyFree"; +import HeroBackdrop from "@/components/launchpad/HeroBackdrop"; +import { MagicCard } from "@/components/vendor/magic-card"; +import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout"; export const dynamic = "force-dynamic"; // Title and description come from the root layout; ?sort= / ?chain= / ?window= / ?view= are all this one page. @@ -31,6 +34,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s const chain = visibleChainOr(sp.chain); const filter = isFilter(sp.filter) ? sp.filter : null; const usd = await ethUsd(); + const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value); const listOpts = { sort, window, chain, filter, limit: PAGE_SIZE, ethUsd: usd }; const [page, feed, fresh, posts, fetchedTrending] = await Promise.all([ listLaunchesPage(listOpts), @@ -43,29 +47,27 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s const now = nowMs(); return ( -
- - inRiverWindow(item, now))} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} /> - -
- - +
+ {/* the hero and the river share one full-bleed band; it clips its own backdrop so nothing widens the page */} +
+ +
+ + inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} /> +
+
+
+ +
+ + +
); diff --git a/app/src/app/about/page.tsx b/app/src/app/about/page.tsx index 73ba2861..89c1bce9 100644 --- a/app/src/app/about/page.tsx +++ b/app/src/app/about/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { pageMetadata } from "@/lib/seo"; import Link from "next/link"; -import { ArrowRight, ArrowUpRight } from "lucide-react"; +import { ArrowRight, ArrowUpRight, Info } from "lucide-react"; import SectionIntro from "@/components/sections/SectionIntro"; import shell from "@/components/sections/SectionShell.module.css"; import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, LEGACY_DOMAIN } from "@/lib/brand"; @@ -34,6 +34,7 @@ export default function AboutPage() {
} title={`What ${BRAND} is`} description={<>{BRAND} is a free, open-source token launchpad. One transaction deploys a fixed-supply ERC-20, opens a Uniswap v4 pool at the market cap you pick and locks 100% of the supply as liquidity, forever. It runs on Base, Robinhood Chain and Arc.} > diff --git a/app/src/app/agents/page.tsx b/app/src/app/agents/page.tsx index 69a23c1d..950fbd15 100644 --- a/app/src/app/agents/page.tsx +++ b/app/src/app/agents/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { pageMetadata } from "@/lib/seo"; -import { ArrowDown, ArrowRight, ArrowUpRight, Braces, FileCode2, Terminal } from "lucide-react"; +import { ArrowDown, ArrowLeftRight, ArrowRight, ArrowUpRight, BookOpen, Bot, Braces, FileCode2, Rocket, Terminal } from "lucide-react"; import SectionIntro from "@/components/sections/SectionIntro"; import AgentsCodeBlock from "@/components/sections/AgentsCodeBlock"; import shell from "@/components/sections/SectionShell.module.css"; @@ -8,13 +8,15 @@ import styles from "@/components/sections/Agents.module.css"; import { launchpad } from "@/lib/launchpad/config"; import { CHAIN_KEYS, CHAIN_LABELS, CHAINS, SITE_URL, explorerAddress } from "@/lib/chainPublic"; import { BRAND_GITHUB } from "@/lib/brand"; +import { ChainLogo } from "@/components/launchpad/ChainLogo"; export const metadata: Metadata = pageMetadata({ path: "/agents", title: "Agents", description: "Launch and trade tokens from an agent: one contract call, plus a JSON API for the list, trades and metadata." }); +/** The page's sections, each with the mark its heading carries. */ const sections = [ - { id: "launch", number: "01", label: "Launch a token" }, - { id: "read", number: "02", label: "Read the API" }, - { id: "trade", number: "03", label: "Trade & collect" }, + { id: "launch", icon: Rocket, label: "Launch a token" }, + { id: "read", icon: BookOpen, label: "Read the API" }, + { id: "trade", icon: ArrowLeftRight, label: "Trade & collect" }, ] as const; const endpoints = [ @@ -34,13 +36,13 @@ export default function AgentsPage() { return (
- No API key, no signup, no platform fee. A launch is one contract call from any wallet with a little ETH for gas. Everything the site shows is also JSON.}> + } title="Agents" description={<>No API key, no signup, no platform fee. A launch is one contract call from any wallet with a little ETH for gas. Everything the site shows is also JSON.}> Explore the source
-
+

The interface is optional.The same protocol is yours to build with.

Start with a launch
@@ -48,19 +50,19 @@ export default function AgentsPage() {
-
01

Launch a token

One call, from your own wallet.

+

Launch a token

One call, from your own wallet.

{CHAIN_KEYS.map((chain) => { const config = launchpad(chain); return
-

{CHAIN_LABELS[chain]}

Chain {CHAINS[chain].id}
+

{CHAIN_LABELS[chain]}

Chain {CHAINS[chain].id}
{(["factory", "locker"] as const).map((contract) =>
{contract}
{config[contract] ? {config[contract]} : not deployed yet}
@@ -90,7 +92,7 @@ export default function AgentsPage() {
-
02

Read the API

JSON endpoints, plus a plain-text agent reference.

+

Read the API

JSON endpoints, plus a plain-text agent reference.

Public endpointsNo API key
{endpoints.map((endpoint) =>
@@ -106,7 +108,7 @@ export default function AgentsPage() {
-
03

Trade & collect

Standard pools. Direct contract calls.

+

Trade & collect

Standard pools. Direct contract calls.

Swap

Speak Uniswap v4

Pools are plain Uniswap v4, with tick spacing 200 and no hook. The quote can be ETH, TWIG (Base), GITLAWB (Base and Robinhood Chain), USDG (Robinhood Chain), USDC (Arc) or a supported issuer-registry stock token. Get the actual pool key with poolKeyOf(token) on the factory. Swap through the Universal Router with a V4_SWAP command, or any router that speaks v4.

Collect

Pay out accrued fees

Anyone may call collect(tokenId) on that chain's locker to pay out accrued fees. Base locker: {locker}. Use the matching chain address above.

diff --git a/app/src/app/api/launch/chart-provider/route.test.ts b/app/src/app/api/launch/chart-provider/route.test.ts new file mode 100644 index 00000000..858672af --- /dev/null +++ b/app/src/app/api/launch/chart-provider/route.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { GET } from "./route"; +const identity={chain:"base",token:"0x"+"a".repeat(40),quote:"0x"+"0".repeat(40),pool:"0x"+"b".repeat(64)}; +function request(overrides: Partial = {}, ip="chart-test") { + return new Request("http://localhost/api/launch/chart-provider?"+new URLSearchParams({...identity,...overrides}),{headers:{"fly-client-ip":ip}}); +} +test("route validates without network, serves classified data, and rate limits clients",async(t)=>{ + let now=Date.now();t.mock.method(Date,"now",()=>now); + const response={data:{type:"pool",id:"base_"+identity.pool,attributes:{address:identity.pool,base_token_price_usd:"1"}, + relationships:{base_token:{data:{type:"token",id:"base_"+identity.token}},quote_token:{data:{type:"token",id:"base_"+identity.quote}}}}}; + const upstream=t.mock.method(globalThis,"fetch",async()=>Response.json(response)); + for(const bad of [{chain:"ethereum"},{pool:"https://evil.test"},{token:identity.quote}]) assert.equal((await GET(request(bad))).status,400); + assert.equal(upstream.mock.callCount(),0); + const result=await GET(request());assert.equal(result.status,200);assert.deepEqual(await result.json(),{status:"ready"}); + assert.equal(result.headers.get("cache-control"),"no-store"); + await GET(request());assert.equal(upstream.mock.callCount(),1); + const mismatch=await GET(request({quote:"0x"+"c".repeat(40)}));assert.equal(mismatch.status,503); + assert.equal(mismatch.headers.get("retry-after"),"60"); + now+=60_001; + for(let i=0;i<60;i++) assert.equal((await GET(request({},"limited"))).status,200); + assert.equal((await GET(request({},"limited"))).status,429); + now+=60_001;assert.equal((await GET(request({},"limited"))).status,200); +}); + +test("Arc provider route preserves exact identity and recoverable listing, pricing and upstream failures", async (t) => { + let now = Date.now() + 300_000; + t.mock.method(Date, "now", () => now); + const arc = { ...identity, chain: "arc", quote: "0x3600000000000000000000000000000000000000" }; + const payload = (pool: string, price: unknown = "0.00001") => ({ data: { + type: "pool", id: `arc_${pool}`, attributes: { address: pool, base_token_price_usd: price }, + relationships: { + base_token: { data: { type: "token", id: `arc_${arc.token}` } }, + quote_token: { data: { type: "token", id: `arc_${arc.quote}` } }, + }, + } }); + const scenarios: { name: string; response: (pool: string) => Response; status: number; lookup?: string }[] = [ + { name: "listed", response: (pool) => Response.json(payload(pool)), status: 200, lookup: "ready" }, + { name: "missing", response: () => new Response(null, { status: 404 }), status: 200, lookup: "unlisted" }, + { name: "unpriced", response: (pool) => Response.json(payload(pool, null)), status: 200, lookup: "unpriced" }, + { name: "reversed", response: (pool) => { + const data = payload(pool); + [data.data.relationships.base_token, data.data.relationships.quote_token] = [data.data.relationships.quote_token, data.data.relationships.base_token]; + return Response.json(data); + }, status: 200, lookup: "inverted" }, + { name: "upstream failure", response: () => new Response(null, { status: 500 }), status: 503 }, + { name: "malformed", response: () => Response.json({ data: [] }), status: 503 }, + { name: "timeout", response: () => { throw new DOMException("Timed out", "TimeoutError"); }, status: 503 }, + { name: "wrong quote", response: (pool) => { + const data = payload(pool); + data.data.relationships.quote_token.data.id = `arc_${identity.quote}`; + return Response.json(data); + }, status: 503 }, + ]; + let upstreamResponse: (pool: string) => Response; + const upstream = t.mock.method(globalThis, "fetch", async (url: unknown) => { + const target = new URL(String(url)); + assert.equal(target.origin, "https://api.geckoterminal.com"); + assert.ok(target.pathname.startsWith("/api/v2/networks/arc/pools/")); + return upstreamResponse(target.pathname.split("/").at(-1)!); + }); + for (const [index, scenario] of scenarios.entries()) { + now += 60_001; + upstreamResponse = scenario.response; + const current = { ...arc, pool: "0x" + (index + 100).toString(16).padStart(64, "0") }; + const result = await GET(request(current, "arc-chart-test")); + assert.equal(result.status, scenario.status, scenario.name); + assert.equal(result.headers.get("cache-control"), "no-store"); + if (scenario.lookup) assert.deepEqual(await result.json(), { status: scenario.lookup }); + else { + assert.match((await result.json()).error, /On-chain history is still available/); + assert.equal(result.headers.get("retry-after"), "60"); + const calls = upstream.mock.callCount(); + upstreamResponse = (pool) => Response.json(payload(pool)); + assert.deepEqual(await (await GET(request(current, "arc-chart-test"))).json(), { status: "ready" }); + assert.equal(upstream.mock.callCount(), calls + 1, "errors must not be cached as absent pools"); + } + } +}); diff --git a/app/src/app/api/launch/chart-provider/route.ts b/app/src/app/api/launch/chart-provider/route.ts new file mode 100644 index 00000000..9265b69c --- /dev/null +++ b/app/src/app/api/launch/chart-provider/route.ts @@ -0,0 +1,21 @@ +import { isChartPool } from "@/lib/launchpad/chart-pool"; +import { lookupGeckoPool } from "@/lib/launchpad/geckoterminal"; +import { createGeckoCache } from "@/lib/launchpad/gecko-cache"; +import { rateLimited } from "@/lib/launchpad/editServer"; + +export const dynamic = "force-dynamic"; +const lookup = createGeckoCache(lookupGeckoPool, () => Date.now()); + +export async function GET(request: Request) { + const headers = { "cache-control": "no-store" }; + const ip = (request.headers.get("fly-client-ip") || request.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; + if (rateLimited(`chart:ip:${ip}`, 60)) return Response.json({ error: "Too many chart requests. Try again shortly." }, { status: 429, headers: { ...headers, "retry-after": "60" } }); + const query = new URL(request.url).searchParams; + const pool = { chain: query.get("chain"), token: query.get("token"), poolId: query.get("pool"), quote: query.get("quote") }; + if (!isChartPool(pool)) return Response.json({ error: "Invalid pool." }, { status: 400, headers }); + try { + return Response.json({ status: await lookup(pool) }, { headers }); + } catch { + return Response.json({ error: "GeckoTerminal could not be checked. On-chain history is still available." }, { status: 503, headers: { ...headers, "retry-after": "60" } }); + } +} diff --git a/app/src/app/api/launch/image/route.ts b/app/src/app/api/launch/image/route.ts index 4c3048a0..456b3af1 100644 --- a/app/src/app/api/launch/image/route.ts +++ b/app/src/app/api/launch/image/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; -import { IMAGE_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, isWalletParam, randomImageKey } from "@/lib/launchpad/images"; -import { toLogoWebp } from "@/lib/launchpad/imageProcess"; +import { BANNER_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, imageRole, isWalletParam, maxBytesFor, randomImageKey } from "@/lib/launchpad/images"; +import { toBannerWebp, toLogoWebp } from "@/lib/launchpad/imageProcess"; import { imageUploadsEnabled, putImage } from "@/lib/launchpad/imageStore"; export const dynamic = "force-dynamic"; @@ -10,9 +10,10 @@ export const runtime = "nodejs"; const HOUR = 60 * 60_000; /** - * POST multipart {wallet, file} → {url}. Token logo upload for the launch form / edit sheet. - * Order: wallet + rate limits → size (Content-Length, then real bytes) → magic bytes → sharp re-encode - * to a 512² WebP (metadata stripped; the decoder is the validator) → random key → public bucket. + * POST multipart {wallet, file, kind?} → {url}. Token logo (or, with kind=banner, banner) upload for the launch + * form / edit sheet. Order: wallet + rate limits → size (Content-Length, then real bytes) → magic bytes → sharp + * re-encode to a 512² logo or a 1500×500 banner WebP (metadata stripped; the decoder is the validator) → random key + * → public bucket. Logos and banners share the upload limits. * The URL then flows through the normal metadata path (https-only rule, signed edits) unchanged. */ export async function POST(req: Request) { @@ -20,7 +21,8 @@ export async function POST(req: Request) { const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0"; if (rateLimited(`img:ip:${ip}`, IMAGE_UPLOADS_PER_HOUR * 2, HOUR)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const declared = Number(req.headers.get("content-length") || 0); - if (declared > IMAGE_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 2 MB" }, { status: 413 }); + // the role is inside the body, so the early check allows the larger (banner) cap; the real cap is applied below + if (declared > BANNER_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 4 MB" }, { status: 413 }); let form: FormData; try { @@ -30,18 +32,20 @@ export async function POST(req: Request) { } const wallet = form.get("wallet"); const file = form.get("file"); + const role = imageRole(form.get("kind")); + const max = maxBytesFor(role); if (!isWalletParam(wallet)) return NextResponse.json({ error: "connect a wallet first" }, { status: 400 }); if (!(file instanceof Blob)) return NextResponse.json({ error: "file required" }, { status: 400 }); if (rateLimited(`img:wallet:${wallet.toLowerCase()}`, IMAGE_UPLOADS_PER_HOUR, HOUR)) return NextResponse.json({ error: "upload limit reached, try later" }, { status: 429 }); - if (file.size > IMAGE_MAX_BYTES) return NextResponse.json({ error: "image must be ≤ 2 MB" }, { status: 413 }); + if (file.size > max) return NextResponse.json({ error: `image must be ≤ ${max / 1024 / 1024} MB` }, { status: 413 }); const bytes = new Uint8Array(await file.arrayBuffer()); - const pre = checkUpload(bytes); + const pre = checkUpload(bytes, max); if (!pre.ok) return NextResponse.json({ error: pre.error }, { status: pre.status }); let out: Buffer; try { - out = await toLogoWebp(bytes); + out = role === "banner" ? await toBannerWebp(bytes) : await toLogoWebp(bytes); } catch (e) { const msg = e instanceof Error && /too small/.test(e.message) ? e.message : "not a valid image"; return NextResponse.json({ error: msg }, { status: 415 }); diff --git a/app/src/app/feed/loading.tsx b/app/src/app/feed/loading.tsx index 80da5400..633acfa6 100644 --- a/app/src/app/feed/loading.tsx +++ b/app/src/app/feed/loading.tsx @@ -10,7 +10,7 @@ export default function Loading() { return (
-
+
@@ -19,13 +19,13 @@ export default function Loading() {
- +
-
+

    {Array.from({ length: 6 }, (_, i) => ( @@ -36,22 +36,21 @@ export default function Loading() {
{ if (values[0]) setChain(values[0] === "all" ? null : values[0] as ChainKey); }} aria-label="Filter posts by chain"> All chains - {VISIBLE_CHAINS.map((k) => {CHAIN_SHORT[k]})} + {/* the market board's chain filter: each chain's mark, its name from sm up */} + {VISIBLE_CHAINS.map((k) => {CHAIN_SHORT[k]})}