feat(scanner): add debug instrumentation for vision pipeline timing #155
14 changed files with 1202 additions and 79 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
/* eslint-disable @next/next/no-img-element -- Artwork comes from MinIO CDN / data URLs; next/image is out of scope for the designer canvas. */
|
/* eslint-disable @next/next/no-img-element -- Artwork/symbols come from MinIO CDN / data URLs; next/image is out of scope for the designer canvas. */
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { getFrame, getRarity } from './frames';
|
import { getFrame, getRarity } from './frames';
|
||||||
|
|
@ -8,23 +8,31 @@ 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;
|
||||||
|
|
||||||
|
/** Resolve the active palette: a custom frame's palette when linked,
|
||||||
|
* otherwise the starter frame's. */
|
||||||
|
export function resolvePalette(design) {
|
||||||
|
if (design.custom_frame?.palette) {
|
||||||
|
return design.custom_frame.palette;
|
||||||
|
}
|
||||||
|
return getFrame(design.frame_id).palette;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* so html-to-image can rasterize it 1:1 during PNG export.
|
* so html-to-image can rasterize it 1:1 during PNG export.
|
||||||
*/
|
*/
|
||||||
export default function CardFrame({ design, innerRef }) {
|
export default function CardFrame({ design, innerRef, symbols }) {
|
||||||
if (design.art_mode === 'fullart') {
|
if (design.art_mode === 'fullart') {
|
||||||
return <FullArtCard design={design} innerRef={innerRef} />;
|
return <FullArtCard design={design} innerRef={innerRef} symbols={symbols} />;
|
||||||
}
|
}
|
||||||
return <FramedCard design={design} innerRef={innerRef} />;
|
return <FramedCard design={design} innerRef={innerRef} symbols={symbols} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─────────────────────────── framed layout ─────────────────────────── */
|
/* ─────────────────────────── framed layout ─────────────────────────── */
|
||||||
|
|
||||||
function FramedCard({ design, innerRef }) {
|
function FramedCard({ design, innerRef, symbols }) {
|
||||||
const frame = getFrame(design.frame_id);
|
const p = resolvePalette(design);
|
||||||
const p = frame.palette;
|
|
||||||
const showPt = Boolean(design.power || design.toughness);
|
const showPt = Boolean(design.power || design.toughness);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,7 +81,7 @@ function FramedCard({ design, innerRef }) {
|
||||||
>
|
>
|
||||||
{design.name || 'Untitled Card'}
|
{design.name || 'Untitled Card'}
|
||||||
</span>
|
</span>
|
||||||
<ManaPips cost={design.mana_cost} accent={p.accent} />
|
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Artwork window */}
|
{/* Artwork window */}
|
||||||
|
|
@ -136,9 +144,8 @@ function FramedCard({ design, innerRef }) {
|
||||||
|
|
||||||
/* ─────────────────────────── full-art layout ───────────────────────── */
|
/* ─────────────────────────── full-art layout ───────────────────────── */
|
||||||
|
|
||||||
function FullArtCard({ design, innerRef }) {
|
function FullArtCard({ design, innerRef, symbols }) {
|
||||||
const frame = getFrame(design.frame_id);
|
const p = resolvePalette(design);
|
||||||
const p = frame.palette;
|
|
||||||
const showPt = Boolean(design.power || design.toughness);
|
const showPt = Boolean(design.power || design.toughness);
|
||||||
const hasArt = Boolean(design.artwork_url);
|
const hasArt = Boolean(design.artwork_url);
|
||||||
|
|
||||||
|
|
@ -208,7 +215,7 @@ function FullArtCard({ design, innerRef }) {
|
||||||
>
|
>
|
||||||
{design.name || 'Untitled Card'}
|
{design.name || 'Untitled Card'}
|
||||||
</span>
|
</span>
|
||||||
<ManaPips cost={design.mana_cost} accent={p.accent} />
|
<ManaPips cost={design.mana_cost} accent={p.accent} symbols={symbols} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom scrim: type line, text, P/T */}
|
{/* Bottom scrim: type line, text, P/T */}
|
||||||
|
|
@ -517,7 +524,7 @@ function starPath(s) {
|
||||||
* Scales CardFrame to fit the available width while preserving the
|
* Scales CardFrame to fit the available width while preserving the
|
||||||
* natural 420x588 layout (transform keeps export coordinates intact).
|
* natural 420x588 layout (transform keeps export coordinates intact).
|
||||||
*/
|
*/
|
||||||
export function CardPreview({ design, innerRef, maxWidth = 420 }) {
|
export function CardPreview({ design, innerRef, maxWidth = 420, symbols }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
|
|
||||||
|
|
@ -546,7 +553,7 @@ export function CardPreview({ design, innerRef, maxWidth = 420 }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}>
|
<div style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}>
|
||||||
<CardFrame design={design} innerRef={innerRef} />
|
<CardFrame design={design} innerRef={innerRef} symbols={symbols} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -556,9 +563,10 @@ export function CardPreview({ design, innerRef, maxWidth = 420 }) {
|
||||||
/**
|
/**
|
||||||
* Renders a mana cost string as pip circles. Accepts both scryfall
|
* Renders a mana cost string as pip circles. Accepts both scryfall
|
||||||
* braces ("{2}{R}{R}") and plain notation ("2RR"), plus arbitrary
|
* braces ("{2}{R}{R}") and plain notation ("2RR"), plus arbitrary
|
||||||
* symbols for custom games.
|
* symbols for custom games. When `symbols` maps a token to an icon URL,
|
||||||
|
* the icon renders inside the pip instead of text.
|
||||||
*/
|
*/
|
||||||
export function ManaPips({ cost, accent }) {
|
export function ManaPips({ cost, accent, symbols }) {
|
||||||
if (!cost || !cost.trim()) return null;
|
if (!cost || !cost.trim()) return null;
|
||||||
|
|
||||||
const tokens = /\{/.test(cost)
|
const tokens = /\{/.test(cost)
|
||||||
|
|
@ -567,9 +575,21 @@ export function ManaPips({ cost, accent }) {
|
||||||
|
|
||||||
if (tokens.length === 0) return null;
|
if (tokens.length === 0) return null;
|
||||||
|
|
||||||
|
const iconFor = (token) => {
|
||||||
|
if (!symbols) return null;
|
||||||
|
return (
|
||||||
|
symbols[token] ||
|
||||||
|
symbols[token.toLowerCase()] ||
|
||||||
|
symbols[token.toUpperCase()] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', gap: 3, flexShrink: 0 }}>
|
<span style={{ display: 'inline-flex', gap: 3, flexShrink: 0 }}>
|
||||||
{tokens.slice(0, 8).map((token, i) => (
|
{tokens.slice(0, 8).map((token, i) => {
|
||||||
|
const icon = iconFor(token);
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
key={`${token}-${i}`}
|
key={`${token}-${i}`}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -585,11 +605,21 @@ export function ManaPips({ cost, accent }) {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: 'Georgia, serif',
|
fontFamily: 'Georgia, serif',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{token}
|
{icon ? (
|
||||||
|
<img
|
||||||
|
src={icon}
|
||||||
|
alt={token}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
token
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,6 @@ export function pickDesignFields(body = {}) {
|
||||||
artMode: artMode === 'fullart' ? 'fullart' : 'framed',
|
artMode: artMode === 'fullart' ? 'fullart' : 'framed',
|
||||||
gameTarget,
|
gameTarget,
|
||||||
customGameId: int(body.custom_game_id) || int(body.customGameId),
|
customGameId: int(body.custom_game_id) || int(body.customGameId),
|
||||||
|
customFrameId: int(body.custom_frame_id) || int(body.customFrameId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
lib/frame-palette.js
Normal file
38
lib/frame-palette.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Palette contract for card frames. Starter and custom frames share the
|
||||||
|
* same slots; CardFrame resolves a palette from these keys.
|
||||||
|
*/
|
||||||
|
export const PALETTE_SLOTS = [
|
||||||
|
{ key: 'outer', label: 'Card body' },
|
||||||
|
{ key: 'border', label: 'Frame border' },
|
||||||
|
{ key: 'titleBar', label: 'Title bar' },
|
||||||
|
{ key: 'typeBar', label: 'Type bar' },
|
||||||
|
{ key: 'textBox', label: 'Text box' },
|
||||||
|
{ key: 'artBacking', label: 'Art backing' },
|
||||||
|
{ key: 'text', label: 'Body text' },
|
||||||
|
{ key: 'titleText', label: 'Title text' },
|
||||||
|
{ key: 'accent', label: 'Accent' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate/normalize a palette object. Returns { palette } with all nine
|
||||||
|
* slots as lowercase hex, or { error } when a slot is missing/invalid.
|
||||||
|
*/
|
||||||
|
export function validatePalette(input) {
|
||||||
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
|
return { error: 'Palette must be an object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const palette = {};
|
||||||
|
for (const { key } of PALETTE_SLOTS) {
|
||||||
|
const value = input[key];
|
||||||
|
if (typeof value !== 'string' || !HEX_RE.test(value.trim())) {
|
||||||
|
return { error: `Invalid or missing color for "${key}"` };
|
||||||
|
}
|
||||||
|
palette[key] = value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { palette };
|
||||||
|
}
|
||||||
56
migrations/1787700511000_add-custom-frames-symbols.js
Normal file
56
migrations/1787700511000_add-custom-frames-symbols.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/**
|
||||||
|
* Phase 3 — user-created frames and cost symbols.
|
||||||
|
*
|
||||||
|
* custom_frames: private per-user frames. `palette` holds the full slot
|
||||||
|
* map (outer, border, titleBar, typeBar, textBox, artBacking, text,
|
||||||
|
* titleText, accent) as JSONB so new slots stay additive.
|
||||||
|
*
|
||||||
|
* custom_symbols: user-uploaded cost icons keyed by a short code used in
|
||||||
|
* mana cost strings ({F}, {2}{F}...).
|
||||||
|
*
|
||||||
|
* custom_cards.custom_frame_id: set when a design uses a custom frame;
|
||||||
|
* NULL means the starter frame in `frame_id` applies.
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_frames (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
palette JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_custom_frames_user_name UNIQUE (user_id, name)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_symbols (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
code VARCHAR(10) NOT NULL,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_custom_symbols_user_code UNIQUE (user_id, code)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_cards
|
||||||
|
ADD COLUMN IF NOT EXISTS custom_frame_id INTEGER REFERENCES custom_frames(id) ON DELETE SET NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_cards_custom_frame_id
|
||||||
|
ON custom_cards(custom_frame_id)
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_cards DROP COLUMN IF EXISTS custom_frame_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`DROP TABLE IF EXISTS custom_symbols CASCADE`);
|
||||||
|
pgm.sql(`DROP TABLE IF EXISTS custom_frames CASCADE`);
|
||||||
|
};
|
||||||
|
|
@ -16,13 +16,22 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = await sql`
|
const found = await sql`
|
||||||
SELECT * FROM custom_cards
|
SELECT c.*, f.name AS frame_name, f.palette AS frame_palette
|
||||||
WHERE id = ${designId} AND user_id = ${user.userId}
|
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}
|
||||||
`;
|
`;
|
||||||
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 existing = found.rows[0];
|
const { frame_name, frame_palette, ...row } = found.rows[0];
|
||||||
|
const existing = {
|
||||||
|
...row,
|
||||||
|
custom_frame:
|
||||||
|
frame_name != null
|
||||||
|
? { id: row.custom_frame_id, name: frame_name, palette: frame_palette }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
return res.status(200).json({ design: existing });
|
return res.status(200).json({ design: existing });
|
||||||
|
|
@ -44,6 +53,7 @@ export default async function handler(req, res) {
|
||||||
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
|
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
|
||||||
art_mode = ${f.artMode},
|
art_mode = ${f.artMode},
|
||||||
game_target = ${f.gameTarget}, custom_game_id = ${f.customGameId},
|
game_target = ${f.gameTarget}, custom_game_id = ${f.customGameId},
|
||||||
|
custom_frame_id = ${f.customFrameId},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ${designId}
|
WHERE id = ${designId}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,24 @@ 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,
|
||||||
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,
|
||||||
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
|
||||||
|
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
||||||
WHERE c.user_id = ${user.userId}
|
WHERE c.user_id = ${user.userId}
|
||||||
ORDER BY c.updated_at DESC
|
ORDER BY c.updated_at DESC
|
||||||
`;
|
`;
|
||||||
return res.status(200).json({ designs: result.rows });
|
const designs = result.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
custom_frame:
|
||||||
|
row.frame_pk != null
|
||||||
|
? { id: row.frame_pk, name: row.frame_name, palette: row.frame_palette }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
return res.status(200).json({
|
||||||
|
designs: designs.map(({ frame_pk, frame_name, frame_palette, ...rest }) => rest),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
|
|
@ -53,12 +64,12 @@ async function createDesign(userId, body) {
|
||||||
INSERT INTO custom_cards
|
INSERT INTO custom_cards
|
||||||
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
||||||
actions, flavor_quote, power, toughness, frame_id, artwork_url,
|
actions, flavor_quote, power, toughness, frame_id, artwork_url,
|
||||||
art_mode, game_target, custom_game_id)
|
art_mode, game_target, custom_game_id, custom_frame_id)
|
||||||
VALUES
|
VALUES
|
||||||
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
||||||
${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power},
|
${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power},
|
||||||
${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode},
|
${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode},
|
||||||
${f.gameTarget}, ${f.customGameId})
|
${f.gameTarget}, ${f.customGameId}, ${f.customFrameId})
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
const design = inserted.rows[0];
|
const design = inserted.rows[0];
|
||||||
|
|
|
||||||
72
pages/api/custom-frames/[id].js
Normal file
72
pages/api/custom-frames/[id].js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { sql } from '../../../lib/sql.js';
|
||||||
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { validatePalette } 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 * 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
return res.status(200).json({ frame: found.rows[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'PUT') {
|
||||||
|
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 clash = await sql`
|
||||||
|
SELECT id FROM custom_frames
|
||||||
|
WHERE user_id = ${user.userId}
|
||||||
|
AND lower(name) = ${name.toLowerCase()}
|
||||||
|
AND id <> ${frameId}
|
||||||
|
`;
|
||||||
|
if (clash.rows.length > 0) {
|
||||||
|
return res.status(409).json({ error: 'You already have a frame with that name' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await sql`
|
||||||
|
UPDATE custom_frames SET
|
||||||
|
name = ${name}, palette = ${sql.json(palette)},
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${frameId}
|
||||||
|
RETURNING id, name, palette, created_at, updated_at
|
||||||
|
`;
|
||||||
|
return res.status(200).json({ frame: updated.rows[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
// Designs using this frame fall back to their starter frame_id
|
||||||
|
// (custom_frame_id drops to NULL via FK).
|
||||||
|
await sql`DELETE FROM custom_frames WHERE id = ${frameId}`;
|
||||||
|
return res.status(200).json({ message: 'Frame deleted' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Custom frame API error:', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
54
pages/api/custom-frames/index.js
Normal file
54
pages/api/custom-frames/index.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { sql } from '../../../lib/sql.js';
|
||||||
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
import { validatePalette } 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, 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 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)
|
||||||
|
VALUES (${user.userId}, ${name}, ${sql.json(palette)})
|
||||||
|
RETURNING id, name, palette, 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
pages/api/custom-symbols/[id].js
Normal file
41
pages/api/custom-symbols/[id].js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { del } from '../../../lib/object-storage.js';
|
||||||
|
import { sql } from '../../../lib/sql.js';
|
||||||
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbolId = parseInt(req.query.id, 10);
|
||||||
|
if (!Number.isInteger(symbolId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid symbol id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'DELETE') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await sql`
|
||||||
|
SELECT id, image_url FROM custom_symbols
|
||||||
|
WHERE id = ${symbolId} AND user_id = ${user.userId}
|
||||||
|
`;
|
||||||
|
if (found.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Symbol not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await del(found.rows[0].image_url);
|
||||||
|
} catch (blobError) {
|
||||||
|
console.warn('Failed to delete symbol image:', blobError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`DELETE FROM custom_symbols WHERE id = ${symbolId}`;
|
||||||
|
return res.status(200).json({ message: 'Symbol deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Custom symbol API error:', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
158
pages/api/custom-symbols/index.js
Normal file
158
pages/api/custom-symbols/index.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
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: '2mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ['image/png', 'image/webp', 'image/svg+xml'];
|
||||||
|
const CODE_RE = /^[A-Za-z0-9]{1,10}$/;
|
||||||
|
|
||||||
|
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, code, image_url, created_at
|
||||||
|
FROM custom_symbols
|
||||||
|
WHERE user_id = ${user.userId}
|
||||||
|
ORDER BY code ASC
|
||||||
|
`;
|
||||||
|
return res.status(200).json({ symbols: result.rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 code = (formData.code || '').trim();
|
||||||
|
const file = formData.image;
|
||||||
|
|
||||||
|
if (!CODE_RE.test(code)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Symbol code must be 1-10 letters or numbers (e.g. F, E, 10)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
return res.status(400).json({ error: 'No symbol image provided' });
|
||||||
|
}
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid file type. Please upload a PNG, WebP, or SVG image.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
return res.status(400).json({ error: 'File size must be less than 2MB' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace any existing symbol with the same code.
|
||||||
|
const existing = await sql`
|
||||||
|
SELECT id, image_url FROM custom_symbols
|
||||||
|
WHERE user_id = ${user.userId} AND code = ${code}
|
||||||
|
`;
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
try {
|
||||||
|
await del(existing.rows[0].image_url);
|
||||||
|
} catch (blobError) {
|
||||||
|
console.warn('Failed to delete old symbol image:', blobError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.type === 'image/svg+xml' ? 'svg' : file.type.split('/')[1];
|
||||||
|
const filename = `symbols/${user.userId}-${code.toLowerCase()}-${Date.now()}.${ext}`;
|
||||||
|
const blob = await put(filename, file.buffer, {
|
||||||
|
access: 'public',
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await sql`
|
||||||
|
INSERT INTO custom_symbols (user_id, code, image_url)
|
||||||
|
VALUES (${user.userId}, ${code}, ${blob.url})
|
||||||
|
ON CONFLICT (user_id, code)
|
||||||
|
DO UPDATE SET image_url = ${blob.url}, created_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, code, image_url, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
return res.status(existing.rows.length > 0 ? 200 : 201).json({ symbol: saved.rows[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Custom symbols API error:', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,10 @@ 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 CardPreview from '../components/designer/CardFrame';
|
import CardFrame, { CardPreview } from '../components/designer/CardFrame';
|
||||||
import { FRAMES, RARITIES } 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 { Button } from '../components/ui';
|
import { Button } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
|
|
||||||
|
|
@ -24,8 +25,13 @@ const BLANK_DESIGN = {
|
||||||
art_mode: 'framed',
|
art_mode: 'framed',
|
||||||
game_target: 'custom',
|
game_target: 'custom',
|
||||||
custom_game_id: null,
|
custom_game_id: null,
|
||||||
|
custom_frame_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BLANK_PALETTE = Object.fromEntries(
|
||||||
|
PALETTE_SLOTS.map(({ key }) => [key, getFrame('classic').palette[key]])
|
||||||
|
);
|
||||||
|
|
||||||
export default function Designer() {
|
export default function Designer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
@ -41,6 +47,15 @@ export default function Designer() {
|
||||||
const [newGameName, setNewGameName] = useState('');
|
const [newGameName, setNewGameName] = useState('');
|
||||||
const [creatingGame, setCreatingGame] = useState(false);
|
const [creatingGame, setCreatingGame] = useState(false);
|
||||||
|
|
||||||
|
// Custom frames + symbols
|
||||||
|
const [frames, setFrames] = useState([]);
|
||||||
|
const [symbols, setSymbols] = useState([]);
|
||||||
|
const [frameEditor, setFrameEditor] = useState(null); // { id, name, palette } | null
|
||||||
|
const [savingFrame, setSavingFrame] = useState(false);
|
||||||
|
const [symbolCode, setSymbolCode] = useState('');
|
||||||
|
const symbolFileRef = useRef(null);
|
||||||
|
const [uploadingSymbol, setUploadingSymbol] = useState(false);
|
||||||
|
|
||||||
const cardRef = useRef(null);
|
const cardRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -61,9 +76,37 @@ export default function Designer() {
|
||||||
}
|
}
|
||||||
}, [authHeaders]);
|
}, [authHeaders]);
|
||||||
|
|
||||||
|
const loadFrames = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/custom-frames', { headers: authHeaders() });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setFrames(data.frames);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — starter frames still work.
|
||||||
|
}
|
||||||
|
}, [authHeaders]);
|
||||||
|
|
||||||
|
const loadSymbols = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/custom-symbols', { headers: authHeaders() });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSymbols(data.symbols);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — text pips still work.
|
||||||
|
}
|
||||||
|
}, [authHeaders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) loadGames(); // eslint-disable-line react-hooks/set-state-in-effect -- fetch-driven reload via async loadGames
|
if (user) {
|
||||||
}, [user, loadGames]);
|
loadGames(); // eslint-disable-line react-hooks/set-state-in-effect -- fetch-driven reload via async loadGames
|
||||||
|
loadFrames();
|
||||||
|
loadSymbols();
|
||||||
|
}
|
||||||
|
}, [user, loadGames, loadFrames, loadSymbols]);
|
||||||
|
|
||||||
const handleCreateGame = async () => {
|
const handleCreateGame = async () => {
|
||||||
const name = newGameName.trim();
|
const name = newGameName.trim();
|
||||||
|
|
@ -95,6 +138,124 @@ export default function Designer() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openNewFrameEditor = () => {
|
||||||
|
const base = getFrame(design.frame_id).palette;
|
||||||
|
setFrameEditor({ id: null, name: '', palette: { ...base } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditFrameEditor = (frame) => {
|
||||||
|
setFrameEditor({ id: frame.id, name: frame.name, palette: { ...frame.palette } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveFrame = async () => {
|
||||||
|
if (!frameEditor) return;
|
||||||
|
const name = frameEditor.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
setMessage({ kind: 'error', text: 'Give the frame a name first.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingFrame(true);
|
||||||
|
try {
|
||||||
|
const isNew = !frameEditor.id;
|
||||||
|
const response = await fetch(
|
||||||
|
isNew ? '/api/custom-frames' : `/api/custom-frames/${frameEditor.id}`,
|
||||||
|
{
|
||||||
|
method: isNew ? 'POST' : 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify({ name, palette: frameEditor.palette }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
setFrames((prev) =>
|
||||||
|
isNew
|
||||||
|
? [...prev, data.frame].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
: prev.map((f) => (f.id === data.frame.id ? data.frame : f))
|
||||||
|
);
|
||||||
|
setDesign((prev) => ({ ...prev, custom_frame_id: data.frame.id }));
|
||||||
|
setFrameEditor(null);
|
||||||
|
setMessage({ kind: 'success', text: `Frame "${data.frame.name}" saved.` });
|
||||||
|
} else {
|
||||||
|
setMessage({ kind: 'error', text: data.error || 'Could not save frame.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ kind: 'error', text: 'Could not save frame.' });
|
||||||
|
} finally {
|
||||||
|
setSavingFrame(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFrame = async (frameId) => {
|
||||||
|
if (!window.confirm('Delete this frame? Cards using it fall back to their starter frame.')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/custom-frames/${frameId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setFrames((prev) => prev.filter((f) => f.id !== frameId));
|
||||||
|
setDesign((prev) =>
|
||||||
|
prev.custom_frame_id === frameId ? { ...prev, custom_frame_id: null } : prev
|
||||||
|
);
|
||||||
|
if (frameEditor?.id === frameId) setFrameEditor(null);
|
||||||
|
} else {
|
||||||
|
setMessage({ kind: 'error', text: 'Could not delete frame.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ kind: 'error', text: 'Could not delete frame.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSymbolUpload = async (file) => {
|
||||||
|
const code = symbolCode.trim();
|
||||||
|
if (!file || !code) return;
|
||||||
|
setUploadingSymbol(true);
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('code', code);
|
||||||
|
body.append('image', file);
|
||||||
|
const response = await fetch('/api/custom-symbols', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
setSymbols((prev) => {
|
||||||
|
const next = prev.filter((s) => s.code !== data.symbol.code);
|
||||||
|
return [...next, data.symbol].sort((a, b) => a.code.localeCompare(b.code));
|
||||||
|
});
|
||||||
|
setSymbolCode('');
|
||||||
|
setMessage({ kind: 'success', text: `Symbol {${data.symbol.code}} saved.` });
|
||||||
|
} else {
|
||||||
|
setMessage({ kind: 'error', text: data.error || 'Symbol upload failed.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ kind: 'error', text: 'Symbol upload failed.' });
|
||||||
|
} finally {
|
||||||
|
setUploadingSymbol(false);
|
||||||
|
if (symbolFileRef.current) symbolFileRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSymbol = async (symbolId) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/custom-symbols/${symbolId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setSymbols((prev) => prev.filter((s) => s.id !== symbolId));
|
||||||
|
} else {
|
||||||
|
setMessage({ kind: 'error', text: 'Could not delete symbol.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ kind: 'error', text: 'Could not delete symbol.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const symbolsMap = Object.fromEntries(symbols.map((s) => [s.code, s.image_url]));
|
||||||
|
|
||||||
// Edit mode when ?id= is present
|
// Edit mode when ?id= is present
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !router.query.id) return;
|
if (!user || !router.query.id) return;
|
||||||
|
|
@ -387,26 +548,37 @@ export default function Designer() {
|
||||||
|
|
||||||
{/* Frame picker */}
|
{/* Frame picker */}
|
||||||
<section className="glass-panel rounded-2xl p-5">
|
<section className="glass-panel rounded-2xl p-5">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Frame
|
Frame
|
||||||
</h2>
|
</h2>
|
||||||
|
<Button variant="secondary" size="sm" onClick={openNewFrameEditor}>
|
||||||
|
+ New Frame
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{FRAMES.map((frame) => (
|
{FRAMES.map((frame) => {
|
||||||
|
const active = !design.custom_frame_id && design.frame_id === frame.id;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={frame.id}
|
key={frame.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setField('frame_id', frame.id)}
|
onClick={() =>
|
||||||
|
setDesign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
frame_id: frame.id,
|
||||||
|
custom_frame_id: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
className={`rounded-xl p-3 text-left transition-all duration-200 border ${
|
className={`rounded-xl p-3 text-left transition-all duration-200 border ${
|
||||||
design.frame_id === frame.id
|
active ? 'shadow-lg scale-[1.02]' : 'opacity-80 hover:opacity-100'
|
||||||
? 'shadow-lg scale-[1.02]'
|
|
||||||
: 'opacity-80 hover:opacity-100'
|
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: frame.palette.outer,
|
backgroundColor: frame.palette.outer,
|
||||||
borderColor: design.frame_id === frame.id ? frame.palette.border : 'transparent',
|
borderColor: active ? frame.palette.border : 'transparent',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
}}
|
}}
|
||||||
aria-pressed={design.frame_id === frame.id}
|
aria-pressed={active}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full h-8 rounded mb-2"
|
className="w-full h-8 rounded mb-2"
|
||||||
|
|
@ -419,8 +591,181 @@ export default function Designer() {
|
||||||
{frame.name}
|
{frame.name}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{frames.map((frame) => {
|
||||||
|
const p = frame.palette;
|
||||||
|
const active = design.custom_frame_id === frame.id;
|
||||||
|
return (
|
||||||
|
<div key={frame.id} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDesign((prev) => ({ ...prev, custom_frame_id: frame.id }))}
|
||||||
|
onDoubleClick={() => openEditFrameEditor(frame)}
|
||||||
|
className={`w-full rounded-xl p-3 text-left transition-all duration-200 border ${
|
||||||
|
active ? 'shadow-lg scale-[1.02]' : 'opacity-80 hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: p.outer,
|
||||||
|
borderColor: active ? p.border : 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={`${frame.name} — double-click to edit`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-8 rounded mb-2"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${p.titleBar}, ${p.textBox})`,
|
||||||
|
border: `1px solid ${p.border}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold block truncate" style={{ color: p.titleText }}>
|
||||||
|
{frame.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEditFrameEditor(frame)}
|
||||||
|
className="absolute top-1 right-1 text-[10px] px-1.5 py-0.5 rounded"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: p.titleText }}
|
||||||
|
aria-label={`Edit ${frame.name}`}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame editor */}
|
||||||
|
{frameEditor && (
|
||||||
|
<div className="mt-4 rounded-xl p-4 border" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input-field flex-1"
|
||||||
|
value={frameEditor.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrameEditor((prev) => ({ ...prev, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Frame name…"
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" onClick={handleSaveFrame} disabled={savingFrame}>
|
||||||
|
{savingFrame ? 'Saving…' : frameEditor.id ? 'Update' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setFrameEditor(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{PALETTE_SLOTS.map(({ key, label }) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={frameEditor.palette[key]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrameEditor((prev) => ({
|
||||||
|
...prev,
|
||||||
|
palette: { ...prev.palette, [key]: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-7 h-7 rounded cursor-pointer bg-transparent"
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{frameEditor.id && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
|
||||||
|
Delete Frame
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-[240px] shrink-0">
|
||||||
|
<p className="text-xs mb-2 text-center" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Live frame preview
|
||||||
|
</p>
|
||||||
|
<CardFrame
|
||||||
|
design={{
|
||||||
|
...design,
|
||||||
|
custom_frame: { palette: frameEditor.palette },
|
||||||
|
}}
|
||||||
|
symbols={symbolsMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Cost symbols */}
|
||||||
|
<section className="glass-panel rounded-2xl p-5">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Cost Symbols
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Upload small icons and use them in the Cost field as <code>{'{CODE}'}</code> or plain letters — e.g.{' '}
|
||||||
|
<code>{'2{F}{F}'}</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{symbols.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{symbols.map((symbol) => (
|
||||||
|
<span
|
||||||
|
key={symbol.id}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full pl-1 pr-2 py-1 border text-xs font-semibold"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- CDN icon */}
|
||||||
|
<img
|
||||||
|
src={symbol.image_url}
|
||||||
|
alt={symbol.code}
|
||||||
|
className="w-6 h-6 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{symbol.code}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteSymbol(symbol.id)}
|
||||||
|
className="ml-1 opacity-60 hover:opacity-100"
|
||||||
|
aria-label={`Delete symbol ${symbol.code}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={symbolFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/webp,image/svg+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleSymbolUpload(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="input-field w-28"
|
||||||
|
value={symbolCode}
|
||||||
|
onChange={(e) => setSymbolCode(e.target.value)}
|
||||||
|
placeholder="Code (F)"
|
||||||
|
maxLength={10}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => symbolFileRef.current?.click()}
|
||||||
|
disabled={uploadingSymbol || !symbolCode.trim()}
|
||||||
|
>
|
||||||
|
{uploadingSymbol ? 'Uploading…' : 'Upload Icon'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
PNG, WebP, or SVG · up to 2MB · re-uploading a code replaces it
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Artwork */}
|
{/* Artwork */}
|
||||||
|
|
@ -597,7 +942,7 @@ export default function Designer() {
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Live Preview
|
Live Preview
|
||||||
</h2>
|
</h2>
|
||||||
<CardPreview design={design} innerRef={cardRef} />
|
<CardPreview design={design} innerRef={cardRef} symbols={symbolsMap} />
|
||||||
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Saved designs appear in{' '}
|
Saved designs appear in{' '}
|
||||||
<Link href="/my-designs" style={{ color: 'var(--accent-ember)' }}>My Designs</Link>{' '}
|
<Link href="/my-designs" style={{ color: 'var(--accent-ember)' }}>My Designs</Link>{' '}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ describe('GET /api/custom-cards', () => {
|
||||||
expect(sql).not.toHaveBeenCalled();
|
expect(sql).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the user designs', async () => {
|
it('returns the user designs with resolved custom frames', async () => {
|
||||||
const design = { id: 5, name: 'Emberwing', card_id: 77 };
|
const design = { id: 5, name: 'Emberwing', card_id: 77 };
|
||||||
sql.mockResolvedValueOnce({ rows: [design] });
|
sql.mockResolvedValueOnce({ rows: [design] });
|
||||||
const res = createRes();
|
const res = createRes();
|
||||||
|
|
@ -51,7 +51,7 @@ describe('GET /api/custom-cards', () => {
|
||||||
await handler({ method: 'GET' }, res);
|
await handler({ method: 'GET' }, res);
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body.designs).toEqual([design]);
|
expect(res.body.designs).toEqual([{ ...design, custom_frame: null }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
158
test/api/custom-frames.test.js
Normal file
158
test/api/custom-frames.test.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
|
||||||
|
vi.mock('../../lib/permission-middleware', () => ({
|
||||||
|
getUserFromRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
function createRes() {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(code) {
|
||||||
|
res.statusCode = code;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
json(data) {
|
||||||
|
res.body = data;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOOD_PALETTE = Object.fromEntries(
|
||||||
|
PALETTE_SLOTS.map(({ key }) => [key, '#123456'])
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('validatePalette', () => {
|
||||||
|
it('accepts and normalizes a complete palette', () => {
|
||||||
|
const { palette, error } = validatePalette(
|
||||||
|
Object.fromEntries(PALETTE_SLOTS.map(({ key }) => [key, '#ABCDEF']))
|
||||||
|
);
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(palette.outer).toBe('#abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing slots and bad colors', () => {
|
||||||
|
expect(validatePalette({}).error).toBeTruthy();
|
||||||
|
const missing = { ...GOOD_PALETTE };
|
||||||
|
delete missing.accent;
|
||||||
|
expect(validatePalette(missing).error).toBeTruthy();
|
||||||
|
expect(validatePalette({ ...GOOD_PALETTE, border: 'red' }).error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/custom-frames', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||||
|
sql.mockResolvedValue({ rows: [] });
|
||||||
|
sql.json = vi.fn((v) => v);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication', async () => {
|
||||||
|
getUserFromRequest.mockResolvedValue(null);
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler({ method: 'GET' }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(sql).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid palettes on create', async () => {
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler(
|
||||||
|
{ method: 'POST', body: { name: 'My Frame', palette: { outer: 'nope' } } },
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toContain('Invalid or missing color');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate frame names', async () => {
|
||||||
|
sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler(
|
||||||
|
{ method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } },
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a frame with the normalized palette', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await handler(
|
||||||
|
{ method: 'POST', body: { name: 'Molten', palette: GOOD_PALETTE } },
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.body.frame.name).toBe('Molten');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/custom-frames/[id]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||||
|
sql.mockResolvedValue({ rows: [] });
|
||||||
|
sql.json = vi.fn((v) => v);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for another user\'s frame', async () => {
|
||||||
|
sql.mockResolvedValueOnce({ rows: [] });
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await itemHandler({ method: 'GET', query: { id: '4' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates name and palette', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // clash lookup misses
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await itemHandler(
|
||||||
|
{ method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE } },
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.frame.name).toBe('Molten II');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the frame', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership
|
||||||
|
.mockResolvedValueOnce({ rows: [] }); // DELETE
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await itemHandler({ method: 'DELETE', query: { id: '4' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(sql).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
149
test/api/custom-symbols.test.js
Normal file
149
test/api/custom-symbols.test.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../lib/object-storage.js', () => ({
|
||||||
|
put: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
|
||||||
|
vi.mock('../../lib/permission-middleware', () => ({
|
||||||
|
getUserFromRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/rate-limit.js', () => ({
|
||||||
|
checkUploadRateLimit: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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';
|
||||||
|
import handler from '../../pages/api/custom-symbols/index.js';
|
||||||
|
|
||||||
|
function createRes() {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(code) {
|
||||||
|
res.statusCode = code;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
json(data) {
|
||||||
|
res.body = data;
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
setHeader() {
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a fake multipart request whose body carries the given parts. */
|
||||||
|
function multipartRequest(fields) {
|
||||||
|
const boundary = 'testboundary';
|
||||||
|
const body =
|
||||||
|
fields.map((f) => `--${boundary}\r\n${f}\r\n`).join('') + `--${boundary}--\r\n`;
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
|
||||||
|
on(event, cb) {
|
||||||
|
if (event === 'data') cb(Buffer.from(body));
|
||||||
|
if (event === 'end') cb();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePart(name, filename, type) {
|
||||||
|
return `Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\nBINARYDATA`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPart(name, value) {
|
||||||
|
return `Content-Disposition: form-data; name="${name}"\r\n\r\n${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('/api/custom-symbols', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||||
|
checkUploadRateLimit.mockResolvedValue({ allowed: true, reset: Date.now() + 60000 });
|
||||||
|
sql.mockResolvedValue({ rows: [] });
|
||||||
|
put.mockResolvedValue({ url: 'https://cdn.example.com/symbols/x.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication', async () => {
|
||||||
|
getUserFromRequest.mockResolvedValue(null);
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler({ method: 'GET' }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(sql).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid symbol codes', async () => {
|
||||||
|
const req = multipartRequest([
|
||||||
|
filePart('image', 'f.png', 'image/png'),
|
||||||
|
textPart('code', 'bad code!'),
|
||||||
|
]);
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toContain('1-10 letters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported file types', async () => {
|
||||||
|
const req = multipartRequest([
|
||||||
|
filePart('image', 'f.gif', 'image/gif'),
|
||||||
|
textPart('code', 'F'),
|
||||||
|
]);
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.body.error).toContain('PNG, WebP, or SVG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads a symbol and returns its URL', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // existing lookup misses
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 7, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = multipartRequest([
|
||||||
|
filePart('image', 'fire.png', 'image/png'),
|
||||||
|
textPart('code', 'F'),
|
||||||
|
]);
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.body.symbol.code).toBe('F');
|
||||||
|
expect(put).toHaveBeenCalledTimes(1);
|
||||||
|
expect(put.mock.calls[0][0]).toContain('symbols/1-f-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces an existing symbol with the same code', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 6, image_url: 'https://cdn.example.com/symbols/old.png' }],
|
||||||
|
}) // existing lookup hits
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 6, code: 'F', image_url: 'https://cdn.example.com/symbols/x.png' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = multipartRequest([
|
||||||
|
filePart('image', 'fire.png', 'image/png'),
|
||||||
|
textPart('code', 'F'),
|
||||||
|
]);
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(del).toHaveBeenCalledWith('https://cdn.example.com/symbols/old.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue