- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
678 lines
19 KiB
JavaScript
678 lines
19 KiB
JavaScript
/* eslint-disable @next/next/no-img-element -- Artwork/symbols come from MinIO CDN / data URLs; next/image is out of scope for the designer canvas. */
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { getFrame, getRarity } from './frames';
|
|
|
|
/** Natural render size of the card (5:7). Everything inside is px-based
|
|
* so screen preview and PNG export are pixel-identical. */
|
|
export const CARD_W = 420;
|
|
export const CARD_H = 588;
|
|
|
|
/** Resolve the active palette: a custom frame's palette when linked,
|
|
* otherwise the starter frame's. */
|
|
export function resolvePalette(design) {
|
|
if (design.custom_frame?.palette) {
|
|
return design.custom_frame.palette;
|
|
}
|
|
return getFrame(design.frame_id).palette;
|
|
}
|
|
|
|
/** Optional background texture from the linked custom frame. */
|
|
function resolveTexture(design) {
|
|
return design.custom_frame?.texture_url || null;
|
|
}
|
|
|
|
/**
|
|
* Live card renderer for the designer. Renders at CARD_W x CARD_H and is
|
|
* scaled to fit its container by CardPreview below. Fully inline-styled
|
|
* so html-to-image can rasterize it 1:1 during PNG export.
|
|
*/
|
|
export default function CardFrame({ design, innerRef, symbols }) {
|
|
if (design.art_mode === 'fullart') {
|
|
return <FullArtCard design={design} innerRef={innerRef} symbols={symbols} />;
|
|
}
|
|
return <FramedCard design={design} innerRef={innerRef} symbols={symbols} />;
|
|
}
|
|
|
|
/* ─────────────────────────── framed layout ─────────────────────────── */
|
|
|
|
function FramedCard({ design, innerRef, symbols }) {
|
|
const p = resolvePalette(design);
|
|
const texture = resolveTexture(design);
|
|
const showPt = Boolean(design.power || design.toughness);
|
|
|
|
return (
|
|
<div
|
|
ref={innerRef}
|
|
style={{
|
|
width: CARD_W,
|
|
height: CARD_H,
|
|
backgroundColor: p.outer,
|
|
backgroundImage: texture ? `url(${texture})` : undefined,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
borderRadius: 18,
|
|
border: `6px solid ${p.border}`,
|
|
boxSizing: 'border-box',
|
|
padding: 16,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
fontFamily: 'Georgia, "Times New Roman", serif',
|
|
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
|
|
userSelect: 'none',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Title bar */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 8,
|
|
backgroundColor: p.titleBar,
|
|
border: `1px solid ${p.border}`,
|
|
borderRadius: 6,
|
|
padding: '6px 10px',
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
color: p.titleText,
|
|
fontSize: 17,
|
|
fontWeight: 700,
|
|
lineHeight: 1.15,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{design.name || 'Untitled Card'}
|
|
</span>
|
|
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
|
|
</div>
|
|
|
|
{/* Artwork window */}
|
|
<div
|
|
style={{
|
|
height: 234,
|
|
flexShrink: 0,
|
|
backgroundColor: p.artBacking,
|
|
border: `1px solid ${p.border}`,
|
|
borderRadius: 6,
|
|
overflow: 'hidden',
|
|
marginBottom: 8,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{design.artwork_url ? (
|
|
<img
|
|
src={design.artwork_url}
|
|
alt={design.name || 'Card artwork'}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
/>
|
|
) : (
|
|
<ArtPlaceholder p={p} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Type line + rarity badge (anchored inside the bar) */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 8,
|
|
backgroundColor: p.typeBar,
|
|
border: `1px solid ${p.border}`,
|
|
borderRadius: 6,
|
|
padding: '5px 10px',
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
color: p.titleText,
|
|
fontSize: 13,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{design.card_type || '— Type —'}
|
|
</span>
|
|
<RarityBadge rarityId={design.rarity} size={18} />
|
|
</div>
|
|
|
|
{/* Text box */}
|
|
<TextBox design={design} p={p} showPt={showPt} symbols={symbols} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─────────────────────────── full-art layout ───────────────────────── */
|
|
|
|
function FullArtCard({ design, innerRef, symbols }) {
|
|
const p = resolvePalette(design);
|
|
const texture = resolveTexture(design);
|
|
const showPt = Boolean(design.power || design.toughness);
|
|
const hasArt = Boolean(design.artwork_url);
|
|
|
|
return (
|
|
<div
|
|
ref={innerRef}
|
|
style={{
|
|
width: CARD_W,
|
|
height: CARD_H,
|
|
position: 'relative',
|
|
borderRadius: 18,
|
|
border: `6px solid ${p.border}`,
|
|
boxSizing: 'border-box',
|
|
overflow: 'hidden',
|
|
backgroundColor: p.artBacking,
|
|
backgroundImage: texture ? `url(${texture})` : undefined,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
fontFamily: 'Georgia, "Times New Roman", serif',
|
|
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{/* Edge-to-edge artwork */}
|
|
{hasArt ? (
|
|
<img
|
|
src={design.artwork_url}
|
|
alt={design.name || 'Card artwork'}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
display: 'block',
|
|
}}
|
|
/>
|
|
) : (
|
|
<div style={{ position: 'absolute', inset: 0 }}>
|
|
<ArtPlaceholder p={p} full />
|
|
</div>
|
|
)}
|
|
|
|
{/* Top scrim: title + cost */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
padding: '14px 14px 26px',
|
|
background: 'linear-gradient(to bottom, rgba(10,8,6,0.82) 0%, rgba(10,8,6,0.45) 70%, transparent 100%)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
color: '#f7f2e6',
|
|
fontSize: 18,
|
|
fontWeight: 700,
|
|
textShadow: '0 1px 4px rgba(0,0,0,0.9)',
|
|
lineHeight: 1.15,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{design.name || 'Untitled Card'}
|
|
</span>
|
|
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
|
|
</div>
|
|
|
|
{/* Bottom scrim: type line, text, P/T */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
padding: '30px 14px 12px',
|
|
background: 'linear-gradient(to top, rgba(10,8,6,0.88) 0%, rgba(10,8,6,0.62) 75%, transparent 100%)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 8,
|
|
borderBottom: `1px solid ${p.accent}88`,
|
|
paddingBottom: 6,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
color: '#f7f2e6',
|
|
fontSize: 13,
|
|
textShadow: '0 1px 3px rgba(0,0,0,0.9)',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{design.card_type || '— Type —'}
|
|
</span>
|
|
<RarityBadge rarityId={design.rarity} size={18} />
|
|
</div>
|
|
|
|
<TextBlocks design={design} color="#f2ecdd" dividerColor={`${p.accent}99`} compact symbols={symbols} />
|
|
|
|
{showPt && (
|
|
<div
|
|
style={{
|
|
alignSelf: 'flex-end',
|
|
backgroundColor: 'rgba(10,8,6,0.75)',
|
|
color: '#f7f2e6',
|
|
border: `1px solid ${p.accent}`,
|
|
borderRadius: 999,
|
|
padding: '1px 14px',
|
|
fontWeight: 700,
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
{design.power || '0'} / {design.toughness || '0'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─────────────────────────── shared pieces ─────────────────────────── */
|
|
|
|
function ArtPlaceholder({ p, full = false }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
opacity: full ? 0.35 : 0.55,
|
|
background: full
|
|
? `linear-gradient(160deg, ${p.titleBar}, ${p.artBacking} 70%)`
|
|
: 'transparent',
|
|
}}
|
|
>
|
|
<svg width="96" height="96" viewBox="0 0 24 24" fill="none" stroke={p.border} strokeWidth="1.5">
|
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
<path d="M21 15l-5-5L5 21" />
|
|
</svg>
|
|
<span style={{ color: p.border, fontSize: 13, fontStyle: 'italic', fontFamily: 'inherit' }}>
|
|
Upload artwork
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextBox({ design, p, showPt, symbols }) {
|
|
const hasAny = design.rules_text || design.actions || design.flavor_quote;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: p.textBox,
|
|
border: `1px solid ${p.border}`,
|
|
borderRadius: 6,
|
|
padding: '10px 12px',
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{hasAny ? (
|
|
<TextBlocks design={design} color={p.text} dividerColor={`${p.accent}55`} symbols={symbols} />
|
|
) : (
|
|
<p
|
|
style={{
|
|
margin: 'auto',
|
|
color: `${p.text}66`,
|
|
fontSize: 12.5,
|
|
fontStyle: 'italic',
|
|
}}
|
|
>
|
|
Description & actions appear here
|
|
</p>
|
|
)}
|
|
|
|
{showPt && (
|
|
<div
|
|
style={{
|
|
marginTop: 'auto',
|
|
alignSelf: 'flex-end',
|
|
backgroundColor: p.titleBar,
|
|
color: p.titleText,
|
|
border: `1px solid ${p.border}`,
|
|
borderRadius: 999,
|
|
padding: '1px 14px',
|
|
fontWeight: 700,
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
{design.power || '0'} / {design.toughness || '0'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ordered text content: description, actions, then the flavor quotation.
|
|
* Sections are separated by ornamental dividers; the quote renders in
|
|
* italics wrapped in decorative quotation marks. `{CODE}` tokens render
|
|
* inline as symbol icons when the user has defined them.
|
|
*/
|
|
function TextBlocks({ design, color, dividerColor, compact = false, symbols }) {
|
|
const fontSize = compact ? 11.5 : 12.5;
|
|
const blocks = [];
|
|
|
|
if (design.rules_text) {
|
|
blocks.push(
|
|
<RichText key="desc" text={design.rules_text} symbols={symbols}
|
|
style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }} />
|
|
);
|
|
}
|
|
|
|
if (design.actions) {
|
|
if (blocks.length > 0) blocks.push(<Divider key="d1" color={dividerColor} />);
|
|
blocks.push(
|
|
<RichText key="actions" text={design.actions} symbols={symbols}
|
|
style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }} />
|
|
);
|
|
}
|
|
|
|
if (design.flavor_quote) {
|
|
if (blocks.length > 0) blocks.push(<Divider key="d2" color={dividerColor} ornament />);
|
|
blocks.push(
|
|
<RichText
|
|
key="quote"
|
|
text={design.flavor_quote}
|
|
symbols={symbols}
|
|
style={{
|
|
margin: 0,
|
|
color,
|
|
fontSize,
|
|
lineHeight: 1.35,
|
|
whiteSpace: 'pre-wrap',
|
|
overflow: 'hidden',
|
|
fontStyle: 'italic',
|
|
textAlign: 'center',
|
|
}}
|
|
prefix={<span style={{ opacity: 0.7, marginRight: 2, fontSize: fontSize + 3 }}>“</span>}
|
|
suffix={<span style={{ opacity: 0.7, marginLeft: 2, fontSize: fontSize + 3 }}>”</span>}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>{blocks}</div>;
|
|
}
|
|
|
|
/** Renders text with `{CODE}` tokens replaced by inline symbol icons. */
|
|
export function RichText({ text, symbols, style, prefix, suffix }) {
|
|
const hasSymbols = symbols && Object.keys(symbols).length > 0;
|
|
const iconFor = (code) => {
|
|
if (!hasSymbols) return null;
|
|
return symbols[code] || symbols[code.toLowerCase()] || symbols[code.toUpperCase()] || null;
|
|
};
|
|
|
|
const parts = hasSymbols ? text.split(/(\{[^}]+\})/g) : [text];
|
|
|
|
return (
|
|
<p style={style}>
|
|
{prefix}
|
|
{parts.map((part, i) => {
|
|
const match = part.match(/^\{([^}]+)\}$/);
|
|
if (match) {
|
|
const icon = iconFor(match[1]);
|
|
if (icon) {
|
|
return (
|
|
<img
|
|
key={`${match[1]}-${i}`}
|
|
src={icon}
|
|
alt={match[1]}
|
|
style={{
|
|
height: '1.2em',
|
|
width: 'auto',
|
|
verticalAlign: '-0.22em',
|
|
display: 'inline-block',
|
|
margin: '0 1px',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
return <span key={i}>{part}</span>;
|
|
})}
|
|
{suffix}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** Thin rule; ornament adds a small diamond at center. */
|
|
function Divider({ color, ornament = false }) {
|
|
if (!ornament) {
|
|
return <div style={{ borderTop: `1px solid ${color}`, width: '100%' }} />;
|
|
}
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
|
<div style={{ flex: 1, borderTop: `1px solid ${color}` }} />
|
|
<div
|
|
style={{
|
|
width: 6,
|
|
height: 6,
|
|
transform: 'rotate(45deg)',
|
|
backgroundColor: color,
|
|
}}
|
|
/>
|
|
<div style={{ flex: 1, borderTop: `1px solid ${color}` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Rarity badge rendered inside the type bar: a distinct shape and color
|
|
* per rarity, vertically centered, right-aligned. Inline SVG keeps PNG
|
|
* export pixel-faithful.
|
|
*
|
|
* common → circle
|
|
* uncommon → diamond
|
|
* rare → pentagon
|
|
* mythic → star
|
|
*/
|
|
export function RarityBadge({ rarityId, size = 18 }) {
|
|
const rarity = getRarity(rarityId);
|
|
const s = size;
|
|
const stroke = 'rgba(0,0,0,0.55)';
|
|
const shine = 'rgba(255,255,255,0.5)';
|
|
|
|
const shapes = {
|
|
common: (
|
|
<circle cx={s / 2} cy={s / 2} r={s / 2 - 1.5} fill={rarity.color} stroke={stroke} strokeWidth="1.5" />
|
|
),
|
|
uncommon: (
|
|
<path
|
|
d={`M${s / 2} 1 L${s - 1} ${s / 2} L${s / 2} ${s - 1} L1 ${s / 2} Z`}
|
|
fill={rarity.color}
|
|
stroke={stroke}
|
|
strokeWidth="1.5"
|
|
strokeLinejoin="round"
|
|
/>
|
|
),
|
|
rare: (
|
|
<path
|
|
d={`M${s / 2} 1 L${s - 1} ${(s * 0.36).toFixed(1)} L${(s * 0.81).toFixed(1)} ${s - 1} L${(s * 0.19).toFixed(1)} ${s - 1} L1 ${(s * 0.36).toFixed(1)} Z`}
|
|
fill={rarity.color}
|
|
stroke={stroke}
|
|
strokeWidth="1.5"
|
|
strokeLinejoin="round"
|
|
/>
|
|
),
|
|
mythic: (
|
|
<path
|
|
d={starPath(s)}
|
|
fill={rarity.color}
|
|
stroke={stroke}
|
|
strokeWidth="1.5"
|
|
strokeLinejoin="round"
|
|
/>
|
|
),
|
|
};
|
|
|
|
return (
|
|
<span
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: s,
|
|
height: s,
|
|
flexShrink: 0,
|
|
filter: `drop-shadow(0 0 2px ${shine})`,
|
|
}}
|
|
title={rarity.name}
|
|
>
|
|
<svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
|
{shapes[rarity.id] || shapes.common}
|
|
</svg>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function starPath(s) {
|
|
const cx = s / 2;
|
|
const cy = s / 2 + s * 0.04;
|
|
const outer = s / 2 - 0.5;
|
|
const inner = outer * 0.42;
|
|
const points = [];
|
|
for (let i = 0; i < 10; i += 1) {
|
|
const r = i % 2 === 0 ? outer : inner;
|
|
const angle = (Math.PI / 5) * i - Math.PI / 2;
|
|
points.push(`${(cx + r * Math.cos(angle)).toFixed(2)} ${(cy + r * Math.sin(angle)).toFixed(2)}`);
|
|
}
|
|
return `M${points.join(' L')} Z`;
|
|
}
|
|
|
|
/**
|
|
* Scales CardFrame to fit the available width while preserving the
|
|
* natural 420x588 layout (transform keeps export coordinates intact).
|
|
*/
|
|
export function CardPreview({ design, innerRef, maxWidth = 420, symbols }) {
|
|
const containerRef = useRef(null);
|
|
const [scale, setScale] = useState(1);
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return undefined;
|
|
|
|
const update = () => {
|
|
const available = Math.min(el.clientWidth, maxWidth);
|
|
setScale(Math.min(1, available / CARD_W));
|
|
};
|
|
|
|
update();
|
|
const observer = new ResizeObserver(update);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [maxWidth]);
|
|
|
|
return (
|
|
<div ref={containerRef} style={{ width: '100%', overflow: 'visible' }}>
|
|
<div
|
|
style={{
|
|
width: CARD_W * scale,
|
|
height: CARD_H * scale,
|
|
margin: '0 auto',
|
|
}}
|
|
>
|
|
<div style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}>
|
|
<CardFrame design={design} innerRef={innerRef} symbols={symbols} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders a mana cost string as pip circles. Accepts both scryfall
|
|
* braces ("{2}{R}{R}") and plain notation ("2RR"), plus arbitrary
|
|
* symbols for custom games. When `symbols` maps a token to an icon URL,
|
|
* the icon renders inside the pip instead of text.
|
|
*/
|
|
export function ManaPips({ cost, accent, symbols }) {
|
|
if (!cost || !cost.trim()) return null;
|
|
|
|
const tokens = /\{/.test(cost)
|
|
? (cost.match(/\{[^}]+\}/g) || []).map((t) => t.slice(1, -1))
|
|
: cost.split(/\s+/).flatMap((chunk) => chunk.split(''));
|
|
|
|
if (tokens.length === 0) return null;
|
|
|
|
const iconFor = (token) => {
|
|
if (!symbols) return null;
|
|
return (
|
|
symbols[token] ||
|
|
symbols[token.toLowerCase()] ||
|
|
symbols[token.toUpperCase()] ||
|
|
null
|
|
);
|
|
};
|
|
|
|
return (
|
|
<span style={{ display: 'inline-flex', gap: 3, flexShrink: 0 }}>
|
|
{tokens.slice(0, 8).map((token, i) => {
|
|
const icon = iconFor(token);
|
|
return (
|
|
<span
|
|
key={`${token}-${i}`}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: '50%',
|
|
backgroundColor: '#0e0c08',
|
|
border: `1.5px solid ${accent}`,
|
|
color: '#f4efe2',
|
|
fontSize: 12,
|
|
fontWeight: 700,
|
|
fontFamily: 'Georgia, serif',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{icon ? (
|
|
<img
|
|
src={icon}
|
|
alt={token}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
/>
|
|
) : (
|
|
token
|
|
)}
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
}
|