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:
parent
5b9a278ca2
commit
027ddcf83e
14 changed files with 833 additions and 62 deletions
|
|
@ -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 }) {
|
||||
|
|
|
|||
161
components/designer/ZoneEditor.js
Normal file
161
components/designer/ZoneEditor.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
23
migrations/1787718511000_add-frame-image-layout.js
Normal file
23
migrations/1787718511000_add-frame-image-layout.js
Normal 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
|
||||
`);
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
}
|
||||
|
|
|
|||
164
pages/api/custom-frames/[id]/frame-image.js
Normal file
164
pages/api/custom-frames/[id]/frame-image.js
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
Loading…
Reference in a new issue