/* eslint-disable @next/next/no-img-element -- Artwork comes 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; /** * 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 }) { if (design.art_mode === 'fullart') { return ; } return ; } /* ─────────────────────────── framed layout ─────────────────────────── */ function FramedCard({ design, innerRef }) { const frame = getFrame(design.frame_id); const p = frame.palette; const showPt = Boolean(design.power || design.toughness); return (
{/* Title bar */}
{design.name || 'Untitled Card'}
{/* Artwork window */}
{design.artwork_url ? ( {design.name ) : ( )}
{/* Type line + rarity badge (anchored inside the bar) */}
{design.card_type || '— Type —'}
{/* Text box */}
); } /* ─────────────────────────── full-art layout ───────────────────────── */ function FullArtCard({ design, innerRef }) { const frame = getFrame(design.frame_id); const p = frame.palette; const showPt = Boolean(design.power || design.toughness); const hasArt = Boolean(design.artwork_url); return (
{/* Edge-to-edge artwork */} {hasArt ? ( {design.name ) : (
)} {/* Top scrim: title + cost */}
{design.name || 'Untitled Card'}
{/* Bottom scrim: type line, text, P/T */}
{design.card_type || '— Type —'}
{showPt && (
{design.power || '0'} / {design.toughness || '0'}
)}
); } /* ─────────────────────────── shared pieces ─────────────────────────── */ function ArtPlaceholder({ p, full = false }) { return (
Upload artwork
); } function TextBox({ design, p, showPt }) { const hasAny = design.rules_text || design.actions || design.flavor_quote; return (
{hasAny ? ( ) : (

Description & actions appear here

)} {showPt && (
{design.power || '0'} / {design.toughness || '0'}
)}
); } /** * 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. */ function TextBlocks({ design, color, dividerColor, compact = false }) { const fontSize = compact ? 11.5 : 12.5; const blocks = []; if (design.rules_text) { blocks.push(

{design.rules_text}

); } if (design.actions) { if (blocks.length > 0) blocks.push(); blocks.push(

{design.actions}

); } if (design.flavor_quote) { if (blocks.length > 0) blocks.push(); blocks.push(

{design.flavor_quote}

); } return
{blocks}
; } /** 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 ( {shapes[rarity.id] || shapes.common} ); } 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 }) { 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. */ export function ManaPips({ cost, accent }) { 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; return ( {tokens.slice(0, 8).map((token, i) => ( {token} ))} ); }