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 }) { }} >
+ Live frame preview +
+ Upload small icons and use them in the Cost field as {'{CODE}'} or plain letters — e.g.{' '} + {'2{F}{F}'}. +
{'{CODE}'}
{'2{F}{F}'}
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'); + }); +});