11"use client" ;
22
33import { useId , useRef , useState } from "react" ;
4+ import { ImagePlus , X } from "lucide-react" ;
45import { 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" ;
67import { 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 = / ^ h t t p s ? : \/ \/ / . 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 ) ;
0 commit comments