feat(designer): add card designer with starter frames, live preview, and PNG export

- 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
This commit is contained in:
Randall Stillwell 2026-08-24 14:53:06 -05:00
parent f2ba333daf
commit 421c5e5ee5
12 changed files with 1553 additions and 0 deletions

View file

@ -209,6 +209,14 @@ function NavigationContent({ user, router, onItemClick }) {
router.pathname === '/decks' ||
router.pathname.startsWith('/deck/'),
},
{
name: 'Designer',
href: '/my-designs',
icon: 'designer',
active:
router.pathname === '/designer' ||
router.pathname === '/my-designs',
},
{
name: 'Scanner',
href: '/scanner',
@ -318,6 +326,11 @@ function NavigationContent({ user, router, onItemClick }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
designer: (
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
};
return icons[iconName] || icons.grid;

View file

@ -0,0 +1,316 @@
/* 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 &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>
</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>
);
}

View file

@ -0,0 +1,86 @@
/**
* Starter frame definitions for the card designer.
* Colors are concrete values (no CSS vars) so the rendered frame
* rasterizes faithfully during PNG export.
*/
export const FRAMES = [
{
id: 'classic',
name: 'Ember Classic',
description: 'Warm creature frame with gold trim',
palette: {
outer: '#2b1d12',
border: '#c9a227',
titleBar: '#513a20',
typeBar: '#5d4426',
textBox: '#e8dcc3',
artBacking: '#1a1108',
text: '#241a0e',
titleText: '#f5ead1',
accent: '#c9a227',
},
},
{
id: 'sorcery',
name: 'Azure Sorcery',
description: 'Cool spell frame with arcane blue',
palette: {
outer: '#101b2b',
border: '#6ea8dc',
titleBar: '#1d3a57',
typeBar: '#24466a',
textBox: '#dfe9f4',
artBacking: '#0a1220',
text: '#152232',
titleText: '#e3eefb',
accent: '#6ea8dc',
},
},
{
id: 'verdant',
name: 'Verdant Wilds',
description: 'Nature frame with deep green growth',
palette: {
outer: '#14210f',
border: '#8fbc6f',
titleBar: '#2c4420',
typeBar: '#36512a',
textBox: '#e4ecd8',
artBacking: '#0c1408',
text: '#1c2913',
titleText: '#eaf3de',
accent: '#8fbc6f',
},
},
{
id: 'void',
name: 'Void Artifact',
description: 'Neutral dark frame for anything',
palette: {
outer: '#17161a',
border: '#9d93b8',
titleBar: '#2e2b36',
typeBar: '#383442',
textBox: '#e6e3ee',
artBacking: '#0e0d11',
text: '#211f27',
titleText: '#ece9f4',
accent: '#9d93b8',
},
},
];
export const RARITIES = [
{ id: 'common', name: 'Common', color: '#9ca3af' },
{ id: 'uncommon', name: 'Uncommon', color: '#a8b6c8' },
{ id: 'rare', name: 'Rare', color: '#d4af37' },
{ id: 'mythic', name: 'Mythic', color: '#e0662f' },
];
export function getFrame(frameId) {
return FRAMES.find((f) => f.id === frameId) || FRAMES[0];
}
export function getRarity(rarityId) {
return RARITIES.find((r) => r.id === rarityId) || RARITIES[0];
}

View file

@ -0,0 +1,43 @@
/**
* Custom card designs (card designer feature).
*
* One row per user-designed card. Designer-specific fields live here
* (frame, artwork, actions); on save we also upsert a matching catalog
* row in `cards` (game='Custom') + a `user_cards` row so designed cards
* appear in My Cards, lists, and decks through the normal joins.
*/
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS custom_cards (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
card_id INTEGER REFERENCES cards(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
mana_cost VARCHAR(50),
card_type VARCHAR(255),
rarity VARCHAR(50),
rules_text TEXT,
actions TEXT,
power VARCHAR(10),
toughness VARCHAR(10),
frame_id VARCHAR(50) DEFAULT 'classic',
artwork_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_custom_cards_user_id
ON custom_cards(user_id)
`);
pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_custom_cards_card_id
ON custom_cards(card_id)
`);
};
export const down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS custom_cards CASCADE`);
};

7
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@neondatabase/serverless": "^1.1.0",
"bcryptjs": "^3.0.2",
"dotenv": "^17.2.1",
"html-to-image": "^1.11.13",
"ioredis": "^5.7.0",
"jsonwebtoken": "^9.0.2",
"next": "^16.2.6",
@ -6159,6 +6160,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",

View file

@ -30,6 +30,7 @@
"@neondatabase/serverless": "^1.1.0",
"bcryptjs": "^3.0.2",
"dotenv": "^17.2.1",
"html-to-image": "^1.11.13",
"ioredis": "^5.7.0",
"jsonwebtoken": "^9.0.2",
"next": "^16.2.6",

View file

@ -0,0 +1,91 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { syncCatalogCard, ensureOwnedRow } from './index.js';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const designId = parseInt(req.query.id, 10);
if (!Number.isInteger(designId)) {
return res.status(400).json({ error: 'Invalid design id' });
}
const found = await sql`
SELECT * FROM custom_cards
WHERE id = ${designId} AND user_id = ${user.userId}
`;
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Design not found' });
}
const existing = found.rows[0];
if (req.method === 'GET') {
return res.status(200).json({ design: existing });
}
if (req.method === 'PUT') {
const f = pickFields(req.body);
if (!f.name) {
return res.status(400).json({ error: 'Name is required' });
}
const updated = await sql`
UPDATE custom_cards SET
name = ${f.name}, mana_cost = ${f.manaCost},
card_type = ${f.cardType}, rarity = ${f.rarity},
rules_text = ${f.rulesText}, actions = ${f.actions},
power = ${f.power}, toughness = ${f.toughness},
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${designId}
RETURNING *
`;
const design = updated.rows[0];
const cardId = await syncCatalogCard(design, f);
if (cardId) {
await ensureOwnedRow(user.userId, cardId);
if (!design.card_id) {
const linked = await sql`
UPDATE custom_cards SET card_id = ${cardId}
WHERE id = ${designId} RETURNING *
`;
return res.status(200).json({ design: linked.rows[0] });
}
}
return res.status(200).json({ design });
}
if (req.method === 'DELETE') {
// The catalog twin stays (it may already live in lists/decks);
// removing the design row simply detaches future edits from it.
await sql`DELETE FROM custom_cards WHERE id = ${designId}`;
return res.status(200).json({ message: 'Design deleted' });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom card API error:', 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),
};
}

View file

@ -0,0 +1,134 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
const CUSTOM_GAME = 'Custom';
const CUSTOM_SET = 'Designs';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
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
FROM custom_cards
WHERE user_id = ${user.userId}
ORDER BY updated_at DESC
`;
return res.status(200).json({ designs: result.rows });
}
if (req.method === 'POST') {
const design = await createDesign(user.userId, req.body);
if (!design) {
return res.status(400).json({ error: 'Name is required' });
}
return res.status(201).json({ design });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom cards API error:', 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),
};
}
async function createDesign(userId, body) {
const f = pickFields(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)
VALUES
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
${f.rulesText}, ${f.actions}, ${f.power}, ${f.toughness},
${f.frameId}, ${f.artworkUrl})
RETURNING *
`;
const design = inserted.rows[0];
// Mirror into the shared catalog so the design shows up in My Cards,
// lists, and decks through the normal card joins.
const cardId = await syncCatalogCard(design, f);
if (cardId) {
await ensureOwnedRow(userId, cardId);
const linked = await sql`
UPDATE custom_cards SET card_id = ${cardId} WHERE id = ${design.id}
RETURNING *
`;
return linked.rows[0];
}
return design;
}
/** Create/update the catalog twin of a custom design; returns card id. */
export async function syncCatalogCard(design, fields) {
const values = {
name: fields.name,
setName: CUSTOM_SET,
setCode: 'DSGN',
rarity: fields.rarity,
game: CUSTOM_GAME,
manaCost: fields.manaCost,
cardType: fields.cardType,
oracleText: [fields.rulesText, fields.actions].filter(Boolean).join('\n\n') || null,
imageUrl: fields.artworkUrl,
};
if (design.card_id) {
const updated = await sql`
UPDATE cards SET
name = ${values.name}, set_name = ${values.setName},
set_code = ${values.setCode}, rarity = ${values.rarity},
mana_cost = ${values.manaCost}, card_type = ${values.cardType},
oracle_text = ${values.oracleText}, image_url = ${values.imageUrl},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${design.card_id}
RETURNING id
`;
if (updated.rows.length > 0) return updated.rows[0].id;
}
const inserted = await sql`
INSERT INTO cards (name, set_name, set_code, rarity, game,
mana_cost, card_type, oracle_text, image_url)
VALUES (${values.name}, ${values.setName}, ${values.setCode},
${values.rarity}, ${values.game}, ${values.manaCost},
${values.cardType}, ${values.oracleText}, ${values.imageUrl})
RETURNING id
`;
return inserted.rows[0].id;
}
/** Idempotently give the designer one copy of their own creation. */
export async function ensureOwnedRow(userId, cardId) {
await sql`
INSERT INTO user_cards (user_id, card_id, quantity)
VALUES (${userId}, ${cardId}, 1)
ON CONFLICT (user_id, card_id, is_foil)
DO NOTHING
`;
}

View file

@ -0,0 +1,122 @@
import { put } from '../../../lib/object-storage.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
export const config = {
api: {
bodyParser: {
sizeLimit: '5mb',
},
},
};
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
const { allowed, reset } = await checkUploadRateLimit(req, user.userId);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('multipart/form-data')) {
return res.status(400).json({ error: 'Content-Type must be multipart/form-data' });
}
const formData = await parseMultipartFormData(req);
const file = formData.artwork;
if (!file) {
return res.status(400).json({ error: 'No artwork file provided' });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return res.status(400).json({
error: 'Invalid file type. Please upload a JPEG, PNG, or WebP image.',
});
}
if (file.size > 5 * 1024 * 1024) {
return res.status(400).json({ error: 'File size must be less than 5MB' });
}
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type.split('/')[1];
const filename = `card-art/${user.userId}-${Date.now()}.${extension}`;
const blob = await put(filename, file.buffer, {
access: 'public',
contentType: file.type,
});
return res.status(200).json({ artwork_url: blob.url });
} catch (error) {
console.error('Artwork upload API error:', error);
return res.status(500).json({ error: 'Failed to upload artwork' });
}
}
async function parseMultipartFormData(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const boundary = req.headers['content-type'].split('boundary=')[1];
const parts = buffer.toString('binary').split(`--${boundary}`);
const formData = {};
for (const part of parts) {
if (part.includes('Content-Disposition: form-data')) {
const nameMatch = part.match(/name="([^"]+)"/);
const filenameMatch = part.match(/filename="([^"]+)"/);
const contentTypeMatch = part.match(/Content-Type: ([^\r\n]+)/);
if (nameMatch) {
const fieldName = nameMatch[1];
const headerEndIndex = part.indexOf('\r\n\r\n');
if (headerEndIndex !== -1) {
const content = part.substring(headerEndIndex + 4);
const contentBuffer = Buffer.from(content, 'binary');
if (filenameMatch && contentTypeMatch) {
formData[fieldName] = {
originalName: filenameMatch[1],
type: contentTypeMatch[1].trim(),
buffer: contentBuffer.slice(0, -2),
size: contentBuffer.length - 2,
};
} else {
formData[fieldName] = content.trim();
}
}
}
}
}
resolve(formData);
} catch (error) {
reject(error);
}
});
req.on('error', reject);
});
}

415
pages/designer.js Normal file
View file

@ -0,0 +1,415 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import CardPreview from '../components/designer/CardFrame';
import { FRAMES, RARITIES } from '../components/designer/frames';
import { Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
const BLANK_DESIGN = {
id: null,
name: '',
mana_cost: '',
card_type: '',
rarity: 'common',
rules_text: '',
actions: '',
power: '',
toughness: '',
frame_id: 'classic',
artwork_url: '',
};
export default function Designer() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [design, setDesign] = useState(BLANK_DESIGN);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState(null);
const cardRef = useRef(null);
const fileInputRef = useRef(null);
// Edit mode when ?id= is present
useEffect(() => {
if (!user || !router.query.id) return;
const loadDesign = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-cards/${router.query.id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
const data = await response.json();
setDesign({ ...BLANK_DESIGN, ...data.design });
} else {
setMessage({ kind: 'error', text: 'Design not found.' });
}
} catch {
setMessage({ kind: 'error', text: 'Failed to load design.' });
}
};
loadDesign();
}, [user, router.query.id]);
const setField = useCallback((field, value) => {
setDesign((prev) => ({ ...prev, [field]: value }));
}, []);
const handleUpload = async (file) => {
if (!file) return;
setUploading(true);
setMessage(null);
try {
const body = new FormData();
body.append('artwork', file);
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/custom-cards/upload-artwork', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body,
});
const data = await response.json();
if (response.ok && data.artwork_url) {
setField('artwork_url', data.artwork_url);
} else {
setMessage({ kind: 'error', text: data.error || 'Upload failed.' });
}
} catch {
setMessage({ kind: 'error', text: 'Upload failed. Please try again.' });
} finally {
setUploading(false);
}
};
const handleSave = async () => {
if (!design.name.trim()) {
setMessage({ kind: 'error', text: 'Give your card a title first.' });
return;
}
setSaving(true);
setMessage(null);
try {
const token = localStorage.getItem('auth_token');
const isNew = !design.id;
const response = await fetch(
isNew ? '/api/custom-cards' : `/api/custom-cards/${design.id}`,
{
method: isNew ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(design),
}
);
const data = await response.json();
if (response.ok) {
setDesign({ ...BLANK_DESIGN, ...data.design });
setMessage({
kind: 'success',
text: isNew ? 'Card saved to your designs!' : 'Changes saved.',
});
} else {
setMessage({ kind: 'error', text: data.error || 'Save failed.' });
}
} catch {
setMessage({ kind: 'error', text: 'Save failed. Please try again.' });
} finally {
setSaving(false);
}
};
const handleExportPng = async () => {
if (!cardRef.current) return;
setExporting(true);
try {
const { toPng } = await import('html-to-image');
const dataUrl = await toPng(cardRef.current, {
width: 420,
height: 588,
pixelRatio: 2,
cacheBust: true,
});
const link = document.createElement('a');
link.download = `${(design.name || 'card').replace(/[^a-z0-9-_ ]/gi, '').trim() || 'card'}.png`;
link.href = dataUrl;
link.click();
} catch {
setMessage({ kind: 'error', text: 'Export failed. Please try again.' });
} finally {
setExporting(false);
}
};
const handleNew = () => {
setDesign(BLANK_DESIGN);
setMessage(null);
router.replace('/designer', undefined, { shallow: true });
};
if (authLoading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
{/* Header */}
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
Card Designer
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
Fill in the details and watch your card come to life.
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleNew}>New</Button>
<Button variant="secondary" onClick={handleExportPng} disabled={exporting}>
{exporting ? 'Exporting…' : 'Download PNG'}
</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : design.id ? 'Save Changes' : 'Save Card'}
</Button>
</div>
</div>
{message && (
<div
className="glass-panel rounded-xl px-4 py-3 text-sm"
style={{ color: message.kind === 'error' ? '#f87171' : '#4ade80' }}
role="status"
>
{message.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_460px] gap-6">
{/* ── Form column ─────────────────────────────── */}
<div className="space-y-5 min-w-0">
{/* Frame picker */}
<section className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
Frame
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{FRAMES.map((frame) => (
<button
key={frame.id}
type="button"
onClick={() => setField('frame_id', frame.id)}
className={`rounded-xl p-3 text-left transition-all duration-200 border ${
design.frame_id === frame.id
? 'shadow-lg scale-[1.02]'
: 'opacity-80 hover:opacity-100'
}`}
style={{
backgroundColor: frame.palette.outer,
borderColor: design.frame_id === frame.id ? frame.palette.border : 'transparent',
borderWidth: 2,
}}
aria-pressed={design.frame_id === frame.id}
>
<div
className="w-full h-8 rounded mb-2"
style={{
background: `linear-gradient(135deg, ${frame.palette.titleBar}, ${frame.palette.textBox})`,
border: `1px solid ${frame.palette.border}`,
}}
/>
<span className="text-xs font-semibold block" style={{ color: frame.palette.titleText }}>
{frame.name}
</span>
</button>
))}
</div>
</section>
{/* Artwork */}
<section className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
Artwork
</h2>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => handleUpload(e.target.files?.[0])}
/>
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? 'Uploading…' : design.artwork_url ? 'Replace Image' : 'Upload Image'}
</Button>
{design.artwork_url && (
<Button variant="secondary" onClick={() => setField('artwork_url', '')}>
Remove
</Button>
)}
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
JPEG, PNG, or WebP · up to 5MB
</span>
</div>
</section>
{/* Details */}
<section className="glass-panel rounded-2xl p-5 space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
Details
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Title" required>
<input
className="input-field w-full"
value={design.name}
onChange={(e) => setField('name', e.target.value)}
placeholder="Emberwing Phoenix"
maxLength={60}
/>
</Field>
<Field label="Cost" hint='e.g. "2RR" or "{2}{R}{R}"'>
<input
className="input-field w-full"
value={design.mana_cost}
onChange={(e) => setField('mana_cost', e.target.value)}
placeholder="2RR"
maxLength={24}
/>
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Type">
<input
className="input-field w-full"
value={design.card_type}
onChange={(e) => setField('card_type', e.target.value)}
placeholder="Creature — Phoenix"
maxLength={80}
/>
</Field>
<Field label="Rarity">
<div className="flex gap-2">
{RARITIES.map((rarity) => (
<button
key={rarity.id}
type="button"
onClick={() => setField('rarity', rarity.id)}
className={`flex-1 rounded-lg px-2 py-2 text-xs font-semibold transition-all duration-200 ${
design.rarity === rarity.id ? 'scale-105 shadow-md' : 'opacity-70 hover:opacity-100'
}`}
style={{
backgroundColor: 'var(--bg-tertiary)',
color: design.rarity === rarity.id ? rarity.color : 'var(--text-secondary)',
borderBottom: `3px solid ${rarity.color}`,
}}
aria-pressed={design.rarity === rarity.id}
>
{rarity.name}
</button>
))}
</div>
</Field>
</div>
<Field label="Description / Flavor">
<textarea
className="input-field w-full resize-y"
rows={3}
value={design.rules_text}
onChange={(e) => setField('rules_text', e.target.value)}
placeholder="Flavor text or lore…"
maxLength={500}
/>
</Field>
<Field label="Actions / Abilities">
<textarea
className="input-field w-full resize-y"
rows={3}
value={design.actions}
onChange={(e) => setField('actions', e.target.value)}
placeholder={'Flying, haste\nWhen Emberwing enters the battlefield, it deals 2 damage to any target.'}
maxLength={800}
/>
</Field>
<details className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<summary className="cursor-pointer select-none">Power / Toughness (optional)</summary>
<div className="grid grid-cols-2 gap-4 mt-3">
<Field label="Power">
<input
className="input-field w-full"
value={design.power}
onChange={(e) => setField('power', e.target.value)}
placeholder="3"
maxLength={10}
/>
</Field>
<Field label="Toughness">
<input
className="input-field w-full"
value={design.toughness}
onChange={(e) => setField('toughness', e.target.value)}
placeholder="4"
maxLength={10}
/>
</Field>
</div>
</details>
</section>
</div>
{/* ── Live preview column ─────────────────────── */}
<div>
<div className="sticky top-8 glass-panel rounded-2xl p-6">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
Live Preview
</h2>
<CardPreview design={design} innerRef={cardRef} />
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-secondary)' }}>
Saved designs appear in{' '}
<Link href="/my-designs" style={{ color: 'var(--accent-ember)' }}>My Designs</Link>{' '}
and your collection.
</p>
</div>
</div>
</div>
</div>
</Layout>
);
}
function Field({ label, hint, required, children }) {
return (
<label className="block">
<span className="text-xs font-semibold mb-1.5 block" style={{ color: 'var(--text-primary)' }}>
{label}
{required && <span style={{ color: 'var(--accent-ember)' }}> *</span>}
{hint && (
<span className="font-normal ml-2" style={{ color: 'var(--text-secondary)' }}>
({hint})
</span>
)}
</span>
{children}
</label>
);
}

154
pages/my-designs.js Normal file
View file

@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import CardPreview from '../components/designer/CardFrame';
import { Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
export default function MyDesigns() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [designs, setDesigns] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
useEffect(() => {
if (!user) return undefined;
const loadDesigns = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/custom-cards', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
const data = await response.json();
setDesigns(data.designs);
} else {
setError('Failed to load your designs.');
}
} catch {
setError('Failed to load your designs.');
} finally {
setLoading(false);
}
};
loadDesigns();
return undefined;
}, [user]);
const handleDelete = async (id) => {
if (!window.confirm('Delete this design? The card will remain in your collection.')) return;
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-cards/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
setDesigns((prev) => prev.filter((d) => d.id !== id));
} else {
setError('Delete failed.');
}
} catch {
setError('Delete failed.');
}
};
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
</div>
</Layout>
);
}
return (
<Layout user={user}>
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
{/* Header */}
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
My Designs
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
{designs.length} custom card{designs.length === 1 ? '' : 's'} · also visible in your collection
</p>
</div>
<Button variant="primary" onClick={() => router.push('/designer')}>
+ New Design
</Button>
</div>
{error && (
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
{error}
</div>
)}
{/* Designs grid */}
{!loading && designs.length === 0 && !error && (
<div className="text-center py-20">
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
No designs yet
</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
Create your first custom card in the designer.
</p>
<Button variant="primary" size="lg" onClick={() => router.push('/designer')}>
Open Card Designer
</Button>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-5">
{designs.map((design) => (
<div key={design.id} className="glass-panel rounded-2xl p-4 flex flex-col gap-3">
<div
className="cursor-pointer"
onClick={() => router.push(`/designer?id=${design.id}`)}
role="link"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
>
<CardPreview design={design} maxWidth={280} />
</div>
<div className="min-w-0">
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{design.name}
</p>
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
</p>
</div>
<div className="flex gap-2 mt-auto">
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
Edit
</Button>
<Button variant="secondary" size="sm" onClick={() => handleDelete(design.id)}>
Delete
</Button>
</div>
</div>
))}
</div>
</div>
</Layout>
);
}

View file

@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
vi.mock('../../lib/permission-middleware', () => ({
getUserFromRequest: vi.fn(),
}));
import { sql } from '../../lib/sql.js';
import { getUserFromRequest } from '../../lib/permission-middleware';
import handler from '../../pages/api/custom-cards/index.js';
import itemHandler from '../../pages/api/custom-cards/[id].js';
function createRes() {
const res = {
statusCode: 200,
body: null,
status(code) {
res.statusCode = code;
return res;
},
json(data) {
res.body = data;
return res;
},
};
return res;
}
describe('GET /api/custom-cards', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('requires authentication', async () => {
getUserFromRequest.mockResolvedValue(null);
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(401);
expect(sql).not.toHaveBeenCalled();
});
it('returns the user designs', async () => {
const design = { id: 5, name: 'Emberwing', card_id: 77 };
sql.mockResolvedValueOnce({ rows: [design] });
const res = createRes();
await handler({ method: 'GET' }, res);
expect(res.statusCode).toBe(200);
expect(res.body.designs).toEqual([design]);
});
});
describe('POST /api/custom-cards', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('rejects designs without a name', async () => {
const res = createRes();
await handler(
{ method: 'POST', body: { name: ' ', rarity: 'rare' } },
res
);
expect(res.statusCode).toBe(400);
expect(sql).not.toHaveBeenCalled();
});
it('creates the design, mirrors a catalog card, and links ownership', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: null, name: 'Emberwing Phoenix' }],
}) // INSERT custom_cards
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // INSERT cards twin
.mockResolvedValueOnce({ rows: [] }) // INSERT user_cards
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: 77, name: 'Emberwing Phoenix' }],
}); // link card_id back onto the design
const res = createRes();
await handler(
{
method: 'POST',
body: {
name: 'Emberwing Phoenix',
mana_cost: '2RR',
rarity: 'rare',
frameId: 'classic',
description: 'A phoenix reborn.',
actions: 'Flying, haste',
},
},
res
);
expect(res.statusCode).toBe(201);
expect(res.body.design.card_id).toBe(77);
// 4 statements: insert design, insert catalog twin, own row, link back
expect(sql).toHaveBeenCalledTimes(4);
});
});
describe('/api/custom-cards/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
sql.mockResolvedValue({ rows: [] });
});
it('returns 404 when the design belongs to someone else', async () => {
sql.mockResolvedValueOnce({ rows: [] }); // ownership lookup misses
const res = createRes();
await itemHandler({ method: 'GET', query: { id: '12' } }, res);
expect(res.statusCode).toBe(404);
});
it('rejects invalid ids', async () => {
const res = createRes();
await itemHandler({ method: 'GET', query: { id: 'abc' } }, res);
expect(res.statusCode).toBe(400);
});
it('updates the design and keeps the catalog twin in sync', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, user_id: 1, card_id: 77, name: 'Old Name' }],
}) // ownership lookup hits
.mockResolvedValueOnce({
rows: [{ id: 10, card_id: 77, name: 'New Name' }],
}) // UPDATE custom_cards
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // UPDATE cards twin
.mockResolvedValueOnce({ rows: [] }); // ensureOwnedRow (no-op if exists)
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '10' }, body: { name: 'New Name' } },
res
);
expect(res.statusCode).toBe(200);
expect(res.body.design.name).toBe('New Name');
// 4 statements: ownership, update design, update twin, own row
expect(sql).toHaveBeenCalledTimes(4);
});
it('deletes only the design row', async () => {
sql
.mockResolvedValueOnce({
rows: [{ id: 10, user_id: 1, card_id: 77 }],
}) // ownership lookup
.mockResolvedValueOnce({ rows: [] }); // DELETE custom_cards
const res = createRes();
await itemHandler({ method: 'DELETE', query: { id: '10' } }, res);
expect(res.statusCode).toBe(200);
expect(sql).toHaveBeenCalledTimes(2);
});
});