diff --git a/components/designer/CardFrame.js b/components/designer/CardFrame.js index f9d9fb8..c2c7f06 100644 --- a/components/designer/CardFrame.js +++ b/components/designer/CardFrame.js @@ -14,8 +14,16 @@ export const CARD_H = 588; * 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 rarity = getRarity(design.rarity); const p = frame.palette; 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' }} /> ) : ( -
- - - - - - - Upload artwork - -
+ )} - {/* Type line + rarity gem */} + {/* Type line + rarity badge (anchored inside the bar) */}
@@ -137,89 +125,140 @@ export default function CardFrame({ design, innerRef }) { > {design.card_type || '— Type —'} - {/* Rarity gem straddles art/type boundary */} -
+
- {/* Rules text box */} + {/* 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.rules_text && ( -

+ - {design.rules_text} -

- )} - {design.rules_text && design.actions && ( -
- )} - {design.actions && ( -

- {design.actions} -

- )} - {!design.rules_text && !design.actions && ( -

- Description & actions appear here -

- )} + {design.card_type || '— Type —'} + + +
+ + {showPt && (
+ + + + + + + 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). diff --git a/lib/custom-cards-fields.js b/lib/custom-cards-fields.js new file mode 100644 index 0000000..6876982 --- /dev/null +++ b/lib/custom-cards-fields.js @@ -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', + }; +} diff --git a/migrations/1787685911000_add-custom-cards-art-mode-flavor-quote.js b/migrations/1787685911000_add-custom-cards-art-mode-flavor-quote.js new file mode 100644 index 0000000..aacc716 --- /dev/null +++ b/migrations/1787685911000_add-custom-cards-art-mode-flavor-quote.js @@ -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 + `); +}; diff --git a/pages/api/custom-cards/[id].js b/pages/api/custom-cards/[id].js index a161bba..907e79c 100644 --- a/pages/api/custom-cards/[id].js +++ b/pages/api/custom-cards/[id].js @@ -1,5 +1,6 @@ import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { pickDesignFields } from '../../../lib/custom-cards-fields.js'; import { syncCatalogCard, ensureOwnedRow } from './index.js'; export default async function handler(req, res) { @@ -28,7 +29,7 @@ export default async function handler(req, res) { } if (req.method === 'PUT') { - const f = pickFields(req.body); + const f = pickDesignFields(req.body); if (!f.name) { 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}, card_type = ${f.cardType}, rarity = ${f.rarity}, rules_text = ${f.rulesText}, actions = ${f.actions}, + flavor_quote = ${f.flavorQuote}, power = ${f.power}, toughness = ${f.toughness}, frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl}, + art_mode = ${f.artMode}, updated_at = CURRENT_TIMESTAMP WHERE id = ${designId} RETURNING * @@ -73,19 +76,3 @@ export default async function handler(req, res) { 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), - }; -} diff --git a/pages/api/custom-cards/index.js b/pages/api/custom-cards/index.js index 4841681..21f1565 100644 --- a/pages/api/custom-cards/index.js +++ b/pages/api/custom-cards/index.js @@ -1,5 +1,6 @@ import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { pickDesignFields } from '../../../lib/custom-cards-fields.js'; const CUSTOM_GAME = 'Custom'; const CUSTOM_SET = 'Designs'; @@ -14,8 +15,8 @@ export default async function handler(req, res) { if (req.method === 'GET') { const result = await sql` SELECT id, card_id, name, mana_cost, card_type, rarity, - rules_text, actions, power, toughness, frame_id, - artwork_url, created_at, updated_at + rules_text, actions, flavor_quote, power, toughness, + frame_id, artwork_url, art_mode, created_at, updated_at FROM custom_cards WHERE user_id = ${user.userId} 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) { - const f = pickFields(body); + const f = pickDesignFields(body); if (!f.name) return null; const inserted = await sql` INSERT INTO custom_cards (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 (${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity}, - ${f.rulesText}, ${f.actions}, ${f.power}, ${f.toughness}, - ${f.frameId}, ${f.artworkUrl}) + ${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power}, + ${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode}) RETURNING * `; const design = inserted.rows[0]; @@ -94,7 +80,10 @@ export async function syncCatalogCard(design, fields) { game: CUSTOM_GAME, manaCost: fields.manaCost, 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, }; diff --git a/pages/designer.js b/pages/designer.js index 45cdc1a..4881f2b 100644 --- a/pages/designer.js +++ b/pages/designer.js @@ -15,10 +15,12 @@ const BLANK_DESIGN = { rarity: 'common', rules_text: '', actions: '', + flavor_quote: '', power: '', toughness: '', frame_id: 'classic', artwork_url: '', + art_mode: 'framed', }; export default function Designer() { @@ -254,6 +256,27 @@ export default function Designer() { onChange={(e) => handleUpload(e.target.files?.[0])} />
+
+ {[ + { id: 'framed', label: 'Framed' }, + { id: 'fullart', label: 'Full Art' }, + ].map((mode) => ( + + ))} +
@@ -329,13 +352,13 @@ export default function Designer() {
- +