/* 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 ;
}
return ;
}
/* ─────────────────────────── framed layout ─────────────────────────── */
function FramedCard({ design, innerRef, symbols }) {
const p = resolvePalette(design);
const texture = resolveTexture(design);
const showPt = Boolean(design.power || design.toughness);
return (
{/* Title bar */}
{design.name || 'Untitled Card'}
{/* Artwork window */}
{design.artwork_url ? (
) : (
)}
{/* Type line + rarity badge (anchored inside the bar) */}
);
}
/**
* 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(
);
}
if (design.actions) {
if (blocks.length > 0) blocks.push();
blocks.push(
);
}
if (design.flavor_quote) {
if (blocks.length > 0) blocks.push();
blocks.push(
“}
suffix={”}
/>
);
}
return
{blocks}
;
}
/** 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 (
{prefix}
{parts.map((part, i) => {
const match = part.match(/^\{([^}]+)\}$/);
if (match) {
const icon = iconFor(match[1]);
if (icon) {
return (
);
}
}
return {part};
})}
{suffix}
);
}
/** Thin rule; ornament adds a small diamond at center. */
function Divider({ color, ornament = false }) {
if (!ornament) {
return ;
}
return (
);
}
/**
* 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: (
),
uncommon: (
),
rare: (
),
mythic: (
),
};
return (
);
}
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 (
);
}
/**
* 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 (
{tokens.slice(0, 8).map((token, i) => {
const icon = iconFor(token);
return (
{icon ? (
) : (
token
)}
);
})}
);
}