deckhearth/pages/api/custom-frames/index.js
Randall Stillwell 027ddcf83e 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.
2026-09-01 09:23:16 -05:00

66 lines
2.4 KiB
JavaScript

import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
import { validatePalette, validateLayout, DEFAULT_LAYOUT } from '../../../lib/frame-palette.js';
export default async function handler(req, res) {
try {
const user = await getUserFromRequest(req);
if (!user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.method === 'GET') {
const result = await sql`
SELECT id, name, palette, texture_url, frame_image_url, layout,
created_at, updated_at
FROM custom_frames
WHERE user_id = ${user.userId}
ORDER BY name ASC
`;
return res.status(200).json({ frames: result.rows });
}
if (req.method === 'POST') {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
if (!name || name.length > 100) {
return res.status(400).json({ error: 'Frame name is required (100 characters max)' });
}
const { palette, error } = validatePalette(req.body?.palette);
if (error) {
return res.status(400).json({ error });
}
const 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()
: null;
const clash = await sql`
SELECT id FROM custom_frames
WHERE user_id = ${user.userId} AND lower(name) = ${name.toLowerCase()}
`;
if (clash.rows.length > 0) {
return res.status(409).json({ error: 'You already have a frame with that name' });
}
const inserted = await sql`
INSERT INTO custom_frames (user_id, name, palette, 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] });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error) {
console.error('Custom frames API error:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}