Skip to content

Commit 51e694d

Browse files
Vasanthdev2004kevincodex1
authored andcommitted
feat(launch): optional creator banner, stored and edited like the logo
One nullable column (bb_launch_meta.banner_url), the upload route picks its processor and byte cap from the role, and the edit sheet offers it. Signed like every other edit.
1 parent 544f1df commit 51e694d

15 files changed

Lines changed: 259 additions & 98 deletions

File tree

‎app/db/schema.sql‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,8 @@ CREATE TABLE IF NOT EXISTS bb_quote_tokens (
318318
checked_at timestamptz NOT NULL DEFAULT now(),
319319
PRIMARY KEY (chain_id, address)
320320
);
321+
322+
-- ── token banners (2026-10-05) ─────────────────────────────────────────────
323+
-- An optional wide image the creator uploads beside the logo. Same rules as image_url (https only; uploads are
324+
-- re-encoded server-side, here to a 1500×500 WebP). NULL means no banner: the market cards draw one from the logo.
325+
ALTER TABLE bb_launch_meta ADD COLUMN IF NOT EXISTS banner_url text;

‎app/src/app/api/launch/image/route.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextResponse } from "next/server";
22
import { rateLimited } from "@/lib/launchpad/editServer";
3-
import { IMAGE_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, isWalletParam, randomImageKey } from "@/lib/launchpad/images";
4-
import { toLogoWebp } from "@/lib/launchpad/imageProcess";
3+
import { BANNER_MAX_BYTES, IMAGE_UPLOADS_PER_HOUR, checkUpload, imageRole, isWalletParam, maxBytesFor, randomImageKey } from "@/lib/launchpad/images";
4+
import { toBannerWebp, toLogoWebp } from "@/lib/launchpad/imageProcess";
55
import { imageUploadsEnabled, putImage } from "@/lib/launchpad/imageStore";
66

77
export const dynamic = "force-dynamic";
@@ -10,17 +10,19 @@ export const runtime = "nodejs";
1010
const HOUR = 60 * 60_000;
1111

1212
/**
13-
* POST multipart {wallet, file} → {url}. Token logo upload for the launch form / edit sheet.
14-
* Order: wallet + rate limits → size (Content-Length, then real bytes) → magic bytes → sharp re-encode
15-
* to a 512² WebP (metadata stripped; the decoder is the validator) → random key → public bucket.
13+
* POST multipart {wallet, file, kind?} → {url}. Token logo (or, with kind=banner, banner) upload for the launch
14+
* form / edit sheet. Order: wallet + rate limits → size (Content-Length, then real bytes) → magic bytes → sharp
15+
* re-encode to a 512² logo or a 1500×500 banner WebP (metadata stripped; the decoder is the validator) → random key
16+
* → public bucket. Logos and banners share the upload limits.
1617
* The URL then flows through the normal metadata path (https-only rule, signed edits) unchanged.
1718
*/
1819
export async function POST(req: Request) {
1920
if (!imageUploadsEnabled()) return NextResponse.json({ error: "uploads not configured" }, { status: 503 });
2021
const ip = (req.headers.get("fly-client-ip") || req.headers.get("x-forwarded-for") || "").split(",")[0].trim() || "0.0.0.0";
2122
if (rateLimited(`img:ip:${ip}`, IMAGE_UPLOADS_PER_HOUR * 2, HOUR)) return NextResponse.json({ error: "slow down" }, { status: 429 });
2223
const declared = Number(req.headers.get("content-length") || 0);
23-
if (declared > IMAGE_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 2 MB" }, { status: 413 });
24+
// the role is inside the body, so the early check allows the larger (banner) cap; the real cap is applied below
25+
if (declared > BANNER_MAX_BYTES + 16 * 1024) return NextResponse.json({ error: "image must be ≤ 4 MB" }, { status: 413 });
2426

2527
let form: FormData;
2628
try {
@@ -30,18 +32,20 @@ export async function POST(req: Request) {
3032
}
3133
const wallet = form.get("wallet");
3234
const file = form.get("file");
35+
const role = imageRole(form.get("kind"));
36+
const max = maxBytesFor(role);
3337
if (!isWalletParam(wallet)) return NextResponse.json({ error: "connect a wallet first" }, { status: 400 });
3438
if (!(file instanceof Blob)) return NextResponse.json({ error: "file required" }, { status: 400 });
3539
if (rateLimited(`img:wallet:${wallet.toLowerCase()}`, IMAGE_UPLOADS_PER_HOUR, HOUR)) return NextResponse.json({ error: "upload limit reached, try later" }, { status: 429 });
36-
if (file.size > IMAGE_MAX_BYTES) return NextResponse.json({ error: "image must be ≤ 2 MB" }, { status: 413 });
40+
if (file.size > max) return NextResponse.json({ error: `image must be ≤ ${max / 1024 / 1024} MB` }, { status: 413 });
3741

3842
const bytes = new Uint8Array(await file.arrayBuffer());
39-
const pre = checkUpload(bytes);
43+
const pre = checkUpload(bytes, max);
4044
if (!pre.ok) return NextResponse.json({ error: pre.error }, { status: pre.status });
4145

4246
let out: Buffer;
4347
try {
44-
out = await toLogoWebp(bytes);
48+
out = role === "banner" ? await toBannerWebp(bytes) : await toLogoWebp(bytes);
4549
} catch (e) {
4650
const msg = e instanceof Error && /too small/.test(e.message) ? e.message : "not a valid image";
4751
return NextResponse.json({ error: msg }, { status: 415 });

‎app/src/components/launchpad/EditTokenSheet.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { friendlyError } from "@/lib/errors";
1717
export default function EditTokenSheet({ chain, token, symbol, initial, onClose, onSaved }: { chain: ChainKey; token: string; symbol: string; initial: EditFields; onClose: () => void; onSaved: (f: EditFields) => void }) {
1818
const config = useConfig();
1919
const { address } = useAccount();
20-
const [f, setF] = useState<EditFields>({ description: initial.description ?? "", image_url: initial.image_url ?? "", website: initial.website ?? "", x_handle: initial.x_handle ?? "" });
20+
const [f, setF] = useState<EditFields>({ description: initial.description ?? "", image_url: initial.image_url ?? "", banner_url: initial.banner_url ?? "", website: initial.website ?? "", x_handle: initial.x_handle ?? "" });
2121
const [phase, setPhase] = useState<"idle" | "nonce" | "sign" | "save" | "done">("idle");
2222
const [err, setErr] = useState<string | null>(null);
2323
const v = validateEdit(f);
@@ -61,6 +61,10 @@ export default function EditTokenSheet({ chain, token, symbol, initial, onClose,
6161
<p className={label}>Image</p>
6262
<ImageUpload compact value={f.image_url ?? ""} onChange={(url) => setF({ ...f, image_url: url })} wallet={address} />
6363
</div>
64+
<div>
65+
<p className={label}>Banner</p>
66+
<ImageUpload kind="banner" value={f.banner_url ?? ""} onChange={(url) => setF({ ...f, banner_url: url })} wallet={address} />
67+
</div>
6468
<div className="grid grid-cols-2 gap-3">
6569
<div>
6670
<label className={label} htmlFor="e-web">Website</label>

‎app/src/components/launchpad/ImageUpload.tsx‎

Lines changed: 98 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,41 @@
11
"use client";
22

33
import { useId, useRef, useState } from "react";
4+
import { ImagePlus, X } from "lucide-react";
45
import { helper, input as inputCls } from "@/components/ui";
5-
import { IMAGE_MAX_BYTES, sniffImage } from "@/lib/launchpad/images";
6+
import { BANNER_HEIGHT, BANNER_WIDTH, maxBytesFor, sniffImage, type ImageRole } from "@/lib/launchpad/images";
67
import { Spinner } from "@/components/Skeleton";
78

89
/**
9-
* Token logo picker: drop zone / tap-to-browse → POST /api/launch/image → https URL into `value`.
10-
* The URL field stays available for people who already host a logo. Wallet must be connected
11-
* (uploads are rate-limited per wallet server-side).
10+
* Token image picker: drop zone / tap-to-browse → POST /api/launch/image → https URL into `value`.
11+
* The URL field stays available for people who already host the image. Wallet must be connected
12+
* (uploads are rate-limited per wallet server-side). `kind="banner"` picks the wide banner instead of
13+
* the square logo: a 3:1 drop zone that previews the banner across its full width. `stacked` (from sm up)
14+
* fills the height it is given, so a logo and a banner side by side end level: the logo's tile sits above
15+
* its words, and the banner zone drops its fixed 3:1 for the row's height.
1216
*/
13-
export default function ImageUpload({ value, onChange, wallet, compact = false }: { value: string; onChange: (url: string) => void; wallet: string | undefined; compact?: boolean }) {
17+
export default function ImageUpload({ value, onChange, wallet, compact = false, kind = "logo", stacked = false }: { value: string; onChange: (url: string) => void; wallet: string | undefined; compact?: boolean; kind?: ImageRole; stacked?: boolean }) {
1418
const id = useId();
1519
const fileRef = useRef<HTMLInputElement>(null);
1620
const [busy, setBusy] = useState(false);
1721
const [err, setErr] = useState<string | null>(null);
1822
const [over, setOver] = useState(false);
1923
const preview = /^https?:\/\//.test(value.trim()) ? value.trim() : null;
24+
const banner = kind === "banner";
25+
const max = maxBytesFor(kind);
2026

2127
async function upload(file: File) {
2228
setErr(null);
2329
if (!wallet) return setErr("Connect a wallet to upload.");
24-
if (file.size > IMAGE_MAX_BYTES) return setErr("Max 2 MB.");
30+
if (file.size > max) return setErr(`Max ${max / 1024 / 1024} MB.`);
2531
const head = new Uint8Array(await file.slice(0, 16).arrayBuffer());
2632
if (!sniffImage(head)) return setErr("PNG, JPEG, WebP or GIF only.");
2733
setBusy(true);
2834
try {
2935
const fd = new FormData();
3036
fd.set("wallet", wallet);
31-
fd.set("file", file, "logo");
37+
fd.set("kind", kind);
38+
fd.set("file", file, kind);
3239
const r = await fetch("/api/launch/image", { method: "POST", body: fd });
3340
const j = (await r.json().catch(() => ({}))) as { url?: string; error?: string };
3441
if (!r.ok || !j.url) throw new Error(j.error || `upload failed (${r.status})`);
@@ -40,68 +47,96 @@ export default function ImageUpload({ value, onChange, wallet, compact = false }
4047
}
4148
}
4249

50+
// the drop zone behaves the same for both kinds; only what it draws differs
51+
const zone = {
52+
role: "button" as const,
53+
tabIndex: 0,
54+
"aria-label": banner ? "Upload token banner" : "Upload token image",
55+
onClick: () => !busy && fileRef.current?.click(),
56+
onKeyDown: (e: React.KeyboardEvent) => {
57+
if (e.key === "Enter" || e.key === " ") {
58+
e.preventDefault();
59+
fileRef.current?.click();
60+
}
61+
},
62+
onDragOver: (e: React.DragEvent) => {
63+
e.preventDefault();
64+
setOver(true);
65+
},
66+
onDragLeave: () => setOver(false),
67+
onDrop: (e: React.DragEvent) => {
68+
e.preventDefault();
69+
setOver(false);
70+
const f = e.dataTransfer.files?.[0];
71+
if (f) void upload(f);
72+
},
73+
};
74+
const edge = `${over ? "border-brand bg-brand-soft" : "border-line hover:border-ink/40 bg-paper"} ${busy ? "opacity-70 cursor-progress" : ""}`;
75+
const fileInput = (
76+
<input
77+
ref={fileRef}
78+
id={id}
79+
type="file"
80+
accept="image/png,image/jpeg,image/webp,image/gif"
81+
className="sr-only"
82+
onChange={(e) => {
83+
const f = e.target.files?.[0];
84+
e.target.value = "";
85+
if (f) void upload(f);
86+
}}
87+
/>
88+
);
89+
4390
const tile = compact ? 64 : 88;
4491
return (
45-
<div>
46-
<div
47-
role="button"
48-
tabIndex={0}
49-
aria-label="Upload token image"
50-
onClick={() => !busy && fileRef.current?.click()}
51-
onKeyDown={(e) => {
52-
if (e.key === "Enter" || e.key === " ") {
53-
e.preventDefault();
54-
fileRef.current?.click();
55-
}
56-
}}
57-
onDragOver={(e) => {
58-
e.preventDefault();
59-
setOver(true);
60-
}}
61-
onDragLeave={() => setOver(false)}
62-
onDrop={(e) => {
63-
e.preventDefault();
64-
setOver(false);
65-
const f = e.dataTransfer.files?.[0];
66-
if (f) void upload(f);
67-
}}
68-
className={`flex items-center gap-4 rounded-2xl border border-dashed px-4 py-3 cursor-pointer transition-colors select-none ${over ? "border-brand bg-brand-soft" : "border-line hover:border-ink/40 bg-paper"} ${busy ? "opacity-70 cursor-progress" : ""}`}
69-
>
70-
<div className="shrink-0 rounded-2xl overflow-hidden bg-card border border-line flex items-center justify-center" style={{ width: tile, height: tile }}>
71-
{preview ? (
72-
// eslint-disable-next-line @next/next/no-img-element
73-
<img src={preview} alt="" width={tile} height={tile} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
74-
) : busy ? (
75-
<Spinner size={20} className="text-brand" />
76-
) : (
77-
<svg width={26} height={26} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="text-muted" aria-hidden>
78-
<rect x="3" y="4" width="18" height="16" rx="3" />
79-
<circle cx="9" cy="10" r="2" />
80-
<path d="M21 16l-5-5-8 8" />
81-
</svg>
82-
)}
92+
<div className={stacked ? "sm:flex sm:h-full sm:flex-col" : undefined}>
93+
{banner ? (
94+
<div className={`relative ${stacked ? "sm:flex-1" : ""}`}>
95+
<div {...zone} className={`group relative grid aspect-[3/1] w-full cursor-pointer select-none place-items-center overflow-hidden rounded-2xl border border-dashed transition-colors ${stacked ? "sm:aspect-auto sm:h-full" : ""} ${edge}`}>
96+
{preview ? (
97+
// eslint-disable-next-line @next/next/no-img-element
98+
<img src={preview} alt="" className="absolute inset-0 size-full object-cover" referrerPolicy="no-referrer" />
99+
) : null}
100+
<div className={`relative flex flex-col items-center gap-1 rounded-xl px-3 py-2 text-center transition-opacity motion-reduce:transition-none ${preview ? "bg-black/60 text-white opacity-0 backdrop-blur-sm group-hover:opacity-100 group-focus-visible:opacity-100" : ""}`}>
101+
{busy ? <Spinner size={18} className={preview ? "text-white" : "text-brand"} /> : preview ? null : <ImagePlus size={20} strokeWidth={1.8} aria-hidden="true" className="mb-0.5 text-muted" />}
102+
<p className="text-sm font-semibold">{busy ? "Uploading…" : preview ? "Change banner" : "Upload banner"}</p>
103+
<p className={`text-xs ${preview ? "text-white/80" : "text-muted"}`}>{busy ? `Resizing to ${BANNER_WIDTH}×${BANNER_HEIGHT}` : `Wide, about 3:1 · PNG, JPEG, WebP or GIF · max ${max / 1024 / 1024} MB`}</p>
104+
</div>
105+
{fileInput}
106+
</div>
107+
{preview && !busy ? (
108+
<button type="button" onClick={() => onChange("")} aria-label="Remove banner" title="Remove banner" className="absolute right-2 top-2 grid size-8 place-items-center rounded-full bg-black/60 text-white ring-1 ring-white/15 backdrop-blur-sm transition-colors hover:bg-black/75 motion-reduce:transition-none">
109+
<X size={14} aria-hidden="true" />
110+
</button>
111+
) : null}
83112
</div>
84-
<div className="min-w-0">
85-
<p className="font-semibold text-sm">{busy ? "Uploading…" : preview ? "Change image" : "Upload image"}</p>
86-
<p className={`${helper} mt-0.5`}>{busy ? "Resizing to 512×512" : "PNG, JPEG, WebP or GIF · max 2 MB · drop it here or tap"}</p>
113+
) : (
114+
<div {...zone} className={`flex items-center rounded-2xl border border-dashed cursor-pointer transition-colors select-none ${stacked ? "gap-4 px-4 py-3 sm:flex-1 sm:flex-col sm:justify-center sm:gap-3 sm:px-3 sm:py-4 sm:text-center" : "gap-4 px-4 py-3"} ${edge}`}>
115+
<div className="shrink-0 rounded-2xl overflow-hidden bg-card border border-line flex items-center justify-center" style={{ width: tile, height: tile }}>
116+
{preview ? (
117+
// eslint-disable-next-line @next/next/no-img-element
118+
<img src={preview} alt="" width={tile} height={tile} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
119+
) : busy ? (
120+
<Spinner size={20} className="text-brand" />
121+
) : (
122+
<svg width={26} height={26} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} className="text-muted" aria-hidden>
123+
<rect x="3" y="4" width="18" height="16" rx="3" />
124+
<circle cx="9" cy="10" r="2" />
125+
<path d="M21 16l-5-5-8 8" />
126+
</svg>
127+
)}
128+
</div>
129+
<div className="min-w-0">
130+
<p className="font-semibold text-sm">{busy ? "Uploading…" : preview ? "Change image" : "Upload image"}</p>
131+
<p className={`${helper} mt-0.5`}>{busy ? "Resizing to 512×512" : stacked ? "Square · PNG, JPEG, WebP or GIF · max 2 MB" : "PNG, JPEG, WebP or GIF · max 2 MB · drop it here or tap"}</p>
132+
</div>
133+
{fileInput}
87134
</div>
88-
<input
89-
ref={fileRef}
90-
id={id}
91-
type="file"
92-
accept="image/png,image/jpeg,image/webp,image/gif"
93-
className="sr-only"
94-
onChange={(e) => {
95-
const f = e.target.files?.[0];
96-
e.target.value = "";
97-
if (f) void upload(f);
98-
}}
99-
/>
100-
</div>
135+
)}
101136
{err ? <p className="text-xs text-red-600 mt-1.5">{err}</p> : null}
102137
<details className="mt-2">
103-
<summary className={`${helper} cursor-pointer select-none`}>or paste an image URL</summary>
104-
<input className={`${inputCls} mt-2`} value={value} onChange={(e) => onChange(e.target.value)} placeholder="https://…/logo.png" inputMode="url" aria-label="Image URL" />
138+
<summary className={`${helper} cursor-pointer select-none`}>{banner ? "or paste a banner URL" : "or paste an image URL"}</summary>
139+
<input className={`${inputCls} mt-2`} value={value} onChange={(e) => onChange(e.target.value)} placeholder={banner ? "https://…/banner.png" : "https://…/logo.png"} inputMode="url" aria-label={banner ? "Banner URL" : "Image URL"} />
105140
</details>
106141
</div>
107142
);

‎app/src/lib/launchpad/creator.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,22 @@ test("clampSocial: ≤125 chars on a word boundary with an ellipsis; short text
116116
assert.equal(clampSocial("x".repeat(200)).length, 125, "no spaces → hard cut + ellipsis");
117117
assert.ok(clampSocial("x".repeat(200), 155).length <= 155);
118118
});
119+
120+
test("a banner is signed when set, and a message signed before banners existed still matches", () => {
121+
const args = { chain: "base", token: "0xab", wallet: "0xcd", nonce: "n", expiresAt: 0 };
122+
const withBanner = buildEditMessage({ ...args, fields: { description: "hi", banner_url: "https://x.y/b.webp" } });
123+
assert.match(withBanner, /\nbanner: https:\/\/x\.y\/b\.webp\n/);
124+
const without = buildEditMessage({ ...args, fields: { description: "hi" } });
125+
assert.doesNotMatch(without, /banner:/);
126+
assert.equal(buildEditMessage({ ...args, fields: { description: "hi", banner_url: "" } }), without, "an empty banner reads like no banner");
127+
assert.notEqual(withBanner, without);
128+
});
129+
130+
test("validateEdit: the banner follows the image rules", () => {
131+
const v = validateEdit({ banner_url: "https://x.y/b.webp" });
132+
assert.equal(v.ok, true);
133+
if (v.ok) assert.equal(v.value.banner_url, "https://x.y/b.webp");
134+
assert.equal(validateEdit({ banner_url: "http://x.y/b.webp" }).ok, false);
135+
assert.equal(validateEdit({ banner_url: "https://localhost/b.webp" }).ok, false);
136+
assert.equal(validateEdit({ banner_url: "https://10.0.0.5/b.webp" }).ok, false);
137+
});

0 commit comments

Comments
 (0)