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 ? ( + {design.name + ) : ( +
+ + + + + + + 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 */} +
+

+ Artwork +

+ handleUpload(e.target.files?.[0])} + /> +
+ + {design.artwork_url && ( + + )} + + JPEG, PNG, or WebP · up to 5MB + +
+
+ + {/* Details */} +
+

+ Details +

+ +
+ + setField('name', e.target.value)} + placeholder="Emberwing Phoenix" + maxLength={60} + /> + + + setField('mana_cost', e.target.value)} + placeholder="2RR" + maxLength={24} + /> + +
+ +
+ + setField('card_type', e.target.value)} + placeholder="Creature — Phoenix" + maxLength={80} + /> + + +
+ {RARITIES.map((rarity) => ( + + ))} +
+
+
+ + +