deckhearth/components/designer/CardFrame.js

596 lines
16 KiB
JavaScript
Raw Normal View History

/* 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 <FullArtCard design={design} innerRef={innerRef} />;
}
return <FramedCard design={design} innerRef={innerRef} />;
}
/* ─────────────────────────── framed layout ─────────────────────────── */
function FramedCard({ design, innerRef }) {
const frame = getFrame(design.frame_id);
const p = frame.palette;
const showPt = Boolean(design.power || design.toughness);
return (
<div
ref={innerRef}
style={{
width: CARD_W,
height: CARD_H,
backgroundColor: p.outer,
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} />
</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} />
</div>
);
}
/* ─────────────────────────── 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 (
<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,
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} />
</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 />
{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 }) {
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`} />
) : (
<p
style={{
margin: 'auto',
color: `${p.text}66`,
fontSize: 12.5,
fontStyle: 'italic',
}}
>
Description &amp; 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.
*/
function TextBlocks({ design, color, dividerColor, compact = false }) {
const fontSize = compact ? 11.5 : 12.5;
const blocks = [];
if (design.rules_text) {
blocks.push(
<p key="desc" style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
{design.rules_text}
</p>
);
}
if (design.actions) {
if (blocks.length > 0) blocks.push(<Divider key="d1" color={dividerColor} />);
blocks.push(
<p key="actions" style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
{design.actions}
</p>
);
}
if (design.flavor_quote) {
if (blocks.length > 0) blocks.push(<Divider key="d2" color={dividerColor} ornament />);
blocks.push(
<p
key="quote"
style={{
margin: 0,
color,
fontSize,
lineHeight: 1.35,
whiteSpace: 'pre-wrap',
overflow: 'hidden',
fontStyle: 'italic',
textAlign: 'center',
}}
>
<span style={{ opacity: 0.7, marginRight: 2, fontSize: fontSize + 3 }}>&ldquo;</span>
{design.flavor_quote}
<span style={{ opacity: 0.7, marginLeft: 2, fontSize: fontSize + 3 }}>&rdquo;</span>
</p>
);
}
return <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>{blocks}</div>;
}
/** 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 }) {
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} />
</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.
*/
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 (
<span style={{ display: 'inline-flex', gap: 3, flexShrink: 0 }}>
{tokens.slice(0, 8).map((token, i) => (
<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',
}}
>
{token}
</span>
))}
</span>
);
}