feat(scanner): add debug instrumentation for vision pipeline timing #155

Merged
rstillwell merged 31 commits from feat/scanner-debug-mode into main 2026-09-01 18:11:03 -04:00
14 changed files with 1202 additions and 79 deletions
Showing only changes of commit fe1695f7ad - Show all commits

View file

@ -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 <FullArtCard design={design} innerRef={innerRef} />;
return <FullArtCard design={design} innerRef={innerRef} symbols={symbols} />;
}
return <FramedCard design={design} innerRef={innerRef} />;
return <FramedCard design={design} innerRef={innerRef} symbols={symbols} />;
}
/* ─────────────────────────── 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'}
</span>
<ManaPips cost={design.mana_cost} accent={p.accent} />
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
</div>
{/* 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'}
</span>
<ManaPips cost={design.mana_cost} accent={p.accent} />
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
</div>
{/* 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 }) {
}}
>
<div style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}>
<CardFrame design={design} innerRef={innerRef} />
<CardFrame design={design} innerRef={innerRef} symbols={symbols} />
</div>
</div>
</div>
@ -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,9 +575,21 @@ 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 (
<span style={{ display: 'inline-flex', gap: 3, flexShrink: 0 }}>
{tokens.slice(0, 8).map((token, i) => (
{tokens.slice(0, 8).map((token, i) => {
const icon = iconFor(token);
return (
<span
key={`${token}-${i}`}
style={{
@ -585,11 +605,21 @@ export function ManaPips({ cost, accent }) {
fontSize: 12,
fontWeight: 700,
fontFamily: 'Georgia, serif',
overflow: 'hidden',
}}
>
{token}
{icon ? (
<img
src={icon}
alt={token}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
token
)}
</span>
))}
);
})}
</span>
);
}

View file

@ -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),
};
}

38
lib/frame-palette.js Normal file
View file

@ -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 };
}

View file

@ -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`);
};

View file

@ -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 *

View file

@ -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];

View file

@ -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' });
}
}

View file

@ -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' });
}
}

View file

@ -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' });
}
}

View file

@ -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);
});
}

View file

@ -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,26 +548,37 @@ export default function Designer() {
{/* Frame picker */}
<section className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
Frame
</h2>
<Button variant="secondary" size="sm" onClick={openNewFrameEditor}>
+ New Frame
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{FRAMES.map((frame) => (
{FRAMES.map((frame) => {
const active = !design.custom_frame_id && design.frame_id === frame.id;
return (
<button
key={frame.id}
type="button"
onClick={() => setField('frame_id', frame.id)}
onClick={() =>
setDesign((prev) => ({
...prev,
frame_id: frame.id,
custom_frame_id: null,
}))
}
className={`rounded-xl p-3 text-left transition-all duration-200 border ${
design.frame_id === frame.id
? 'shadow-lg scale-[1.02]'
: 'opacity-80 hover:opacity-100'
active ? 'shadow-lg scale-[1.02]' : 'opacity-80 hover:opacity-100'
}`}
style={{
backgroundColor: frame.palette.outer,
borderColor: design.frame_id === frame.id ? frame.palette.border : 'transparent',
borderColor: active ? frame.palette.border : 'transparent',
borderWidth: 2,
}}
aria-pressed={design.frame_id === frame.id}
aria-pressed={active}
>
<div
className="w-full h-8 rounded mb-2"
@ -419,8 +591,181 @@ export default function Designer() {
{frame.name}
</span>
</button>
);
})}
{frames.map((frame) => {
const p = frame.palette;
const active = design.custom_frame_id === frame.id;
return (
<div key={frame.id} className="relative">
<button
type="button"
onClick={() => setDesign((prev) => ({ ...prev, custom_frame_id: frame.id }))}
onDoubleClick={() => openEditFrameEditor(frame)}
className={`w-full rounded-xl p-3 text-left transition-all duration-200 border ${
active ? 'shadow-lg scale-[1.02]' : 'opacity-80 hover:opacity-100'
}`}
style={{
backgroundColor: p.outer,
borderColor: active ? p.border : 'transparent',
borderWidth: 2,
}}
aria-pressed={active}
title={`${frame.name} — double-click to edit`}
>
<div
className="w-full h-8 rounded mb-2"
style={{
background: `linear-gradient(135deg, ${p.titleBar}, ${p.textBox})`,
border: `1px solid ${p.border}`,
}}
/>
<span className="text-xs font-semibold block truncate" style={{ color: p.titleText }}>
{frame.name}
</span>
</button>
<button
type="button"
onClick={() => openEditFrameEditor(frame)}
className="absolute top-1 right-1 text-[10px] px-1.5 py-0.5 rounded"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: p.titleText }}
aria-label={`Edit ${frame.name}`}
>
Edit
</button>
</div>
);
})}
</div>
{/* Frame editor */}
{frameEditor && (
<div className="mt-4 rounded-xl p-4 border" style={{ borderColor: 'var(--border)' }}>
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 space-y-3">
<div className="flex gap-2">
<input
className="input-field flex-1"
value={frameEditor.name}
onChange={(e) =>
setFrameEditor((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Frame name…"
maxLength={100}
/>
<Button variant="primary" onClick={handleSaveFrame} disabled={savingFrame}>
{savingFrame ? 'Saving…' : frameEditor.id ? 'Update' : 'Save'}
</Button>
<Button variant="secondary" onClick={() => setFrameEditor(null)}>
Cancel
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{PALETTE_SLOTS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
<input
type="color"
value={frameEditor.palette[key]}
onChange={(e) =>
setFrameEditor((prev) => ({
...prev,
palette: { ...prev.palette, [key]: e.target.value },
}))
}
className="w-7 h-7 rounded cursor-pointer bg-transparent"
aria-label={label}
/>
{label}
</label>
))}
</div>
{frameEditor.id && (
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
Delete Frame
</Button>
)}
</div>
<div className="w-full max-w-[240px] shrink-0">
<p className="text-xs mb-2 text-center" style={{ color: 'var(--text-secondary)' }}>
Live frame preview
</p>
<CardFrame
design={{
...design,
custom_frame: { palette: frameEditor.palette },
}}
symbols={symbolsMap}
/>
</div>
</div>
</div>
)}
</section>
{/* Cost symbols */}
<section className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
Cost Symbols
</h2>
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Upload small icons and use them in the Cost field as <code>{'{CODE}'}</code> or plain letters e.g.{' '}
<code>{'2{F}{F}'}</code>.
</p>
{symbols.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{symbols.map((symbol) => (
<span
key={symbol.id}
className="inline-flex items-center gap-2 rounded-full pl-1 pr-2 py-1 border text-xs font-semibold"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- CDN icon */}
<img
src={symbol.image_url}
alt={symbol.code}
className="w-6 h-6 rounded-full object-cover"
/>
{symbol.code}
<button
type="button"
onClick={() => handleDeleteSymbol(symbol.id)}
className="ml-1 opacity-60 hover:opacity-100"
aria-label={`Delete symbol ${symbol.code}`}
>
×
</button>
</span>
))}
</div>
)}
<input
ref={symbolFileRef}
type="file"
accept="image/png,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => handleSymbolUpload(e.target.files?.[0])}
/>
<div className="flex flex-wrap gap-2 items-center">
<input
className="input-field w-28"
value={symbolCode}
onChange={(e) => setSymbolCode(e.target.value)}
placeholder="Code (F)"
maxLength={10}
/>
<Button
variant="primary"
onClick={() => symbolFileRef.current?.click()}
disabled={uploadingSymbol || !symbolCode.trim()}
>
{uploadingSymbol ? 'Uploading…' : 'Upload Icon'}
</Button>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
PNG, WebP, or SVG · up to 2MB · re-uploading a code replaces it
</span>
</div>
</section>
{/* Artwork */}
@ -597,7 +942,7 @@ export default function Designer() {
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
Live Preview
</h2>
<CardPreview design={design} innerRef={cardRef} />
<CardPreview design={design} innerRef={cardRef} symbols={symbolsMap} />
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-secondary)' }}>
Saved designs appear in{' '}
<Link href="/my-designs" style={{ color: 'var(--accent-ember)' }}>My Designs</Link>{' '}

View file

@ -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 }]);
});
});

View file

@ -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);
});
});

View file

@ -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');
});
});