- custom_cards migration + CRUD API with catalog twin sync so designs appear in My Cards, lists, and decks via normal card joins - artwork upload to MinIO under card-art/ - /designer page: form-driven live preview, 4 starter frames, PNG export - /my-designs gallery with edit/delete - Designer nav entry in sidebar + mobile drawer
316 lines
8.7 KiB
JavaScript
316 lines
8.7 KiB
JavaScript
/* 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 }) {
|
|
const frame = getFrame(design.frame_id);
|
|
const rarity = getRarity(design.rarity);
|
|
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' }}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
opacity: 0.55,
|
|
}}
|
|
>
|
|
<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>
|
|
)}
|
|
</div>
|
|
|
|
{/* Type line + rarity gem */}
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
backgroundColor: p.typeBar,
|
|
border: `1px solid ${p.border}`,
|
|
borderRadius: 6,
|
|
padding: '5px 34px 5px 10px',
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
color: p.titleText,
|
|
fontSize: 13,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{design.card_type || '— Type —'}
|
|
</span>
|
|
{/* Rarity gem straddles art/type boundary */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
right: 12,
|
|
top: -14,
|
|
width: 16,
|
|
height: 16,
|
|
transform: 'rotate(45deg)',
|
|
backgroundColor: rarity.color,
|
|
border: '2px solid rgba(0,0,0,0.55)',
|
|
boxShadow: 'inset 0 0 4px rgba(255,255,255,0.65)',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Rules text box */}
|
|
<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,
|
|
}}
|
|
>
|
|
{design.rules_text && (
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
color: p.text,
|
|
fontSize: 12.5,
|
|
lineHeight: 1.35,
|
|
whiteSpace: 'pre-wrap',
|
|
overflow: 'hidden',
|
|
fontStyle: 'italic',
|
|
}}
|
|
>
|
|
{design.rules_text}
|
|
</p>
|
|
)}
|
|
{design.rules_text && design.actions && (
|
|
<div style={{ borderTop: `1px solid ${p.accent}55`, width: '100%' }} />
|
|
)}
|
|
{design.actions && (
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
color: p.text,
|
|
fontSize: 12.5,
|
|
lineHeight: 1.35,
|
|
whiteSpace: 'pre-wrap',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{design.actions}
|
|
</p>
|
|
)}
|
|
{!design.rules_text && !design.actions && (
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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>
|
|
);
|
|
}
|