diff --git a/components/designer/CardFrame.js b/components/designer/CardFrame.js index c2c7f06..a8d1cd1 100644 --- a/components/designer/CardFrame.js +++ b/components/designer/CardFrame.js @@ -1,4 +1,4 @@ -/* eslint-disable @next/next/no-img-element -- Artwork comes from MinIO CDN / data URLs; next/image is out of scope for the designer canvas. */ +/* eslint-disable @next/next/no-img-element -- Artwork/symbols come 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'; @@ -8,23 +8,31 @@ import { getFrame, getRarity } from './frames'; export const CARD_W = 420; export const CARD_H = 588; +/** Resolve the active palette: a custom frame's palette when linked, + * otherwise the starter frame's. */ +export function resolvePalette(design) { + if (design.custom_frame?.palette) { + return design.custom_frame.palette; + } + return getFrame(design.frame_id).palette; +} + /** * 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 }) { +export default function CardFrame({ design, innerRef, symbols }) { if (design.art_mode === 'fullart') { - return ; + return ; } - return ; + return ; } /* ─────────────────────────── framed layout ─────────────────────────── */ -function FramedCard({ design, innerRef }) { - const frame = getFrame(design.frame_id); - const p = frame.palette; +function FramedCard({ design, innerRef, symbols }) { + const p = resolvePalette(design); const showPt = Boolean(design.power || design.toughness); return ( @@ -73,7 +81,7 @@ function FramedCard({ design, innerRef }) { > {design.name || 'Untitled Card'} - + {/* Artwork window */} @@ -136,9 +144,8 @@ function FramedCard({ design, innerRef }) { /* ─────────────────────────── full-art layout ───────────────────────── */ -function FullArtCard({ design, innerRef }) { - const frame = getFrame(design.frame_id); - const p = frame.palette; +function FullArtCard({ design, innerRef, symbols }) { + const p = resolvePalette(design); const showPt = Boolean(design.power || design.toughness); const hasArt = Boolean(design.artwork_url); @@ -208,7 +215,7 @@ function FullArtCard({ design, innerRef }) { > {design.name || 'Untitled Card'} - + {/* Bottom scrim: type line, text, P/T */} @@ -517,7 +524,7 @@ function starPath(s) { * 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 }) { +export function CardPreview({ design, innerRef, maxWidth = 420, symbols }) { const containerRef = useRef(null); const [scale, setScale] = useState(1); @@ -546,7 +553,7 @@ export function CardPreview({ design, innerRef, maxWidth = 420 }) { }} >
- +
@@ -556,9 +563,10 @@ export function CardPreview({ design, innerRef, maxWidth = 420 }) { /** * 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. + * symbols for custom games. When `symbols` maps a token to an icon URL, + * the icon renders inside the pip instead of text. */ -export function ManaPips({ cost, accent }) { +export function ManaPips({ cost, accent, symbols }) { if (!cost || !cost.trim()) return null; const tokens = /\{/.test(cost) @@ -567,29 +575,51 @@ export function ManaPips({ cost, accent }) { if (tokens.length === 0) return null; + const iconFor = (token) => { + if (!symbols) return null; + return ( + symbols[token] || + symbols[token.toLowerCase()] || + symbols[token.toUpperCase()] || + null + ); + }; + return ( - {tokens.slice(0, 8).map((token, i) => ( - - {token} - - ))} + {tokens.slice(0, 8).map((token, i) => { + const icon = iconFor(token); + return ( + + {icon ? ( + {token} + ) : ( + token + )} + + ); + })} ); } diff --git a/lib/custom-cards-fields.js b/lib/custom-cards-fields.js index 2c244c2..cf638bd 100644 --- a/lib/custom-cards-fields.js +++ b/lib/custom-cards-fields.js @@ -28,5 +28,6 @@ export function pickDesignFields(body = {}) { artMode: artMode === 'fullart' ? 'fullart' : 'framed', gameTarget, customGameId: int(body.custom_game_id) || int(body.customGameId), + customFrameId: int(body.custom_frame_id) || int(body.customFrameId), }; } diff --git a/lib/frame-palette.js b/lib/frame-palette.js new file mode 100644 index 0000000..15ccb03 --- /dev/null +++ b/lib/frame-palette.js @@ -0,0 +1,38 @@ +/** + * Palette contract for card frames. Starter and custom frames share the + * same slots; CardFrame resolves a palette from these keys. + */ +export const PALETTE_SLOTS = [ + { key: 'outer', label: 'Card body' }, + { key: 'border', label: 'Frame border' }, + { key: 'titleBar', label: 'Title bar' }, + { key: 'typeBar', label: 'Type bar' }, + { key: 'textBox', label: 'Text box' }, + { key: 'artBacking', label: 'Art backing' }, + { key: 'text', label: 'Body text' }, + { key: 'titleText', label: 'Title text' }, + { key: 'accent', label: 'Accent' }, +]; + +const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +/** + * Validate/normalize a palette object. Returns { palette } with all nine + * slots as lowercase hex, or { error } when a slot is missing/invalid. + */ +export function validatePalette(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return { error: 'Palette must be an object' }; + } + + const palette = {}; + for (const { key } of PALETTE_SLOTS) { + const value = input[key]; + if (typeof value !== 'string' || !HEX_RE.test(value.trim())) { + return { error: `Invalid or missing color for "${key}"` }; + } + palette[key] = value.trim().toLowerCase(); + } + + return { palette }; +} diff --git a/migrations/1787700511000_add-custom-frames-symbols.js b/migrations/1787700511000_add-custom-frames-symbols.js new file mode 100644 index 0000000..0a53bfd --- /dev/null +++ b/migrations/1787700511000_add-custom-frames-symbols.js @@ -0,0 +1,56 @@ +/** + * Phase 3 — user-created frames and cost symbols. + * + * custom_frames: private per-user frames. `palette` holds the full slot + * map (outer, border, titleBar, typeBar, textBox, artBacking, text, + * titleText, accent) as JSONB so new slots stay additive. + * + * custom_symbols: user-uploaded cost icons keyed by a short code used in + * mana cost strings ({F}, {2}{F}...). + * + * custom_cards.custom_frame_id: set when a design uses a custom frame; + * NULL means the starter frame in `frame_id` applies. + */ +export const up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS custom_frames ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + palette JSONB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_custom_frames_user_name UNIQUE (user_id, name) + ) + `); + + pgm.sql(` + CREATE TABLE IF NOT EXISTS custom_symbols ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code VARCHAR(10) NOT NULL, + image_url TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_custom_symbols_user_code UNIQUE (user_id, code) + ) + `); + + pgm.sql(` + ALTER TABLE custom_cards + ADD COLUMN IF NOT EXISTS custom_frame_id INTEGER REFERENCES custom_frames(id) ON DELETE SET NULL + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_custom_cards_custom_frame_id + ON custom_cards(custom_frame_id) + `); +}; + +export const down = (pgm) => { + pgm.sql(` + ALTER TABLE custom_cards DROP COLUMN IF EXISTS custom_frame_id + `); + + pgm.sql(`DROP TABLE IF EXISTS custom_symbols CASCADE`); + pgm.sql(`DROP TABLE IF EXISTS custom_frames CASCADE`); +}; diff --git a/pages/api/custom-cards/[id].js b/pages/api/custom-cards/[id].js index dffac09..f1bad78 100644 --- a/pages/api/custom-cards/[id].js +++ b/pages/api/custom-cards/[id].js @@ -16,13 +16,22 @@ export default async function handler(req, res) { } const found = await sql` - SELECT * FROM custom_cards - WHERE id = ${designId} AND user_id = ${user.userId} + SELECT c.*, f.name AS frame_name, f.palette AS frame_palette + FROM custom_cards c + LEFT JOIN custom_frames f ON f.id = c.custom_frame_id + WHERE c.id = ${designId} AND c.user_id = ${user.userId} `; if (found.rows.length === 0) { return res.status(404).json({ error: 'Design not found' }); } - const existing = found.rows[0]; + const { frame_name, frame_palette, ...row } = found.rows[0]; + const existing = { + ...row, + custom_frame: + frame_name != null + ? { id: row.custom_frame_id, name: frame_name, palette: frame_palette } + : null, + }; if (req.method === 'GET') { return res.status(200).json({ design: existing }); @@ -44,6 +53,7 @@ export default async function handler(req, res) { frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl}, art_mode = ${f.artMode}, game_target = ${f.gameTarget}, custom_game_id = ${f.customGameId}, + custom_frame_id = ${f.customFrameId}, updated_at = CURRENT_TIMESTAMP WHERE id = ${designId} RETURNING * diff --git a/pages/api/custom-cards/index.js b/pages/api/custom-cards/index.js index cc4e9f5..145b331 100644 --- a/pages/api/custom-cards/index.js +++ b/pages/api/custom-cards/index.js @@ -19,13 +19,24 @@ export default async function handler(req, res) { c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness, c.frame_id, c.artwork_url, c.art_mode, c.game_target, c.custom_game_id, g.name AS custom_game_name, + f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette, c.created_at, c.updated_at FROM custom_cards c LEFT JOIN custom_games g ON g.id = c.custom_game_id + LEFT JOIN custom_frames f ON f.id = c.custom_frame_id WHERE c.user_id = ${user.userId} ORDER BY c.updated_at DESC `; - return res.status(200).json({ designs: result.rows }); + const designs = result.rows.map((row) => ({ + ...row, + custom_frame: + row.frame_pk != null + ? { id: row.frame_pk, name: row.frame_name, palette: row.frame_palette } + : null, + })); + return res.status(200).json({ + designs: designs.map(({ frame_pk, frame_name, frame_palette, ...rest }) => rest), + }); } if (req.method === 'POST') { @@ -53,12 +64,12 @@ async function createDesign(userId, body) { INSERT INTO custom_cards (user_id, name, mana_cost, card_type, rarity, rules_text, actions, flavor_quote, power, toughness, frame_id, artwork_url, - art_mode, game_target, custom_game_id) + art_mode, game_target, custom_game_id, custom_frame_id) VALUES (${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity}, ${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power}, ${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode}, - ${f.gameTarget}, ${f.customGameId}) + ${f.gameTarget}, ${f.customGameId}, ${f.customFrameId}) RETURNING * `; const design = inserted.rows[0]; diff --git a/pages/api/custom-frames/[id].js b/pages/api/custom-frames/[id].js new file mode 100644 index 0000000..cae7da6 --- /dev/null +++ b/pages/api/custom-frames/[id].js @@ -0,0 +1,72 @@ +import { sql } from '../../../lib/sql.js'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { validatePalette } from '../../../lib/frame-palette.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 frameId = parseInt(req.query.id, 10); + if (!Number.isInteger(frameId)) { + return res.status(400).json({ error: 'Invalid frame id' }); + } + + const found = await sql` + SELECT * FROM custom_frames + WHERE id = ${frameId} AND user_id = ${user.userId} + `; + if (found.rows.length === 0) { + return res.status(404).json({ error: 'Frame not found' }); + } + + if (req.method === 'GET') { + return res.status(200).json({ frame: found.rows[0] }); + } + + if (req.method === 'PUT') { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name || name.length > 100) { + return res.status(400).json({ error: 'Frame name is required (100 characters max)' }); + } + + const { palette, error } = validatePalette(req.body?.palette); + if (error) { + return res.status(400).json({ error }); + } + + const clash = await sql` + SELECT id FROM custom_frames + WHERE user_id = ${user.userId} + AND lower(name) = ${name.toLowerCase()} + AND id <> ${frameId} + `; + if (clash.rows.length > 0) { + return res.status(409).json({ error: 'You already have a frame with that name' }); + } + + const updated = await sql` + UPDATE custom_frames SET + name = ${name}, palette = ${sql.json(palette)}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${frameId} + RETURNING id, name, palette, created_at, updated_at + `; + return res.status(200).json({ frame: updated.rows[0] }); + } + + if (req.method === 'DELETE') { + // Designs using this frame fall back to their starter frame_id + // (custom_frame_id drops to NULL via FK). + await sql`DELETE FROM custom_frames WHERE id = ${frameId}`; + return res.status(200).json({ message: 'Frame deleted' }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (error) { + console.error('Custom frame API error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/custom-frames/index.js b/pages/api/custom-frames/index.js new file mode 100644 index 0000000..dc4171b --- /dev/null +++ b/pages/api/custom-frames/index.js @@ -0,0 +1,54 @@ +import { sql } from '../../../lib/sql.js'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { validatePalette } from '../../../lib/frame-palette.js'; + +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, name, palette, created_at, updated_at + FROM custom_frames + WHERE user_id = ${user.userId} + ORDER BY name ASC + `; + return res.status(200).json({ frames: result.rows }); + } + + if (req.method === 'POST') { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + if (!name || name.length > 100) { + return res.status(400).json({ error: 'Frame name is required (100 characters max)' }); + } + + const { palette, error } = validatePalette(req.body?.palette); + if (error) { + return res.status(400).json({ error }); + } + + const clash = await sql` + SELECT id FROM custom_frames + WHERE user_id = ${user.userId} AND lower(name) = ${name.toLowerCase()} + `; + if (clash.rows.length > 0) { + return res.status(409).json({ error: 'You already have a frame with that name' }); + } + + const inserted = await sql` + INSERT INTO custom_frames (user_id, name, palette) + VALUES (${user.userId}, ${name}, ${sql.json(palette)}) + RETURNING id, name, palette, created_at, updated_at + `; + return res.status(201).json({ frame: inserted.rows[0] }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (error) { + console.error('Custom frames API error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/custom-symbols/[id].js b/pages/api/custom-symbols/[id].js new file mode 100644 index 0000000..5cb8ae1 --- /dev/null +++ b/pages/api/custom-symbols/[id].js @@ -0,0 +1,41 @@ +import { del } from '../../../lib/object-storage.js'; +import { sql } from '../../../lib/sql.js'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; + +export default async function handler(req, res) { + try { + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const symbolId = parseInt(req.query.id, 10); + if (!Number.isInteger(symbolId)) { + return res.status(400).json({ error: 'Invalid symbol id' }); + } + + if (req.method !== 'DELETE') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const found = await sql` + SELECT id, image_url FROM custom_symbols + WHERE id = ${symbolId} AND user_id = ${user.userId} + `; + if (found.rows.length === 0) { + return res.status(404).json({ error: 'Symbol not found' }); + } + + try { + await del(found.rows[0].image_url); + } catch (blobError) { + console.warn('Failed to delete symbol image:', blobError); + } + + await sql`DELETE FROM custom_symbols WHERE id = ${symbolId}`; + return res.status(200).json({ message: 'Symbol deleted' }); + } catch (error) { + console.error('Custom symbol API error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/custom-symbols/index.js b/pages/api/custom-symbols/index.js new file mode 100644 index 0000000..aa60f1a --- /dev/null +++ b/pages/api/custom-symbols/index.js @@ -0,0 +1,158 @@ +import { put, del } from '../../../lib/object-storage.js'; +import { sql } from '../../../lib/sql.js'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; +import { checkUploadRateLimit } from '../../../lib/rate-limit.js'; + +export const config = { + api: { + bodyParser: { + sizeLimit: '2mb', + }, + }, +}; + +const ALLOWED_TYPES = ['image/png', 'image/webp', 'image/svg+xml']; +const CODE_RE = /^[A-Za-z0-9]{1,10}$/; + +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, code, image_url, created_at + FROM custom_symbols + WHERE user_id = ${user.userId} + ORDER BY code ASC + `; + return res.status(200).json({ symbols: result.rows }); + } + + if (req.method === 'POST') { + 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.' }); + } + + 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 code = (formData.code || '').trim(); + const file = formData.image; + + if (!CODE_RE.test(code)) { + return res.status(400).json({ + error: 'Symbol code must be 1-10 letters or numbers (e.g. F, E, 10)', + }); + } + if (!file) { + return res.status(400).json({ error: 'No symbol image provided' }); + } + if (!ALLOWED_TYPES.includes(file.type)) { + return res.status(400).json({ + error: 'Invalid file type. Please upload a PNG, WebP, or SVG image.', + }); + } + if (file.size > 2 * 1024 * 1024) { + return res.status(400).json({ error: 'File size must be less than 2MB' }); + } + + // Replace any existing symbol with the same code. + const existing = await sql` + SELECT id, image_url FROM custom_symbols + WHERE user_id = ${user.userId} AND code = ${code} + `; + if (existing.rows.length > 0) { + try { + await del(existing.rows[0].image_url); + } catch (blobError) { + console.warn('Failed to delete old symbol image:', blobError); + } + } + + const ext = file.type === 'image/svg+xml' ? 'svg' : file.type.split('/')[1]; + const filename = `symbols/${user.userId}-${code.toLowerCase()}-${Date.now()}.${ext}`; + const blob = await put(filename, file.buffer, { + access: 'public', + contentType: file.type, + }); + + const saved = await sql` + INSERT INTO custom_symbols (user_id, code, image_url) + VALUES (${user.userId}, ${code}, ${blob.url}) + ON CONFLICT (user_id, code) + DO UPDATE SET image_url = ${blob.url}, created_at = CURRENT_TIMESTAMP + RETURNING id, code, image_url, created_at + `; + + return res.status(existing.rows.length > 0 ? 200 : 201).json({ symbol: saved.rows[0] }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (error) { + console.error('Custom symbols API error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} + +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 index 77dcf2b..39a7e56 100644 --- a/pages/designer.js +++ b/pages/designer.js @@ -2,9 +2,10 @@ 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 CardFrame, { CardPreview } from '../components/designer/CardFrame'; +import { FRAMES, RARITIES, getFrame } from '../components/designer/frames'; import { EXISTING_GAMES } from '../lib/designer-games.js'; +import { PALETTE_SLOTS } from '../lib/frame-palette.js'; import { Button } from '../components/ui'; import { useAuth } from '../lib/use-auth'; @@ -24,8 +25,13 @@ const BLANK_DESIGN = { art_mode: 'framed', game_target: 'custom', custom_game_id: null, + custom_frame_id: null, }; +const BLANK_PALETTE = Object.fromEntries( + PALETTE_SLOTS.map(({ key }) => [key, getFrame('classic').palette[key]]) +); + export default function Designer() { const router = useRouter(); const { user, loading: authLoading } = useAuth(); @@ -41,6 +47,15 @@ export default function Designer() { const [newGameName, setNewGameName] = useState(''); const [creatingGame, setCreatingGame] = useState(false); + // Custom frames + symbols + const [frames, setFrames] = useState([]); + const [symbols, setSymbols] = useState([]); + const [frameEditor, setFrameEditor] = useState(null); // { id, name, palette } | null + const [savingFrame, setSavingFrame] = useState(false); + const [symbolCode, setSymbolCode] = useState(''); + const symbolFileRef = useRef(null); + const [uploadingSymbol, setUploadingSymbol] = useState(false); + const cardRef = useRef(null); const fileInputRef = useRef(null); @@ -61,9 +76,37 @@ export default function Designer() { } }, [authHeaders]); + const loadFrames = useCallback(async () => { + try { + const response = await fetch('/api/custom-frames', { headers: authHeaders() }); + if (response.ok) { + const data = await response.json(); + setFrames(data.frames); + } + } catch { + // Non-fatal — starter frames still work. + } + }, [authHeaders]); + + const loadSymbols = useCallback(async () => { + try { + const response = await fetch('/api/custom-symbols', { headers: authHeaders() }); + if (response.ok) { + const data = await response.json(); + setSymbols(data.symbols); + } + } catch { + // Non-fatal — text pips still work. + } + }, [authHeaders]); + useEffect(() => { - if (user) loadGames(); // eslint-disable-line react-hooks/set-state-in-effect -- fetch-driven reload via async loadGames - }, [user, loadGames]); + if (user) { + loadGames(); // eslint-disable-line react-hooks/set-state-in-effect -- fetch-driven reload via async loadGames + loadFrames(); + loadSymbols(); + } + }, [user, loadGames, loadFrames, loadSymbols]); const handleCreateGame = async () => { const name = newGameName.trim(); @@ -95,6 +138,124 @@ export default function Designer() { } }; + const openNewFrameEditor = () => { + const base = getFrame(design.frame_id).palette; + setFrameEditor({ id: null, name: '', palette: { ...base } }); + }; + + const openEditFrameEditor = (frame) => { + setFrameEditor({ id: frame.id, name: frame.name, palette: { ...frame.palette } }); + }; + + const handleSaveFrame = async () => { + if (!frameEditor) return; + const name = frameEditor.name.trim(); + if (!name) { + setMessage({ kind: 'error', text: 'Give the frame a name first.' }); + return; + } + setSavingFrame(true); + try { + const isNew = !frameEditor.id; + const response = await fetch( + isNew ? '/api/custom-frames' : `/api/custom-frames/${frameEditor.id}`, + { + method: isNew ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ name, palette: frameEditor.palette }), + } + ); + const data = await response.json(); + if (response.ok) { + setFrames((prev) => + isNew + ? [...prev, data.frame].sort((a, b) => a.name.localeCompare(b.name)) + : prev.map((f) => (f.id === data.frame.id ? data.frame : f)) + ); + setDesign((prev) => ({ ...prev, custom_frame_id: data.frame.id })); + setFrameEditor(null); + setMessage({ kind: 'success', text: `Frame "${data.frame.name}" saved.` }); + } else { + setMessage({ kind: 'error', text: data.error || 'Could not save frame.' }); + } + } catch { + setMessage({ kind: 'error', text: 'Could not save frame.' }); + } finally { + setSavingFrame(false); + } + }; + + const handleDeleteFrame = async (frameId) => { + if (!window.confirm('Delete this frame? Cards using it fall back to their starter frame.')) return; + try { + const response = await fetch(`/api/custom-frames/${frameId}`, { + method: 'DELETE', + headers: authHeaders(), + }); + if (response.ok) { + setFrames((prev) => prev.filter((f) => f.id !== frameId)); + setDesign((prev) => + prev.custom_frame_id === frameId ? { ...prev, custom_frame_id: null } : prev + ); + if (frameEditor?.id === frameId) setFrameEditor(null); + } else { + setMessage({ kind: 'error', text: 'Could not delete frame.' }); + } + } catch { + setMessage({ kind: 'error', text: 'Could not delete frame.' }); + } + }; + + const handleSymbolUpload = async (file) => { + const code = symbolCode.trim(); + if (!file || !code) return; + setUploadingSymbol(true); + try { + const body = new FormData(); + body.append('code', code); + body.append('image', file); + const response = await fetch('/api/custom-symbols', { + method: 'POST', + headers: authHeaders(), + body, + }); + const data = await response.json(); + if (response.ok) { + setSymbols((prev) => { + const next = prev.filter((s) => s.code !== data.symbol.code); + return [...next, data.symbol].sort((a, b) => a.code.localeCompare(b.code)); + }); + setSymbolCode(''); + setMessage({ kind: 'success', text: `Symbol {${data.symbol.code}} saved.` }); + } else { + setMessage({ kind: 'error', text: data.error || 'Symbol upload failed.' }); + } + } catch { + setMessage({ kind: 'error', text: 'Symbol upload failed.' }); + } finally { + setUploadingSymbol(false); + if (symbolFileRef.current) symbolFileRef.current.value = ''; + } + }; + + const handleDeleteSymbol = async (symbolId) => { + try { + const response = await fetch(`/api/custom-symbols/${symbolId}`, { + method: 'DELETE', + headers: authHeaders(), + }); + if (response.ok) { + setSymbols((prev) => prev.filter((s) => s.id !== symbolId)); + } else { + setMessage({ kind: 'error', text: 'Could not delete symbol.' }); + } + } catch { + setMessage({ kind: 'error', text: 'Could not delete symbol.' }); + } + }; + + const symbolsMap = Object.fromEntries(symbols.map((s) => [s.code, s.image_url])); + // Edit mode when ?id= is present useEffect(() => { if (!user || !router.query.id) return; @@ -387,39 +548,223 @@ export default function Designer() { {/* Frame picker */}
-

- Frame -

+
+

+ Frame +

+ +
- {FRAMES.map((frame) => ( - + ); + })} + {frames.map((frame) => { + const p = frame.palette; + const active = design.custom_frame_id === frame.id; + return ( +
+ + +
+ ); + })} +
+ + {/* Frame editor */} + {frameEditor && ( +
+
+
+
+ + setFrameEditor((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="Frame name…" + maxLength={100} + /> + + +
+
+ {PALETTE_SLOTS.map(({ key, label }) => ( + + ))} +
+ {frameEditor.id && ( + + )} +
+
+

+ Live frame preview +

+ +
+
+
+ )} +
+ + {/* Cost symbols */} +
+

+ Cost Symbols +

+

+ Upload small icons and use them in the Cost field as {'{CODE}'} or plain letters — e.g.{' '} + {'2{F}{F}'}. +

+ + {symbols.length > 0 && ( +
+ {symbols.map((symbol) => ( + + {/* eslint-disable-next-line @next/next/no-img-element -- CDN icon */} + {symbol.code} + {symbol.code} + - - ))} + ))} +
+ )} + + handleSymbolUpload(e.target.files?.[0])} + /> +
+ setSymbolCode(e.target.value)} + placeholder="Code (F)" + maxLength={10} + /> + + + PNG, WebP, or SVG · up to 2MB · re-uploading a code replaces it +
@@ -597,7 +942,7 @@ export default function Designer() {

Live Preview

- +

Saved designs appear in{' '} My Designs{' '} diff --git a/test/api/custom-cards.test.js b/test/api/custom-cards.test.js index 4d1d26e..ce42978 100644 --- a/test/api/custom-cards.test.js +++ b/test/api/custom-cards.test.js @@ -43,7 +43,7 @@ describe('GET /api/custom-cards', () => { expect(sql).not.toHaveBeenCalled(); }); - it('returns the user designs', async () => { + it('returns the user designs with resolved custom frames', async () => { const design = { id: 5, name: 'Emberwing', card_id: 77 }; sql.mockResolvedValueOnce({ rows: [design] }); const res = createRes(); @@ -51,7 +51,7 @@ describe('GET /api/custom-cards', () => { await handler({ method: 'GET' }, res); expect(res.statusCode).toBe(200); - expect(res.body.designs).toEqual([design]); + expect(res.body.designs).toEqual([{ ...design, custom_frame: null }]); }); }); diff --git a/test/api/custom-frames.test.js b/test/api/custom-frames.test.js new file mode 100644 index 0000000..a33d7a1 --- /dev/null +++ b/test/api/custom-frames.test.js @@ -0,0 +1,158 @@ +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-frames/index.js'; +import itemHandler from '../../pages/api/custom-frames/[id].js'; +import { validatePalette, PALETTE_SLOTS } from '../../lib/frame-palette.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; +} + +const GOOD_PALETTE = Object.fromEntries( + PALETTE_SLOTS.map(({ key }) => [key, '#123456']) +); + +describe('validatePalette', () => { + it('accepts and normalizes a complete palette', () => { + const { palette, error } = validatePalette( + Object.fromEntries(PALETTE_SLOTS.map(({ key }) => [key, '#ABCDEF'])) + ); + expect(error).toBeUndefined(); + expect(palette.outer).toBe('#abcdef'); + }); + + it('rejects missing slots and bad colors', () => { + expect(validatePalette({}).error).toBeTruthy(); + const missing = { ...GOOD_PALETTE }; + delete missing.accent; + expect(validatePalette(missing).error).toBeTruthy(); + expect(validatePalette({ ...GOOD_PALETTE, border: 'red' }).error).toBeTruthy(); + }); +}); + +describe('/api/custom-frames', () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); + sql.mockResolvedValue({ rows: [] }); + sql.json = vi.fn((v) => v); + }); + + 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('rejects invalid palettes on create', async () => { + const res = createRes(); + + await handler( + { method: 'POST', body: { name: 'My Frame', palette: { outer: 'nope' } } }, + res + ); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain('Invalid or missing color'); + }); + + it('rejects duplicate frame names', async () => { + sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits + const res = createRes(); + + await handler( + { method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } }, + res + ); + + expect(res.statusCode).toBe(409); + }); + + it('creates a frame with the normalized palette', async () => { + sql + .mockResolvedValueOnce({ rows: [] }) // clash lookup misses + .mockResolvedValueOnce({ + rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE }], + }); + + const res = createRes(); + await handler( + { method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } }, + res + ); + + expect(res.statusCode).toBe(201); + expect(res.body.frame.name).toBe('Molten'); + }); +}); + +describe('/api/custom-frames/[id]', () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); + sql.mockResolvedValue({ rows: [] }); + sql.json = vi.fn((v) => v); + }); + + it('returns 404 for another user\'s frame', async () => { + sql.mockResolvedValueOnce({ rows: [] }); + const res = createRes(); + + await itemHandler({ method: 'GET', query: { id: '4' } }, res); + + expect(res.statusCode).toBe(404); + }); + + it('updates name and palette', async () => { + sql + .mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership + .mockResolvedValueOnce({ rows: [] }) // clash lookup misses + .mockResolvedValueOnce({ + rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE }], + }); + + const res = createRes(); + await itemHandler( + { method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE } }, + res + ); + + expect(res.statusCode).toBe(200); + expect(res.body.frame.name).toBe('Molten II'); + }); + + it('deletes the frame', async () => { + sql + .mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership + .mockResolvedValueOnce({ rows: [] }); // DELETE + + const res = createRes(); + await itemHandler({ method: 'DELETE', query: { id: '4' } }, res); + + expect(res.statusCode).toBe(200); + expect(sql).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/api/custom-symbols.test.js b/test/api/custom-symbols.test.js new file mode 100644 index 0000000..9e8aba6 --- /dev/null +++ b/test/api/custom-symbols.test.js @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/object-storage.js', () => ({ + put: vi.fn(), + del: vi.fn(), +})); +vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() })); +vi.mock('../../lib/permission-middleware', () => ({ + getUserFromRequest: vi.fn(), +})); +vi.mock('../../lib/rate-limit.js', () => ({ + checkUploadRateLimit: vi.fn(), +})); + +import { put, del } from '../../lib/object-storage.js'; +import { sql } from '../../lib/sql.js'; +import { getUserFromRequest } from '../../lib/permission-middleware'; +import { checkUploadRateLimit } from '../../lib/rate-limit.js'; +import handler from '../../pages/api/custom-symbols/index.js'; + +function createRes() { + const res = { + statusCode: 200, + body: null, + status(code) { + res.statusCode = code; + return res; + }, + json(data) { + res.body = data; + return res; + }, + setHeader() { + return res; + }, + }; + return res; +} + +/** Build a fake multipart request whose body carries the given parts. */ +function multipartRequest(fields) { + const boundary = 'testboundary'; + const body = + fields.map((f) => `--${boundary}\r\n${f}\r\n`).join('') + `--${boundary}--\r\n`; + return { + method: 'POST', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + on(event, cb) { + if (event === 'data') cb(Buffer.from(body)); + if (event === 'end') cb(); + }, + }; +} + +function filePart(name, filename, type) { + return `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\nBINARYDATA`; +} + +function textPart(name, value) { + return `Content-Disposition: form-data; name="${name}"\r\n\r\n${value}`; +} + +describe('/api/custom-symbols', () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' }); + checkUploadRateLimit.mockResolvedValue({ allowed: true, reset: Date.now() + 60000 }); + sql.mockResolvedValue({ rows: [] }); + put.mockResolvedValue({ url: 'https://cdn.example.com/symbols/x.png' }); + }); + + 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('rejects invalid symbol codes', async () => { + const req = multipartRequest([ + filePart('image', 'f.png', 'image/png'), + textPart('code', 'bad code!'), + ]); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain('1-10 letters'); + }); + + it('rejects unsupported file types', async () => { + const req = multipartRequest([ + filePart('image', 'f.gif', 'image/gif'), + textPart('code', 'F'), + ]); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain('PNG, WebP, or SVG'); + }); + + it('uploads a symbol and returns its URL', async () => { + sql + .mockResolvedValueOnce({ rows: [] }) // existing lookup misses + .mockResolvedValueOnce({ + rows: [{ id: 7, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }], + }); + + const req = multipartRequest([ + filePart('image', 'fire.png', 'image/png'), + textPart('code', 'F'), + ]); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(201); + expect(res.body.symbol.code).toBe('F'); + expect(put).toHaveBeenCalledTimes(1); + expect(put.mock.calls[0][0]).toContain('symbols/1-f-'); + }); + + it('replaces an existing symbol with the same code', async () => { + sql + .mockResolvedValueOnce({ + rows: [{ id: 6, image_url: 'https://cdn.example.com/symbols/old.png' }], + }) // existing lookup hits + .mockResolvedValueOnce({ + rows: [{ id: 6, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }], + }); + + const req = multipartRequest([ + filePart('image', 'fire.png', 'image/png'), + textPart('code', 'F'), + ]); + const res = createRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(del).toHaveBeenCalledWith('https://cdn.example.com/symbols/old.png'); + }); +});