feat(designer): image-based frame zones, ZoneEditor, custom frame/game APIs

Adds DEFAULT_LAYOUT + IMAGE_FRAME_ROWS constants (fractional art/text window
anchors), a ZoneEditor component for bounding-box layout editing in the
designer, and expands the custom-frames/games CRUD API surface to support
frame image storage and retrieval. Custom card designer pages wire these
together with the existing PNG export pipeline.

See .convoys/card-designer-image-frames.md for scope tracking.
This commit is contained in:
Randall Stillwell 2026-09-01 09:23:16 -05:00
parent 5b9a278ca2
commit 027ddcf83e
14 changed files with 833 additions and 62 deletions

View file

@ -8,6 +8,21 @@ import { getFrame, getRarity } from './frames';
export const CARD_W = 420;
export const CARD_H = 588;
/**
* Default window zones for image-based frames, as fractions of the card.
* They mirror the framed layout's art window and text box positions.
*/
export const DEFAULT_LAYOUT = {
art: { x: 16 / CARD_W, y: 64 / CARD_H, w: 388 / CARD_W, h: 234 / CARD_H },
text: { x: 16 / CARD_W, y: 342 / CARD_H, w: 388 / CARD_W, h: 224 / CARD_H },
};
/** Standard vertical anchors (px) for text rows on image frames. */
export const IMAGE_FRAME_ROWS = {
title: { top: 22, height: 34 },
type: { top: 306, height: 28 },
};
/** Resolve the active palette: a custom frame's palette when linked,
* otherwise the starter frame's. */
export function resolvePalette(design) {
@ -22,6 +37,13 @@ function resolveTexture(design) {
return design.custom_frame?.texture_url || null;
}
/** Window zones for image frames: saved layout or the defaults. */
export function resolveLayout(design) {
const layout = design.custom_frame?.layout;
if (layout?.art && layout?.text) return layout;
return DEFAULT_LAYOUT;
}
/**
* 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
@ -31,9 +53,186 @@ export default function CardFrame({ design, innerRef, symbols }) {
if (design.art_mode === 'fullart') {
return <FullArtCard design={design} innerRef={innerRef} symbols={symbols} />;
}
if (design.custom_frame?.frame_image_url) {
return <ImageFrameCard design={design} innerRef={innerRef} symbols={symbols} />;
}
return <FramedCard design={design} innerRef={innerRef} symbols={symbols} />;
}
/* ─────────────────────────── image frame layout ────────────────────── */
/**
* Full-custom frame: the uploaded frame artwork is the card base; the
* artwork and text drop into the frame's saved window zones. Title,
* cost, type, and rarity render at standard anchors, colored by palette.
*/
function ImageFrameCard({ design, innerRef, symbols }) {
const p = resolvePalette(design);
const layout = resolveLayout(design);
const showPt = Boolean(design.power || design.toughness);
const zoneStyle = (zone) => ({
position: 'absolute',
left: `${(zone.x * 100).toFixed(3)}%`,
top: `${(zone.y * 100).toFixed(3)}%`,
width: `${(zone.w * 100).toFixed(3)}%`,
height: `${(zone.h * 100).toFixed(3)}%`,
overflow: 'hidden',
});
return (
<div
ref={innerRef}
style={{
width: CARD_W,
height: CARD_H,
position: 'relative',
backgroundColor: p.outer,
borderRadius: 18,
border: `6px solid ${p.border}`,
boxSizing: 'border-box',
overflow: 'hidden',
fontFamily: 'Georgia, "Times New Roman", serif',
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
userSelect: 'none',
}}
>
{/* Frame artwork base layer */}
<img
src={design.custom_frame.frame_image_url}
alt={`${design.custom_frame.name || 'Custom'} frame`}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'fill',
display: 'block',
}}
/>
{/* Artwork window */}
<div style={zoneStyle(layout.art)}>
{design.artwork_url ? (
<img
src={design.artwork_url}
alt={design.name || 'Card artwork'}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: p.artBacking,
opacity: 0.5,
}}
>
<span style={{ color: p.border, fontSize: 13, fontStyle: 'italic' }}>Artwork</span>
</div>
)}
</div>
{/* Title + cost */}
<div
style={{
position: 'absolute',
left: 26,
right: 26,
top: IMAGE_FRAME_ROWS.title.top,
height: IMAGE_FRAME_ROWS.title.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<span
style={{
color: p.titleText,
fontSize: 17,
fontWeight: 700,
lineHeight: 1.15,
textShadow: '0 1px 2px rgba(0,0,0,0.6)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.name || 'Untitled Card'}
</span>
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
</div>
{/* Type line + rarity */}
<div
style={{
position: 'absolute',
left: 26,
right: 26,
top: IMAGE_FRAME_ROWS.type.top,
height: IMAGE_FRAME_ROWS.type.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
}}
>
<span
style={{
color: p.titleText,
fontSize: 13,
textShadow: '0 1px 2px rgba(0,0,0,0.6)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{design.card_type || '— Type —'}
</span>
<RarityBadge rarityId={design.rarity} size={18} />
</div>
{/* Text window */}
<div
style={{
...zoneStyle(layout.text),
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
}}
>
<TextBlocks
design={design}
color={p.text}
dividerColor={`${p.accent}55`}
symbols={symbols}
/>
{showPt && (
<div
style={{
marginTop: 'auto',
alignSelf: 'flex-end',
backgroundColor: p.titleBar,
color: p.titleText,
border: `1px solid ${p.border}`,
borderRadius: 999,
padding: '1px 14px',
fontWeight: 700,
fontSize: 14,
}}
>
{design.power || '0'} / {design.toughness || '0'}
</div>
)}
</div>
</div>
);
}
/* ─────────────────────────── framed layout ─────────────────────────── */
function FramedCard({ design, innerRef, symbols }) {

View file

@ -0,0 +1,161 @@
/* eslint-disable @next/next/no-img-element -- frame image comes from the MinIO CDN; next/image is out of scope for the designer canvas. */
import { useRef } from 'react';
import { CARD_W, CARD_H, DEFAULT_LAYOUT } from './CardFrame';
const ZONE_COLORS = {
art: { fill: 'rgba(110, 168, 220, 0.18)', border: '#6ea8dc' },
text: { fill: 'rgba(143, 188, 111, 0.18)', border: '#8fbc6f' },
};
const HANDLE = 14;
/**
* Interactive zone editor for image-based frames. Renders the uploaded
* frame artwork at natural card size with draggable/resizable overlays
* for the art and text windows. Zones are fractions of the card.
*
* Drag inside a zone to move it; drag the bottom-right handle to resize.
*/
export default function ZoneEditor({ frameImageUrl, layout, onChange }) {
const dragState = useRef(null);
const clampZone = (zone) => {
const w = Math.min(Math.max(zone.w, 0.05), 1);
const h = Math.min(Math.max(zone.h, 0.05), 1);
return {
w,
h,
x: Math.min(Math.max(zone.x, 0), 1 - w),
y: Math.min(Math.max(zone.y, 0), 1 - h),
};
};
const startDrag = (zoneKey, mode) => (event) => {
event.preventDefault();
event.target.setPointerCapture?.(event.pointerId);
dragState.current = {
zoneKey,
mode,
startX: event.clientX,
startY: event.clientY,
origin: layout[zoneKey],
};
};
const onPointerMove = (event) => {
const state = dragState.current;
if (!state) return;
const dx = (event.clientX - state.startX) / CARD_W;
const dy = (event.clientY - state.startY) / CARD_H;
const o = state.origin;
let next;
if (state.mode === 'resize') {
next = clampZone({ x: o.x, y: o.y, w: o.w + dx, h: o.h + dy });
} else {
next = clampZone({ x: o.x + dx, y: o.y + dy, w: o.w, h: o.h });
}
onChange({ ...layout, [state.zoneKey]: next });
};
const endDrag = () => {
dragState.current = null;
};
const renderZone = (zoneKey) => {
const zone = layout[zoneKey] || DEFAULT_LAYOUT[zoneKey];
const colors = ZONE_COLORS[zoneKey];
return (
<div
key={zoneKey}
onPointerDown={startDrag(zoneKey, 'move')}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
style={{
position: 'absolute',
left: `${(zone.x * 100).toFixed(3)}%`,
top: `${(zone.y * 100).toFixed(3)}%`,
width: `${(zone.w * 100).toFixed(3)}%`,
height: `${(zone.h * 100).toFixed(3)}%`,
backgroundColor: colors.fill,
border: `2px dashed ${colors.border}`,
cursor: 'move',
touchAction: 'none',
userSelect: 'none',
}}
>
<span
style={{
position: 'absolute',
top: 4,
left: 6,
fontSize: 11,
fontWeight: 700,
color: colors.border,
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
pointerEvents: 'none',
}}
>
{zoneKey === 'art' ? 'ARTWORK' : 'TEXT'}
</span>
{/* Resize handle */}
<div
onPointerDown={startDrag(zoneKey, 'resize')}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
style={{
position: 'absolute',
right: -HANDLE / 2,
bottom: -HANDLE / 2,
width: HANDLE,
height: HANDLE,
backgroundColor: colors.border,
border: '2px solid #fff',
borderRadius: 3,
cursor: 'nwse-resize',
touchAction: 'none',
}}
/>
</div>
);
};
return (
<div style={{ overflow: 'auto', maxWidth: '100%' }}>
<div
style={{
position: 'relative',
width: CARD_W,
height: CARD_H,
backgroundColor: '#1a1a22',
backgroundImage: frameImageUrl ? `url(${frameImageUrl})` : undefined,
backgroundSize: '100% 100%',
borderRadius: 12,
overflow: 'hidden',
flexShrink: 0,
}}
>
{!frameImageUrl && (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#888',
fontSize: 13,
fontStyle: 'italic',
}}
>
Upload a frame image to position its windows
</div>
)}
{renderZone('art')}
{renderZone('text')}
</div>
</div>
);
}

View file

@ -36,3 +36,41 @@ export function validatePalette(input) {
return { palette };
}
/**
* Validate/normalize frame layout zones (art + text windows as 0-1
* fractions of the card). Returns { layout } or { error }.
*/
export function validateLayout(input) {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return { error: 'Layout must be an object' };
}
const layout = {};
for (const zone of ['art', 'text']) {
const z = input[zone];
if (!z || typeof z !== 'object') {
return { error: `Layout zone "${zone}" is required` };
}
const { x, y, w, h } = z;
for (const [key, value] of Object.entries({ x, y, w, h })) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return { error: `Layout zone "${zone}" has invalid ${key}` };
}
}
if (w <= 0 || h <= 0) {
return { error: `Layout zone "${zone}" must have positive size` };
}
if (x < 0 || y < 0 || x + w > 1.001 || y + h > 1.001) {
return { error: `Layout zone "${zone}" extends past the card` };
}
layout[zone] = {
x: Math.max(0, x),
y: Math.max(0, y),
w: Math.min(w, 1 - Math.max(0, x)),
h: Math.min(h, 1 - Math.max(0, y)),
};
}
return { layout };
}

View file

@ -0,0 +1,23 @@
/**
* Image-based custom frames:
* - frame_image_url: full-card frame artwork (PNG with transparent
* windows for art/text) rendered as the card base layer.
* - layout: art/text window zones as 0-1 fractions of the card
* ({ art: {x,y,w,h}, text: {x,y,w,h} }) so windows align with the
* uploaded image regardless of render size.
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE custom_frames
ADD COLUMN IF NOT EXISTS frame_image_url TEXT,
ADD COLUMN IF NOT EXISTS layout JSONB
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE custom_frames
DROP COLUMN IF EXISTS layout,
DROP COLUMN IF EXISTS frame_image_url
`);
};

View file

@ -16,7 +16,8 @@ export default async function handler(req, res) {
}
const found = await sql`
SELECT c.*, f.name AS frame_name, f.palette AS frame_palette, f.texture_url AS frame_texture
SELECT c.*, f.name AS frame_name, f.palette AS frame_palette, f.texture_url AS frame_texture,
f.frame_image_url AS frame_image, f.layout AS frame_layout
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}
@ -24,7 +25,7 @@ export default async function handler(req, res) {
if (found.rows.length === 0) {
return res.status(404).json({ error: 'Design not found' });
}
const { frame_name, frame_palette, frame_texture, ...row } = found.rows[0];
const { frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...row } = found.rows[0];
const existing = {
...row,
custom_frame:
@ -34,6 +35,8 @@ export default async function handler(req, res) {
name: frame_name,
palette: frame_palette,
texture_url: frame_texture,
frame_image_url: frame_image,
layout: frame_layout,
}
: null,
};

View file

@ -21,6 +21,8 @@ export default async function handler(req, res) {
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,
f.texture_url AS frame_texture,
f.frame_image_url AS frame_image,
f.layout AS frame_layout,
c.created_at, c.updated_at
FROM custom_cards c
LEFT JOIN custom_games g ON g.id = c.custom_game_id
@ -37,12 +39,14 @@ export default async function handler(req, res) {
name: row.frame_name,
palette: row.frame_palette,
texture_url: row.frame_texture,
frame_image_url: row.frame_image,
layout: row.frame_layout,
}
: null,
}));
return res.status(200).json({
designs: designs.map(
({ frame_pk, frame_name, frame_palette, frame_texture, ...rest }) => rest
({ frame_pk, frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...rest }) => rest
),
});
}

View file

@ -1,6 +1,6 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { validatePalette } from '../../../lib/frame-palette.js';
import { validatePalette, validateLayout, DEFAULT_LAYOUT } from '../../../lib/frame-palette.js';
export default async function handler(req, res) {
try {
@ -54,13 +54,20 @@ export default async function handler(req, res) {
? req.body.texture_url.trim()
: found.rows[0].texture_url;
const layoutInput = req.body?.layout === undefined ? found.rows[0].layout : req.body.layout;
const { layout, error: layoutError } = validateLayout(layoutInput || {});
if (layoutError) {
return res.status(400).json({ error: layoutError });
}
const updated = await sql`
UPDATE custom_frames SET
name = ${name}, palette = ${sql.json(palette)},
texture_url = ${textureUrl},
layout = ${sql.json(layout)},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
RETURNING id, name, palette, texture_url, created_at, updated_at
RETURNING id, name, palette, texture_url, frame_image_url, layout, created_at, updated_at
`;
return res.status(200).json({ frame: updated.rows[0] });
}

View file

@ -0,0 +1,164 @@
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: '8mb',
},
},
};
const ALLOWED_TYPES = ['image/png', 'image/webp'];
/**
* POST upload the full-card frame artwork for a custom frame
* (PNG/WebP with transparent art + text windows)
* DELETE remove the frame image
*/
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 id, frame_image_url 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' });
}
const frame = found.rows[0];
const deleteStoredImage = async () => {
if (!frame.frame_image_url) return;
try {
await del(frame.frame_image_url);
} catch (blobError) {
console.warn('Failed to delete old frame image:', blobError);
}
};
if (req.method === 'DELETE') {
await deleteStoredImage();
await sql`
UPDATE custom_frames SET frame_image_url = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
`;
return res.status(200).json({ frame_image_url: null });
}
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 file = formData.image;
if (!file) {
return res.status(400).json({ error: 'No frame image provided' });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return res.status(400).json({
error: 'Invalid file type. Please upload a PNG or WebP image (transparency required).',
});
}
if (file.size > 8 * 1024 * 1024) {
return res.status(400).json({ error: 'File size must be less than 8MB' });
}
await deleteStoredImage();
const extension = file.type.split('/')[1];
const filename = `frame-images/${user.userId}-${frameId}-${Date.now()}.${extension}`;
const blob = await put(filename, file.buffer, {
access: 'public',
contentType: file.type,
});
await sql`
UPDATE custom_frames SET frame_image_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${frameId}
`;
return res.status(200).json({ frame_image_url: blob.url });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Frame image API error:', error);
return res.status(500).json({ error: 'Failed to handle frame image' });
}
}
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

@ -1,6 +1,6 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { validatePalette } from '../../../lib/frame-palette.js';
import { validatePalette, validateLayout, DEFAULT_LAYOUT } from '../../../lib/frame-palette.js';
export default async function handler(req, res) {
try {
@ -11,7 +11,8 @@ export default async function handler(req, res) {
if (req.method === 'GET') {
const result = await sql`
SELECT id, name, palette, texture_url, created_at, updated_at
SELECT id, name, palette, texture_url, frame_image_url, layout,
created_at, updated_at
FROM custom_frames
WHERE user_id = ${user.userId}
ORDER BY name ASC
@ -30,6 +31,12 @@ export default async function handler(req, res) {
return res.status(400).json({ error });
}
const layoutInput = req.body?.layout || DEFAULT_LAYOUT;
const { layout, error: layoutError } = validateLayout(layoutInput);
if (layoutError) {
return res.status(400).json({ error: layoutError });
}
const textureUrl =
typeof req.body?.texture_url === 'string' && req.body.texture_url.trim()
? req.body.texture_url.trim()
@ -44,9 +51,9 @@ export default async function handler(req, res) {
}
const inserted = await sql`
INSERT INTO custom_frames (user_id, name, palette, texture_url)
VALUES (${user.userId}, ${name}, ${sql.json(palette)}, ${textureUrl})
RETURNING id, name, palette, texture_url, created_at, updated_at
INSERT INTO custom_frames (user_id, name, palette, texture_url, layout)
VALUES (${user.userId}, ${name}, ${sql.json(palette)}, ${textureUrl}, ${sql.json(layout)})
RETURNING id, name, palette, texture_url, frame_image_url, layout, created_at, updated_at
`;
return res.status(201).json({ frame: inserted.rows[0] });
}

View file

@ -29,6 +29,8 @@ export default async function handler(req, res) {
c.frame_id, c.artwork_url, c.art_mode,
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
f.texture_url AS frame_texture,
f.frame_image_url AS frame_image,
f.layout AS frame_layout,
c.created_at, c.updated_at
FROM custom_cards c
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
@ -36,7 +38,7 @@ export default async function handler(req, res) {
ORDER BY c.updated_at DESC
`;
const designs = cards.rows.map((row) => {
const { frame_pk, frame_name, frame_palette, frame_texture, ...rest } = row;
const { frame_pk, frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...rest } = row;
return {
...rest,
custom_frame:
@ -46,6 +48,8 @@ export default async function handler(req, res) {
name: frame_name,
palette: frame_palette,
texture_url: frame_texture,
frame_image_url: frame_image,
layout: frame_layout,
}
: null,
};

View file

@ -31,7 +31,9 @@ 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,
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
f.texture_url AS frame_texture
f.texture_url AS frame_texture,
f.frame_image_url AS frame_image,
f.layout AS frame_layout
FROM custom_cards c
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
WHERE c.custom_game_id = ${gameId}
@ -39,7 +41,7 @@ export default async function handler(req, res) {
`;
const designs = cards.rows.map((row) => {
const { frame_pk, frame_name, frame_palette, frame_texture, ...rest } = row;
const { frame_pk, frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...rest } = row;
return {
...rest,
custom_frame:
@ -49,6 +51,8 @@ export default async function handler(req, res) {
name: frame_name,
palette: frame_palette,
texture_url: frame_texture,
frame_image_url: frame_image,
layout: frame_layout,
}
: null,
};

View file

@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../components/Layout';
import CardFrame, { CardPreview } from '../components/designer/CardFrame';
import CardFrame, { CardPreview, DEFAULT_LAYOUT } from '../components/designer/CardFrame';
import ZoneEditor from '../components/designer/ZoneEditor';
import { FRAMES, RARITIES, getFrame } from '../components/designer/frames';
import { EXISTING_GAMES } from '../lib/designer-games.js';
import { PALETTE_SLOTS } from '../lib/frame-palette.js';
@ -57,6 +58,8 @@ export default function Designer() {
const [uploadingSymbol, setUploadingSymbol] = useState(false);
const [uploadingTexture, setUploadingTexture] = useState(false);
const textureFileRef = useRef(null);
const [uploadingFrameImage, setUploadingFrameImage] = useState(false);
const frameImageFileRef = useRef(null);
const cardRef = useRef(null);
const fileInputRef = useRef(null);
@ -142,7 +145,17 @@ export default function Designer() {
const openNewFrameEditor = () => {
const base = getFrame(design.frame_id).palette;
setFrameEditor({ id: null, name: '', palette: { ...base } });
setFrameEditor({
id: null,
name: '',
palette: { ...base },
texture_url: null,
frame_image_url: null,
layout: {
art: { ...DEFAULT_LAYOUT.art },
text: { ...DEFAULT_LAYOUT.text },
},
});
};
const openEditFrameEditor = (frame) => {
@ -151,6 +164,13 @@ export default function Designer() {
name: frame.name,
palette: { ...frame.palette },
texture_url: frame.texture_url || null,
frame_image_url: frame.frame_image_url || null,
layout: frame.layout
? { art: { ...frame.layout.art }, text: { ...frame.layout.text } }
: {
art: { ...DEFAULT_LAYOUT.art },
text: { ...DEFAULT_LAYOUT.text },
},
});
};
@ -169,7 +189,11 @@ export default function Designer() {
{
method: isNew ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ name, palette: frameEditor.palette }),
body: JSON.stringify({
name,
palette: frameEditor.palette,
layout: frameEditor.layout,
}),
}
);
const data = await response.json();
@ -314,6 +338,61 @@ export default function Designer() {
}
};
const handleUploadFrameImage = async (file) => {
if (!file || !frameEditor?.id) return;
setUploadingFrameImage(true);
try {
const body = new FormData();
body.append('image', file);
const response = await fetch(`/api/custom-frames/${frameEditor.id}/frame-image`, {
method: 'POST',
headers: authHeaders(),
body,
});
const data = await response.json();
if (response.ok && data.frame_image_url) {
setFrames((prev) =>
prev.map((f) =>
f.id === frameEditor.id
? { ...f, frame_image_url: data.frame_image_url }
: f
)
);
setFrameEditor((prev) => ({ ...prev, frame_image_url: data.frame_image_url }));
setMessage({ kind: 'success', text: 'Frame image uploaded — drag the windows to match it.' });
} else {
setMessage({ kind: 'error', text: data.error || 'Frame image upload failed.' });
}
} catch {
setMessage({ kind: 'error', text: 'Frame image upload failed.' });
} finally {
setUploadingFrameImage(false);
if (frameImageFileRef.current) frameImageFileRef.current.value = '';
}
};
const handleRemoveFrameImage = async () => {
if (!frameEditor?.id) return;
try {
const response = await fetch(`/api/custom-frames/${frameEditor.id}/frame-image`, {
method: 'DELETE',
headers: authHeaders(),
});
if (response.ok) {
setFrames((prev) =>
prev.map((f) =>
f.id === frameEditor.id ? { ...f, frame_image_url: null } : f
)
);
setFrameEditor((prev) => ({ ...prev, frame_image_url: null }));
} else {
setMessage({ kind: 'error', text: 'Could not remove frame image.' });
}
} catch {
setMessage({ kind: 'error', text: 'Could not remove frame image.' });
}
};
const symbolsMap = Object.fromEntries(symbols.map((s) => [s.code, s.image_url]));
// Edit mode when ?id= is present
@ -739,45 +818,85 @@ export default function Designer() {
</label>
))}
</div>
{frameEditor.id && (
<div className="flex flex-wrap items-center gap-2">
<input
ref={textureFileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => handleUploadTexture(e.target.files?.[0])}
/>
<Button
variant="secondary"
size="sm"
onClick={() => textureFileRef.current?.click()}
disabled={uploadingTexture}
>
{uploadingTexture
? 'Uploading…'
: frameEditor.texture_url
? 'Replace Texture'
: 'Upload Texture'}
</Button>
{frameEditor.texture_url && (
<Button variant="secondary" size="sm" onClick={handleRemoveTexture}>
Remove Texture
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
Delete Frame
</Button>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Textures show behind the frame panels.
</span>
</div>
)}
{!frameEditor.id && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Save the frame first to add a background texture.
</p>
)}
{frameEditor.id && (
<div className="flex flex-wrap items-center gap-2">
<input
ref={textureFileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => handleUploadTexture(e.target.files?.[0])}
/>
<Button
variant="secondary"
size="sm"
onClick={() => textureFileRef.current?.click()}
disabled={uploadingTexture}
>
{uploadingTexture
? 'Uploading…'
: frameEditor.texture_url
? 'Replace Texture'
: 'Upload Texture'}
</Button>
{frameEditor.texture_url && (
<Button variant="secondary" size="sm" onClick={handleRemoveTexture}>
Remove Texture
</Button>
)}
<input
ref={frameImageFileRef}
type="file"
accept="image/png,image/webp"
className="hidden"
onChange={(e) => handleUploadFrameImage(e.target.files?.[0])}
/>
<Button
variant="secondary"
size="sm"
onClick={() => frameImageFileRef.current?.click()}
disabled={uploadingFrameImage}
>
{uploadingFrameImage
? 'Uploading…'
: frameEditor.frame_image_url
? 'Replace Frame Image'
: 'Upload Frame Image'}
</Button>
{frameEditor.frame_image_url && (
<Button variant="secondary" size="sm" onClick={handleRemoveFrameImage}>
Remove Frame Image
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
Delete Frame
</Button>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{frameEditor.frame_image_url
? 'Drag the ARTWORK/TEXT windows to fit your image.'
: 'Textures show behind the frame panels. Save first to upload a full frame PNG with transparent windows.'}
</span>
</div>
)}
{!frameEditor.id && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Save the frame first to add a background texture or full frame image.
</p>
)}
{frameEditor.frame_image_url && (
<div className="mt-4 rounded-xl p-4 border" style={{ borderColor: 'var(--border)' }}>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
Window Editor
</p>
<ZoneEditor
frameImageUrl={frameEditor.frame_image_url}
layout={frameEditor.layout}
onChange={(next) =>
setFrameEditor((prev) => ({ ...prev, layout: next }))
}
/>
</div>
)}
</div>
<div className="w-full max-w-[240px] shrink-0">
<p className="text-xs mb-2 text-center" style={{ color: 'var(--text-secondary)' }}>
@ -789,6 +908,8 @@ export default function Designer() {
custom_frame: {
palette: frameEditor.palette,
texture_url: frameEditor.texture_url,
frame_image_url: frameEditor.frame_image_url,
layout: frameEditor.layout,
},
}}
symbols={symbolsMap}

View file

@ -9,7 +9,7 @@ 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';
import { validatePalette, PALETTE_SLOTS, validateLayout } from '../../lib/frame-palette.js';
function createRes() {
const res = {
@ -31,6 +31,11 @@ const GOOD_PALETTE = Object.fromEntries(
PALETTE_SLOTS.map(({ key }) => [key, '#123456'])
);
const DEFAULT_LAYOUT = {
art: { x: 16 / 420, y: 64 / 588, w: 388 / 420, h: 234 / 588 },
text: { x: 16 / 420, y: 342 / 588, w: 388 / 420, h: 224 / 588 },
};
describe('validatePalette', () => {
it('accepts and normalizes a complete palette', () => {
const { palette, error } = validatePalette(
@ -49,6 +54,25 @@ describe('validatePalette', () => {
});
});
describe('validateLayout', () => {
it('accepts and normalizes a valid layout', () => {
const { layout, error } = validateLayout({
art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 },
text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 },
});
expect(error).toBeUndefined();
expect(layout.art.x).toBeCloseTo(0.038);
});
it('rejects missing zones', () => {
expect(validateLayout({ art: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy();
});
it('rejects zones extending past the card', () => {
expect(validateLayout({ art: { x: 0.9, y: 0, w: 0.2, h: 0.5 }, text: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy();
});
});
describe('/api/custom-frames', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -79,6 +103,18 @@ describe('/api/custom-frames', () => {
expect(res.body.error).toContain('Invalid or missing color');
});
it('rejects invalid layouts on create', async () => {
const res = createRes();
await handler(
{ method: 'POST', body: { name: 'My Frame', palette: GOOD_PALETTE, layout: { art: { x: 1, y: 0, w: 1, h: 1 } } } },
res
);
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('extends past the card');
});
it('rejects duplicate frame names', async () => {
sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits
const res = createRes();
@ -91,11 +127,11 @@ describe('/api/custom-frames', () => {
expect(res.statusCode).toBe(409);
});
it('creates a frame with the normalized palette', async () => {
it('creates a frame with the normalized palette and default layout', async () => {
sql
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
.mockResolvedValueOnce({
rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE }],
rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }],
});
const res = createRes();
@ -106,6 +142,7 @@ describe('/api/custom-frames', () => {
expect(res.statusCode).toBe(201);
expect(res.body.frame.name).toBe('Molten');
expect(res.body.frame.layout).toBeDefined();
});
});
@ -126,17 +163,17 @@ describe('/api/custom-frames/[id]', () => {
expect(res.statusCode).toBe(404);
});
it('updates name and palette', async () => {
it('updates name, palette, and layout', async () => {
sql
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
.mockResolvedValueOnce({
rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE }],
rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }],
});
const res = createRes();
await itemHandler(
{ method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE } },
{ method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 }, text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 } } } },
res
);

View file

@ -1 +0,0 @@
{}