feat(designer): rarity badges, flavor quotes with dividers, full-art mode
- rarity now renders as shape+color symbol anchored inside the type bar (circle/diamond/pentagon/star per rarity) — fixes straddling gem alignment - new flavor_quote field: centered italic quotation with ornamental diamond dividers between description/actions/quote - framed | fullart toggle: full-art bleeds artwork edge-to-edge with title/cost top scrim and type/text bottom scrim - shared pickDesignFields lib so create/update routes cannot drift - migration 1787685911000: art_mode + flavor_quote columns
This commit is contained in:
parent
421c5e5ee5
commit
147041d292
7 changed files with 515 additions and 132 deletions
|
|
@ -14,8 +14,16 @@ export const CARD_H = 588;
|
||||||
* so html-to-image can rasterize it 1:1 during PNG export.
|
* so html-to-image can rasterize it 1:1 during PNG export.
|
||||||
*/
|
*/
|
||||||
export default function CardFrame({ design, innerRef }) {
|
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 frame = getFrame(design.frame_id);
|
||||||
const rarity = getRarity(design.rarity);
|
|
||||||
const p = frame.palette;
|
const p = frame.palette;
|
||||||
const showPt = Boolean(design.power || design.toughness);
|
const showPt = Boolean(design.power || design.toughness);
|
||||||
|
|
||||||
|
|
@ -88,41 +96,21 @@ export default function CardFrame({ design, innerRef }) {
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<ArtPlaceholder p={p} />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Type line + rarity gem */}
|
{/* Type line + rarity badge (anchored inside the bar) */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
backgroundColor: p.typeBar,
|
backgroundColor: p.typeBar,
|
||||||
border: `1px solid ${p.border}`,
|
border: `1px solid ${p.border}`,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
padding: '5px 34px 5px 10px',
|
padding: '5px 10px',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -137,23 +125,189 @@ export default function CardFrame({ design, innerRef }) {
|
||||||
>
|
>
|
||||||
{design.card_type || '— Type —'}
|
{design.card_type || '— Type —'}
|
||||||
</span>
|
</span>
|
||||||
{/* Rarity gem straddles art/type boundary */}
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 12,
|
top: 0,
|
||||||
top: -14,
|
left: 0,
|
||||||
width: 16,
|
right: 0,
|
||||||
height: 16,
|
padding: '14px 14px 26px',
|
||||||
transform: 'rotate(45deg)',
|
background: 'linear-gradient(to bottom, rgba(10,8,6,0.82) 0%, rgba(10,8,6,0.45) 70%, transparent 100%)',
|
||||||
backgroundColor: rarity.color,
|
display: 'flex',
|
||||||
border: '2px solid rgba(0,0,0,0.55)',
|
alignItems: 'center',
|
||||||
boxShadow: 'inset 0 0 4px rgba(255,255,255,0.65)',
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Rules text box */}
|
{/* 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
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -167,39 +321,9 @@ export default function CardFrame({ design, innerRef }) {
|
||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{design.rules_text && (
|
{hasAny ? (
|
||||||
<p
|
<TextBlocks design={design} color={p.text} dividerColor={`${p.accent}55`} />
|
||||||
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
|
<p
|
||||||
style={{
|
style={{
|
||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
|
|
@ -230,10 +354,165 @@ export default function CardFrame({ design, innerRef }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 }}>“</span>
|
||||||
|
{design.flavor_quote}
|
||||||
|
<span style={{ opacity: 0.7, marginLeft: 2, fontSize: fontSize + 3 }}>”</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>
|
</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
|
* Scales CardFrame to fit the available width while preserving the
|
||||||
* natural 420x588 layout (transform keeps export coordinates intact).
|
* natural 420x588 layout (transform keeps export coordinates intact).
|
||||||
|
|
|
||||||
25
lib/custom-cards-fields.js
Normal file
25
lib/custom-cards-fields.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Shared field normalization for custom card designs.
|
||||||
|
* Single source of truth so the create and update routes never drift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function pickDesignFields(body = {}) {
|
||||||
|
const str = (v) => (typeof v === 'string' ? v.trim() : null);
|
||||||
|
|
||||||
|
const artMode = str(body.art_mode) || str(body.artMode) || 'framed';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: str(body.name),
|
||||||
|
manaCost: str(body.mana_cost) || str(body.manaCost),
|
||||||
|
cardType: str(body.card_type) || str(body.cardType),
|
||||||
|
rarity: str(body.rarity),
|
||||||
|
rulesText: str(body.rules_text) || str(body.description),
|
||||||
|
actions: str(body.actions),
|
||||||
|
flavorQuote: str(body.flavor_quote) || str(body.flavorQuote),
|
||||||
|
power: str(body.power),
|
||||||
|
toughness: str(body.toughness),
|
||||||
|
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
|
||||||
|
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
|
||||||
|
artMode: artMode === 'fullart' ? 'fullart' : 'framed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Phase 1 designer upgrades:
|
||||||
|
* - art_mode: 'framed' (classic layout) | 'fullart' (edge-to-edge art,
|
||||||
|
* text on scrims)
|
||||||
|
* - flavor_quote: optional quotation rendered with decorative dividers
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_cards
|
||||||
|
ADD COLUMN IF NOT EXISTS art_mode VARCHAR(20) NOT NULL DEFAULT 'framed',
|
||||||
|
ADD COLUMN IF NOT EXISTS flavor_quote TEXT
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_cards
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_custom_cards_art_mode
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_cards
|
||||||
|
ADD CONSTRAINT chk_custom_cards_art_mode
|
||||||
|
CHECK (art_mode IN ('framed', 'fullart'))
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_cards
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_custom_cards_art_mode
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_cards
|
||||||
|
DROP COLUMN IF EXISTS art_mode,
|
||||||
|
DROP COLUMN IF EXISTS flavor_quote
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
import { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
|
||||||
import { syncCatalogCard, ensureOwnedRow } from './index.js';
|
import { syncCatalogCard, ensureOwnedRow } from './index.js';
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
|
|
@ -28,7 +29,7 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
const f = pickFields(req.body);
|
const f = pickDesignFields(req.body);
|
||||||
if (!f.name) {
|
if (!f.name) {
|
||||||
return res.status(400).json({ error: 'Name is required' });
|
return res.status(400).json({ error: 'Name is required' });
|
||||||
}
|
}
|
||||||
|
|
@ -38,8 +39,10 @@ export default async function handler(req, res) {
|
||||||
name = ${f.name}, mana_cost = ${f.manaCost},
|
name = ${f.name}, mana_cost = ${f.manaCost},
|
||||||
card_type = ${f.cardType}, rarity = ${f.rarity},
|
card_type = ${f.cardType}, rarity = ${f.rarity},
|
||||||
rules_text = ${f.rulesText}, actions = ${f.actions},
|
rules_text = ${f.rulesText}, actions = ${f.actions},
|
||||||
|
flavor_quote = ${f.flavorQuote},
|
||||||
power = ${f.power}, toughness = ${f.toughness},
|
power = ${f.power}, toughness = ${f.toughness},
|
||||||
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
|
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
|
||||||
|
art_mode = ${f.artMode},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ${designId}
|
WHERE id = ${designId}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|
@ -73,19 +76,3 @@ export default async function handler(req, res) {
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickFields(body) {
|
|
||||||
const str = (v) => (typeof v === 'string' ? v.trim() : null);
|
|
||||||
return {
|
|
||||||
name: str(body.name),
|
|
||||||
manaCost: str(body.mana_cost) || str(body.manaCost),
|
|
||||||
cardType: str(body.card_type) || str(body.cardType),
|
|
||||||
rarity: str(body.rarity),
|
|
||||||
rulesText: str(body.rules_text) || str(body.description),
|
|
||||||
actions: str(body.actions),
|
|
||||||
power: str(body.power),
|
|
||||||
toughness: str(body.toughness),
|
|
||||||
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
|
|
||||||
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
import { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
|
||||||
|
|
||||||
const CUSTOM_GAME = 'Custom';
|
const CUSTOM_GAME = 'Custom';
|
||||||
const CUSTOM_SET = 'Designs';
|
const CUSTOM_SET = 'Designs';
|
||||||
|
|
@ -14,8 +15,8 @@ export default async function handler(req, res) {
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT id, card_id, name, mana_cost, card_type, rarity,
|
SELECT id, card_id, name, mana_cost, card_type, rarity,
|
||||||
rules_text, actions, power, toughness, frame_id,
|
rules_text, actions, flavor_quote, power, toughness,
|
||||||
artwork_url, created_at, updated_at
|
frame_id, artwork_url, art_mode, created_at, updated_at
|
||||||
FROM custom_cards
|
FROM custom_cards
|
||||||
WHERE user_id = ${user.userId}
|
WHERE user_id = ${user.userId}
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
|
|
@ -38,34 +39,19 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickFields(body) {
|
|
||||||
const str = (v) => (typeof v === 'string' ? v.trim() : null);
|
|
||||||
return {
|
|
||||||
name: str(body.name),
|
|
||||||
manaCost: str(body.mana_cost) || str(body.manaCost),
|
|
||||||
cardType: str(body.card_type) || str(body.cardType),
|
|
||||||
rarity: str(body.rarity),
|
|
||||||
rulesText: str(body.rules_text) || str(body.description),
|
|
||||||
actions: str(body.actions),
|
|
||||||
power: str(body.power),
|
|
||||||
toughness: str(body.toughness),
|
|
||||||
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
|
|
||||||
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createDesign(userId, body) {
|
async function createDesign(userId, body) {
|
||||||
const f = pickFields(body);
|
const f = pickDesignFields(body);
|
||||||
if (!f.name) return null;
|
if (!f.name) return null;
|
||||||
|
|
||||||
const inserted = await sql`
|
const inserted = await sql`
|
||||||
INSERT INTO custom_cards
|
INSERT INTO custom_cards
|
||||||
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
||||||
actions, power, toughness, frame_id, artwork_url)
|
actions, flavor_quote, power, toughness, frame_id, artwork_url,
|
||||||
|
art_mode)
|
||||||
VALUES
|
VALUES
|
||||||
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
||||||
${f.rulesText}, ${f.actions}, ${f.power}, ${f.toughness},
|
${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power},
|
||||||
${f.frameId}, ${f.artworkUrl})
|
${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode})
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
const design = inserted.rows[0];
|
const design = inserted.rows[0];
|
||||||
|
|
@ -94,7 +80,10 @@ export async function syncCatalogCard(design, fields) {
|
||||||
game: CUSTOM_GAME,
|
game: CUSTOM_GAME,
|
||||||
manaCost: fields.manaCost,
|
manaCost: fields.manaCost,
|
||||||
cardType: fields.cardType,
|
cardType: fields.cardType,
|
||||||
oracleText: [fields.rulesText, fields.actions].filter(Boolean).join('\n\n') || null,
|
oracleText:
|
||||||
|
[fields.actions, fields.rulesText, fields.flavorQuote]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n') || null,
|
||||||
imageUrl: fields.artworkUrl,
|
imageUrl: fields.artworkUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,12 @@ const BLANK_DESIGN = {
|
||||||
rarity: 'common',
|
rarity: 'common',
|
||||||
rules_text: '',
|
rules_text: '',
|
||||||
actions: '',
|
actions: '',
|
||||||
|
flavor_quote: '',
|
||||||
power: '',
|
power: '',
|
||||||
toughness: '',
|
toughness: '',
|
||||||
frame_id: 'classic',
|
frame_id: 'classic',
|
||||||
artwork_url: '',
|
artwork_url: '',
|
||||||
|
art_mode: 'framed',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Designer() {
|
export default function Designer() {
|
||||||
|
|
@ -254,6 +256,27 @@ export default function Designer() {
|
||||||
onChange={(e) => handleUpload(e.target.files?.[0])}
|
onChange={(e) => handleUpload(e.target.files?.[0])}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex rounded-xl overflow-hidden border" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'framed', label: 'Framed' },
|
||||||
|
{ id: 'fullart', label: 'Full Art' },
|
||||||
|
].map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setField('art_mode', mode.id)}
|
||||||
|
className="px-4 py-2 text-xs font-semibold transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
design.art_mode === mode.id ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||||||
|
color: design.art_mode === mode.id ? 'white' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
aria-pressed={design.art_mode === mode.id}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<Button variant="primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
<Button variant="primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||||
{uploading ? 'Uploading…' : design.artwork_url ? 'Replace Image' : 'Upload Image'}
|
{uploading ? 'Uploading…' : design.artwork_url ? 'Replace Image' : 'Upload Image'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -329,13 +352,13 @@ export default function Designer() {
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Description / Flavor">
|
<Field label="Description">
|
||||||
<textarea
|
<textarea
|
||||||
className="input-field w-full resize-y"
|
className="input-field w-full resize-y"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={design.rules_text}
|
value={design.rules_text}
|
||||||
onChange={(e) => setField('rules_text', e.target.value)}
|
onChange={(e) => setField('rules_text', e.target.value)}
|
||||||
placeholder="Flavor text or lore…"
|
placeholder="What the card does, or what it describes…"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -351,6 +374,17 @@ export default function Designer() {
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Flavor Quote" hint="shown in italics with decorative dividers">
|
||||||
|
<textarea
|
||||||
|
className="input-field w-full resize-y"
|
||||||
|
rows={2}
|
||||||
|
value={design.flavor_quote}
|
||||||
|
onChange={(e) => setField('flavor_quote', e.target.value)}
|
||||||
|
placeholder="From the ashes, memory takes wing."
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<details className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<details className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<summary className="cursor-pointer select-none">Power / Toughness (optional)</summary>
|
<summary className="cursor-pointer select-none">Power / Toughness (optional)</summary>
|
||||||
<div className="grid grid-cols-2 gap-4 mt-3">
|
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,38 @@ describe('POST /api/custom-cards', () => {
|
||||||
// 4 statements: insert design, insert catalog twin, own row, link back
|
// 4 statements: insert design, insert catalog twin, own row, link back
|
||||||
expect(sql).toHaveBeenCalledTimes(4);
|
expect(sql).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes camelCase and full-art fields on create', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 11, card_id: null, name: 'Void Mirror' }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 78 }] })
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 11, card_id: 78, name: 'Void Mirror' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: 'Void Mirror',
|
||||||
|
artMode: 'fullart',
|
||||||
|
flavorQuote: 'What looks back is never the same twice.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const insertSql = sql.mock.calls[0][0].join('');
|
||||||
|
// art_mode coerced to the allowed set; flavor_quote mapped through
|
||||||
|
expect(insertSql).toContain('art_mode');
|
||||||
|
expect(insertSql).toContain('flavor_quote');
|
||||||
|
expect(sql.mock.calls[0]).toContain('fullart');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/api/custom-cards/[id]', () => {
|
describe('/api/custom-cards/[id]', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue