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_W = 420;
|
||||||
export const CARD_H = 588;
|
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,
|
/** Resolve the active palette: a custom frame's palette when linked,
|
||||||
* otherwise the starter frame's. */
|
* otherwise the starter frame's. */
|
||||||
export function resolvePalette(design) {
|
export function resolvePalette(design) {
|
||||||
|
|
@ -22,6 +37,13 @@ function resolveTexture(design) {
|
||||||
return design.custom_frame?.texture_url || null;
|
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
|
* 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
|
* 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') {
|
if (design.art_mode === 'fullart') {
|
||||||
return <FullArtCard design={design} innerRef={innerRef} symbols={symbols} />;
|
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} />;
|
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 ─────────────────────────── */
|
/* ─────────────────────────── framed layout ─────────────────────────── */
|
||||||
|
|
||||||
function FramedCard({ design, innerRef, symbols }) {
|
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 };
|
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`
|
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
|
FROM custom_cards c
|
||||||
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
||||||
WHERE c.id = ${designId} AND c.user_id = ${user.userId}
|
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) {
|
if (found.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Design not found' });
|
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 = {
|
const existing = {
|
||||||
...row,
|
...row,
|
||||||
custom_frame:
|
custom_frame:
|
||||||
|
|
@ -34,6 +35,8 @@ export default async function handler(req, res) {
|
||||||
name: frame_name,
|
name: frame_name,
|
||||||
palette: frame_palette,
|
palette: frame_palette,
|
||||||
texture_url: frame_texture,
|
texture_url: frame_texture,
|
||||||
|
frame_image_url: frame_image,
|
||||||
|
layout: frame_layout,
|
||||||
}
|
}
|
||||||
: null,
|
: 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,
|
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.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,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
FROM custom_cards c
|
FROM custom_cards c
|
||||||
LEFT JOIN custom_games g ON g.id = c.custom_game_id
|
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,
|
name: row.frame_name,
|
||||||
palette: row.frame_palette,
|
palette: row.frame_palette,
|
||||||
texture_url: row.frame_texture,
|
texture_url: row.frame_texture,
|
||||||
|
frame_image_url: row.frame_image,
|
||||||
|
layout: row.frame_layout,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
designs: designs.map(
|
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 { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
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) {
|
export default async function handler(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -54,13 +54,20 @@ export default async function handler(req, res) {
|
||||||
? req.body.texture_url.trim()
|
? req.body.texture_url.trim()
|
||||||
: found.rows[0].texture_url;
|
: 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`
|
const updated = await sql`
|
||||||
UPDATE custom_frames SET
|
UPDATE custom_frames SET
|
||||||
name = ${name}, palette = ${sql.json(palette)},
|
name = ${name}, palette = ${sql.json(palette)},
|
||||||
texture_url = ${textureUrl},
|
texture_url = ${textureUrl},
|
||||||
|
layout = ${sql.json(layout)},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ${frameId}
|
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] });
|
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 { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
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) {
|
export default async function handler(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -11,7 +11,8 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const result = await sql`
|
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
|
FROM custom_frames
|
||||||
WHERE user_id = ${user.userId}
|
WHERE user_id = ${user.userId}
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
|
|
@ -30,6 +31,12 @@ export default async function handler(req, res) {
|
||||||
return res.status(400).json({ error });
|
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 =
|
const textureUrl =
|
||||||
typeof req.body?.texture_url === 'string' && req.body.texture_url.trim()
|
typeof req.body?.texture_url === 'string' && req.body.texture_url.trim()
|
||||||
? 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`
|
const inserted = await sql`
|
||||||
INSERT INTO custom_frames (user_id, name, palette, texture_url)
|
INSERT INTO custom_frames (user_id, name, palette, texture_url, layout)
|
||||||
VALUES (${user.userId}, ${name}, ${sql.json(palette)}, ${textureUrl})
|
VALUES (${user.userId}, ${name}, ${sql.json(palette)}, ${textureUrl}, ${sql.json(layout)})
|
||||||
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(201).json({ frame: inserted.rows[0] });
|
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,
|
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.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,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
FROM custom_cards c
|
FROM custom_cards c
|
||||||
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
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
|
ORDER BY c.updated_at DESC
|
||||||
`;
|
`;
|
||||||
const designs = cards.rows.map((row) => {
|
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 {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
custom_frame:
|
custom_frame:
|
||||||
|
|
@ -46,6 +48,8 @@ export default async function handler(req, res) {
|
||||||
name: frame_name,
|
name: frame_name,
|
||||||
palette: frame_palette,
|
palette: frame_palette,
|
||||||
texture_url: frame_texture,
|
texture_url: frame_texture,
|
||||||
|
frame_image_url: frame_image,
|
||||||
|
layout: frame_layout,
|
||||||
}
|
}
|
||||||
: null,
|
: 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.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
|
||||||
c.frame_id, c.artwork_url, c.art_mode,
|
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.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
|
FROM custom_cards c
|
||||||
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
||||||
WHERE c.custom_game_id = ${gameId}
|
WHERE c.custom_game_id = ${gameId}
|
||||||
|
|
@ -39,7 +41,7 @@ export default async function handler(req, res) {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const designs = cards.rows.map((row) => {
|
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 {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
custom_frame:
|
custom_frame:
|
||||||
|
|
@ -49,6 +51,8 @@ export default async function handler(req, res) {
|
||||||
name: frame_name,
|
name: frame_name,
|
||||||
palette: frame_palette,
|
palette: frame_palette,
|
||||||
texture_url: frame_texture,
|
texture_url: frame_texture,
|
||||||
|
frame_image_url: frame_image,
|
||||||
|
layout: frame_layout,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../components/Layout';
|
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 { FRAMES, RARITIES, getFrame } from '../components/designer/frames';
|
||||||
import { EXISTING_GAMES } from '../lib/designer-games.js';
|
import { EXISTING_GAMES } from '../lib/designer-games.js';
|
||||||
import { PALETTE_SLOTS } from '../lib/frame-palette.js';
|
import { PALETTE_SLOTS } from '../lib/frame-palette.js';
|
||||||
|
|
@ -57,6 +58,8 @@ export default function Designer() {
|
||||||
const [uploadingSymbol, setUploadingSymbol] = useState(false);
|
const [uploadingSymbol, setUploadingSymbol] = useState(false);
|
||||||
const [uploadingTexture, setUploadingTexture] = useState(false);
|
const [uploadingTexture, setUploadingTexture] = useState(false);
|
||||||
const textureFileRef = useRef(null);
|
const textureFileRef = useRef(null);
|
||||||
|
const [uploadingFrameImage, setUploadingFrameImage] = useState(false);
|
||||||
|
const frameImageFileRef = useRef(null);
|
||||||
|
|
||||||
const cardRef = useRef(null);
|
const cardRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
@ -142,7 +145,17 @@ export default function Designer() {
|
||||||
|
|
||||||
const openNewFrameEditor = () => {
|
const openNewFrameEditor = () => {
|
||||||
const base = getFrame(design.frame_id).palette;
|
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) => {
|
const openEditFrameEditor = (frame) => {
|
||||||
|
|
@ -151,6 +164,13 @@ export default function Designer() {
|
||||||
name: frame.name,
|
name: frame.name,
|
||||||
palette: { ...frame.palette },
|
palette: { ...frame.palette },
|
||||||
texture_url: frame.texture_url || null,
|
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',
|
method: isNew ? 'POST' : 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
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();
|
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]));
|
const symbolsMap = Object.fromEntries(symbols.map((s) => [s.code, s.image_url]));
|
||||||
|
|
||||||
// Edit mode when ?id= is present
|
// Edit mode when ?id= is present
|
||||||
|
|
@ -739,45 +818,85 @@ export default function Designer() {
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{frameEditor.id && (
|
{frameEditor.id && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
ref={textureFileRef}
|
ref={textureFileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => handleUploadTexture(e.target.files?.[0])}
|
onChange={(e) => handleUploadTexture(e.target.files?.[0])}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => textureFileRef.current?.click()}
|
onClick={() => textureFileRef.current?.click()}
|
||||||
disabled={uploadingTexture}
|
disabled={uploadingTexture}
|
||||||
>
|
>
|
||||||
{uploadingTexture
|
{uploadingTexture
|
||||||
? 'Uploading…'
|
? 'Uploading…'
|
||||||
: frameEditor.texture_url
|
: frameEditor.texture_url
|
||||||
? 'Replace Texture'
|
? 'Replace Texture'
|
||||||
: 'Upload Texture'}
|
: 'Upload Texture'}
|
||||||
</Button>
|
</Button>
|
||||||
{frameEditor.texture_url && (
|
{frameEditor.texture_url && (
|
||||||
<Button variant="secondary" size="sm" onClick={handleRemoveTexture}>
|
<Button variant="secondary" size="sm" onClick={handleRemoveTexture}>
|
||||||
Remove Texture
|
Remove Texture
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
|
<input
|
||||||
Delete Frame
|
ref={frameImageFileRef}
|
||||||
</Button>
|
type="file"
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
accept="image/png,image/webp"
|
||||||
Textures show behind the frame panels.
|
className="hidden"
|
||||||
</span>
|
onChange={(e) => handleUploadFrameImage(e.target.files?.[0])}
|
||||||
</div>
|
/>
|
||||||
)}
|
<Button
|
||||||
{!frameEditor.id && (
|
variant="secondary"
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
size="sm"
|
||||||
Save the frame first to add a background texture.
|
onClick={() => frameImageFileRef.current?.click()}
|
||||||
</p>
|
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>
|
||||||
<div className="w-full max-w-[240px] shrink-0">
|
<div className="w-full max-w-[240px] shrink-0">
|
||||||
<p className="text-xs mb-2 text-center" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs mb-2 text-center" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
|
@ -789,6 +908,8 @@ export default function Designer() {
|
||||||
custom_frame: {
|
custom_frame: {
|
||||||
palette: frameEditor.palette,
|
palette: frameEditor.palette,
|
||||||
texture_url: frameEditor.texture_url,
|
texture_url: frameEditor.texture_url,
|
||||||
|
frame_image_url: frameEditor.frame_image_url,
|
||||||
|
layout: frameEditor.layout,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
symbols={symbolsMap}
|
symbols={symbolsMap}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { sql } from '../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../lib/permission-middleware';
|
||||||
import handler from '../../pages/api/custom-frames/index.js';
|
import handler from '../../pages/api/custom-frames/index.js';
|
||||||
import itemHandler from '../../pages/api/custom-frames/[id].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() {
|
function createRes() {
|
||||||
const res = {
|
const res = {
|
||||||
|
|
@ -31,6 +31,11 @@ const GOOD_PALETTE = Object.fromEntries(
|
||||||
PALETTE_SLOTS.map(({ key }) => [key, '#123456'])
|
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', () => {
|
describe('validatePalette', () => {
|
||||||
it('accepts and normalizes a complete palette', () => {
|
it('accepts and normalizes a complete palette', () => {
|
||||||
const { palette, error } = validatePalette(
|
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', () => {
|
describe('/api/custom-frames', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -79,6 +103,18 @@ describe('/api/custom-frames', () => {
|
||||||
expect(res.body.error).toContain('Invalid or missing color');
|
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 () => {
|
it('rejects duplicate frame names', async () => {
|
||||||
sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits
|
sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits
|
||||||
const res = createRes();
|
const res = createRes();
|
||||||
|
|
@ -91,11 +127,11 @@ describe('/api/custom-frames', () => {
|
||||||
expect(res.statusCode).toBe(409);
|
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
|
sql
|
||||||
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
|
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
|
||||||
.mockResolvedValueOnce({
|
.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();
|
const res = createRes();
|
||||||
|
|
@ -106,6 +142,7 @@ describe('/api/custom-frames', () => {
|
||||||
|
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
expect(res.body.frame.name).toBe('Molten');
|
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);
|
expect(res.statusCode).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates name and palette', async () => {
|
it('updates name, palette, and layout', async () => {
|
||||||
sql
|
sql
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
|
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
|
||||||
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
|
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
|
||||||
.mockResolvedValueOnce({
|
.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();
|
const res = createRes();
|
||||||
await itemHandler(
|
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
|
res
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{}
|
|
||||||
Loading…
Reference in a new issue