diff --git a/components/Layout.js b/components/Layout.js
index e7fe4c1..02e2006 100644
--- a/components/Layout.js
+++ b/components/Layout.js
@@ -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 }) {
+ ),
+ designer: (
+
)
};
return icons[iconName] || icons.grid;
diff --git a/components/designer/CardFrame.js b/components/designer/CardFrame.js
new file mode 100644
index 0000000..f9d9fb8
--- /dev/null
+++ b/components/designer/CardFrame.js
@@ -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 (
+
+ {/* Title bar */}
+
+
+ {design.name || 'Untitled Card'}
+
+
+
+
+ {/* Artwork window */}
+
+ {design.artwork_url ? (
+

+ ) : (
+
+
+
+ Upload artwork
+
+
+ )}
+
+
+ {/* Type line + rarity gem */}
+
+
+ {design.card_type || '— Type —'}
+
+ {/* Rarity gem straddles art/type boundary */}
+
+
+
+ {/* Rules text box */}
+
+ {design.rules_text && (
+
+ {design.rules_text}
+
+ )}
+ {design.rules_text && design.actions && (
+
+ )}
+ {design.actions && (
+
+ {design.actions}
+
+ )}
+ {!design.rules_text && !design.actions && (
+
+ Description & actions appear here
+
+ )}
+
+ {showPt && (
+
+ {design.power || '0'} / {design.toughness || '0'}
+
+ )}
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
+
+/**
+ * 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 (
+
+ {tokens.slice(0, 8).map((token, i) => (
+
+ {token}
+
+ ))}
+
+ );
+}
diff --git a/components/designer/frames.js b/components/designer/frames.js
new file mode 100644
index 0000000..e8f8eda
--- /dev/null
+++ b/components/designer/frames.js
@@ -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];
+}
diff --git a/migrations/1787600511000_create-custom-cards.js b/migrations/1787600511000_create-custom-cards.js
new file mode 100644
index 0000000..34431ba
--- /dev/null
+++ b/migrations/1787600511000_create-custom-cards.js
@@ -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`);
+};
diff --git a/package-lock.json b/package-lock.json
index 5958184..9f06dd6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index b2f236e..d6117fd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pages/api/custom-cards/[id].js b/pages/api/custom-cards/[id].js
new file mode 100644
index 0000000..a161bba
--- /dev/null
+++ b/pages/api/custom-cards/[id].js
@@ -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),
+ };
+}
diff --git a/pages/api/custom-cards/index.js b/pages/api/custom-cards/index.js
new file mode 100644
index 0000000..4841681
--- /dev/null
+++ b/pages/api/custom-cards/index.js
@@ -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
+ `;
+}
diff --git a/pages/api/custom-cards/upload-artwork.js b/pages/api/custom-cards/upload-artwork.js
new file mode 100644
index 0000000..58d6442
--- /dev/null
+++ b/pages/api/custom-cards/upload-artwork.js
@@ -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);
+ });
+}
diff --git a/pages/designer.js b/pages/designer.js
new file mode 100644
index 0000000..45cdc1a
--- /dev/null
+++ b/pages/designer.js
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ Card Designer
+
+
+ Fill in the details and watch your card come to life.
+
+
+
+
+
+
+
+
+
+ {message && (
+
+ {message.text}
+
+ )}
+
+
+ {/* ── Form column ─────────────────────────────── */}
+
+ {/* Frame picker */}
+
+
+ Frame
+
+
+ {FRAMES.map((frame) => (
+
+ ))}
+
+
+
+ {/* Artwork */}
+
+
+ {/* Details */}
+
+
+
+ {/* ── Live preview column ─────────────────────── */}
+
+
+
+ Live Preview
+
+
+
+ Saved designs appear in{' '}
+ My Designs{' '}
+ and your collection.
+
+
+
+
+
+
+ );
+}
+
+function Field({ label, hint, required, children }) {
+ return (
+
+ );
+}
diff --git a/pages/my-designs.js b/pages/my-designs.js
new file mode 100644
index 0000000..8847907
--- /dev/null
+++ b/pages/my-designs.js
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ My Designs
+
+
+ {designs.length} custom card{designs.length === 1 ? '' : 's'} · also visible in your collection
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Designs grid */}
+ {!loading && designs.length === 0 && !error && (
+
+
+
+ No designs yet
+
+
+ Create your first custom card in the designer.
+
+
+
+ )}
+
+
+ {designs.map((design) => (
+
+
router.push(`/designer?id=${design.id}`)}
+ role="link"
+ tabIndex={0}
+ onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
+ >
+
+
+
+
+ {design.name}
+
+
+ {design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/test/api/custom-cards.test.js b/test/api/custom-cards.test.js
new file mode 100644
index 0000000..3fcb298
--- /dev/null
+++ b/test/api/custom-cards.test.js
@@ -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);
+ });
+});