From 027ddcf83eac8fb5881a028f13f26a44cadf0d4d Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 1 Sep 2026 09:23:16 -0500 Subject: [PATCH] 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. --- components/designer/CardFrame.js | 199 +++++++++++++++++ components/designer/ZoneEditor.js | 161 ++++++++++++++ lib/frame-palette.js | 38 ++++ .../1787718511000_add-frame-image-layout.js | 23 ++ pages/api/custom-cards/[id].js | 7 +- pages/api/custom-cards/index.js | 6 +- pages/api/custom-frames/[id].js | 11 +- pages/api/custom-frames/[id]/frame-image.js | 164 ++++++++++++++ pages/api/custom-frames/index.js | 17 +- pages/api/custom-games/[id].js | 6 +- pages/api/public/games/[id].js | 8 +- pages/designer.js | 205 ++++++++++++++---- test/api/custom-frames.test.js | 49 ++++- vercel.json | 1 - 14 files changed, 833 insertions(+), 62 deletions(-) create mode 100644 components/designer/ZoneEditor.js create mode 100644 migrations/1787718511000_add-frame-image-layout.js create mode 100644 pages/api/custom-frames/[id]/frame-image.js delete mode 100644 vercel.json diff --git a/components/designer/CardFrame.js b/components/designer/CardFrame.js index ef43af5..bb811ee 100644 --- a/components/designer/CardFrame.js +++ b/components/designer/CardFrame.js @@ -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 ; } + if (design.custom_frame?.frame_image_url) { + return ; + } return ; } +/* ─────────────────────────── 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 ( +
+ {/* Frame artwork base layer */} + {`${design.custom_frame.name + + {/* Artwork window */} +
+ {design.artwork_url ? ( + {design.name + ) : ( +
+ Artwork +
+ )} +
+ + {/* Title + cost */} +
+ + {design.name || 'Untitled Card'} + + +
+ + {/* Type line + rarity */} +
+ + {design.card_type || '— Type —'} + + +
+ + {/* Text window */} +
+ + {showPt && ( +
+ {design.power || '0'} / {design.toughness || '0'} +
+ )} +
+
+ ); +} + /* ─────────────────────────── framed layout ─────────────────────────── */ function FramedCard({ design, innerRef, symbols }) { diff --git a/components/designer/ZoneEditor.js b/components/designer/ZoneEditor.js new file mode 100644 index 0000000..918c93b --- /dev/null +++ b/components/designer/ZoneEditor.js @@ -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 ( +
+ + {zoneKey === 'art' ? 'ARTWORK' : 'TEXT'} + + {/* Resize handle */} +
+
+ ); + }; + + return ( +
+
+ {!frameImageUrl && ( +
+ Upload a frame image to position its windows +
+ )} + {renderZone('art')} + {renderZone('text')} +
+
+ ); +} diff --git a/lib/frame-palette.js b/lib/frame-palette.js index 15ccb03..18237bb 100644 --- a/lib/frame-palette.js +++ b/lib/frame-palette.js @@ -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 }; +} diff --git a/migrations/1787718511000_add-frame-image-layout.js b/migrations/1787718511000_add-frame-image-layout.js new file mode 100644 index 0000000..f3155e5 --- /dev/null +++ b/migrations/1787718511000_add-frame-image-layout.js @@ -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 + `); +}; diff --git a/pages/api/custom-cards/[id].js b/pages/api/custom-cards/[id].js index 10347cb..aecdd6e 100644 --- a/pages/api/custom-cards/[id].js +++ b/pages/api/custom-cards/[id].js @@ -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, }; diff --git a/pages/api/custom-cards/index.js b/pages/api/custom-cards/index.js index 4f1bd4b..d238f14 100644 --- a/pages/api/custom-cards/index.js +++ b/pages/api/custom-cards/index.js @@ -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 ), }); } diff --git a/pages/api/custom-frames/[id].js b/pages/api/custom-frames/[id].js index 4e7b174..76ef6a6 100644 --- a/pages/api/custom-frames/[id].js +++ b/pages/api/custom-frames/[id].js @@ -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] }); } diff --git a/pages/api/custom-frames/[id]/frame-image.js b/pages/api/custom-frames/[id]/frame-image.js new file mode 100644 index 0000000..aa2c62c --- /dev/null +++ b/pages/api/custom-frames/[id]/frame-image.js @@ -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); + }); +} diff --git a/pages/api/custom-frames/index.js b/pages/api/custom-frames/index.js index 3523af0..5f732fc 100644 --- a/pages/api/custom-frames/index.js +++ b/pages/api/custom-frames/index.js @@ -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] }); } diff --git a/pages/api/custom-games/[id].js b/pages/api/custom-games/[id].js index de01419..a7842bc 100644 --- a/pages/api/custom-games/[id].js +++ b/pages/api/custom-games/[id].js @@ -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, }; diff --git a/pages/api/public/games/[id].js b/pages/api/public/games/[id].js index df07029..8183c5e 100644 --- a/pages/api/public/games/[id].js +++ b/pages/api/public/games/[id].js @@ -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, }; diff --git a/pages/designer.js b/pages/designer.js index 2637444..9f5093a 100644 --- a/pages/designer.js +++ b/pages/designer.js @@ -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() { ))}
- {frameEditor.id && ( -
- handleUploadTexture(e.target.files?.[0])} - /> - - {frameEditor.texture_url && ( - - )} - - - Textures show behind the frame panels. - -
- )} - {!frameEditor.id && ( -

- Save the frame first to add a background texture. -

- )} +{frameEditor.id && ( +
+ handleUploadTexture(e.target.files?.[0])} + /> + + {frameEditor.texture_url && ( + + )} + handleUploadFrameImage(e.target.files?.[0])} + /> + + {frameEditor.frame_image_url && ( + + )} + + + {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.'} + +
+ )} + {!frameEditor.id && ( +

+ Save the frame first to add a background texture or full frame image. +

+ )} + {frameEditor.frame_image_url && ( +
+

+ Window Editor +

+ + setFrameEditor((prev) => ({ ...prev, layout: next })) + } + /> +
+ )}

@@ -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} diff --git a/test/api/custom-frames.test.js b/test/api/custom-frames.test.js index a33d7a1..d833b3d 100644 --- a/test/api/custom-frames.test.js +++ b/test/api/custom-frames.test.js @@ -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 ); diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 9e26dfe..0000000 --- a/vercel.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file