2026-08-24 15:53:06 -04:00
|
|
|
import { sql } from '../../../lib/sql.js';
|
|
|
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
2026-08-24 17:19:24 -04:00
|
|
|
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
|
2026-08-24 22:01:04 -04:00
|
|
|
import { isExistingGame } from '../../../lib/designer-games.js';
|
2026-08-24 15:53:06 -04:00
|
|
|
|
|
|
|
|
const CUSTOM_GAME = 'Custom';
|
|
|
|
|
const CUSTOM_SET = 'Designs';
|
|
|
|
|
|
|
|
|
|
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`
|
2026-08-24 22:01:04 -04:00
|
|
|
SELECT c.id, c.card_id, c.name, c.mana_cost, c.card_type, c.rarity,
|
|
|
|
|
c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
|
|
|
|
|
c.frame_id, c.artwork_url, c.art_mode,
|
|
|
|
|
c.game_target, c.custom_game_id, g.name AS custom_game_name,
|
2026-08-24 22:13:12 -04:00
|
|
|
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
|
feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
2026-08-24 22:48:52 -04:00
|
|
|
f.texture_url AS frame_texture,
|
2026-09-01 10:23:16 -04:00
|
|
|
f.frame_image_url AS frame_image,
|
|
|
|
|
f.layout AS frame_layout,
|
2026-08-24 22:01:04 -04:00
|
|
|
c.created_at, c.updated_at
|
|
|
|
|
FROM custom_cards c
|
|
|
|
|
LEFT JOIN custom_games g ON g.id = c.custom_game_id
|
2026-08-24 22:13:12 -04:00
|
|
|
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
2026-08-24 22:01:04 -04:00
|
|
|
WHERE c.user_id = ${user.userId}
|
|
|
|
|
ORDER BY c.updated_at DESC
|
2026-08-24 15:53:06 -04:00
|
|
|
`;
|
2026-08-24 22:13:12 -04:00
|
|
|
const designs = result.rows.map((row) => ({
|
|
|
|
|
...row,
|
|
|
|
|
custom_frame:
|
|
|
|
|
row.frame_pk != null
|
feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
2026-08-24 22:48:52 -04:00
|
|
|
? {
|
|
|
|
|
id: row.frame_pk,
|
|
|
|
|
name: row.frame_name,
|
|
|
|
|
palette: row.frame_palette,
|
|
|
|
|
texture_url: row.frame_texture,
|
2026-09-01 10:23:16 -04:00
|
|
|
frame_image_url: row.frame_image,
|
|
|
|
|
layout: row.frame_layout,
|
feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
2026-08-24 22:48:52 -04:00
|
|
|
}
|
2026-08-24 22:13:12 -04:00
|
|
|
: null,
|
|
|
|
|
}));
|
|
|
|
|
return res.status(200).json({
|
feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
2026-08-24 22:48:52 -04:00
|
|
|
designs: designs.map(
|
2026-09-01 10:23:16 -04:00
|
|
|
({ frame_pk, frame_name, frame_palette, frame_texture, frame_image, frame_layout, ...rest }) => rest
|
feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
2026-08-24 22:48:52 -04:00
|
|
|
),
|
2026-08-24 22:13:12 -04:00
|
|
|
});
|
2026-08-24 15:53:06 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.method === 'POST') {
|
|
|
|
|
const design = await createDesign(user.userId, req.body);
|
|
|
|
|
if (!design) {
|
|
|
|
|
return res.status(400).json({ error: 'Name is required' });
|
|
|
|
|
}
|
|
|
|
|
return res.status(201).json({ design });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.status(405).json({ error: 'Method not allowed' });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Custom cards API error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createDesign(userId, body) {
|
2026-08-24 17:19:24 -04:00
|
|
|
const f = pickDesignFields(body);
|
2026-08-24 15:53:06 -04:00
|
|
|
if (!f.name) return null;
|
|
|
|
|
|
2026-08-24 22:01:04 -04:00
|
|
|
const catalogGame = await resolveCatalogGame(userId, f);
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
const inserted = await sql`
|
|
|
|
|
INSERT INTO custom_cards
|
|
|
|
|
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
2026-08-24 17:19:24 -04:00
|
|
|
actions, flavor_quote, power, toughness, frame_id, artwork_url,
|
2026-08-24 22:13:12 -04:00
|
|
|
art_mode, game_target, custom_game_id, custom_frame_id)
|
2026-08-24 15:53:06 -04:00
|
|
|
VALUES
|
|
|
|
|
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
2026-08-24 17:19:24 -04:00
|
|
|
${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power},
|
2026-08-24 22:01:04 -04:00
|
|
|
${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode},
|
2026-08-24 22:13:12 -04:00
|
|
|
${f.gameTarget}, ${f.customGameId}, ${f.customFrameId})
|
2026-08-24 15:53:06 -04:00
|
|
|
RETURNING *
|
|
|
|
|
`;
|
|
|
|
|
const design = inserted.rows[0];
|
|
|
|
|
|
|
|
|
|
// Mirror into the shared catalog so the design shows up in My Cards,
|
|
|
|
|
// lists, and decks through the normal card joins.
|
2026-08-24 22:01:04 -04:00
|
|
|
const cardId = await syncCatalogCard(design, f, catalogGame);
|
2026-08-24 15:53:06 -04:00
|
|
|
if (cardId) {
|
|
|
|
|
await ensureOwnedRow(userId, cardId);
|
|
|
|
|
const linked = await sql`
|
|
|
|
|
UPDATE custom_cards SET card_id = ${cardId} WHERE id = ${design.id}
|
|
|
|
|
RETURNING *
|
|
|
|
|
`;
|
|
|
|
|
return linked.rows[0];
|
|
|
|
|
}
|
|
|
|
|
return design;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 22:01:04 -04:00
|
|
|
/**
|
|
|
|
|
* Resolve the catalog `game` value for a design: an existing system code,
|
|
|
|
|
* the owning custom game's name, or the generic 'Custom' bucket.
|
|
|
|
|
*/
|
|
|
|
|
export async function resolveCatalogGame(userId, fields) {
|
|
|
|
|
if (fields.gameTarget && isExistingGame(fields.gameTarget)) {
|
|
|
|
|
return fields.gameTarget;
|
|
|
|
|
}
|
|
|
|
|
if (fields.customGameId) {
|
|
|
|
|
const rows = await sql`
|
|
|
|
|
SELECT name FROM custom_games
|
|
|
|
|
WHERE id = ${fields.customGameId} AND user_id = ${userId}
|
|
|
|
|
`;
|
|
|
|
|
if (rows.rows.length > 0) {
|
|
|
|
|
// cards.game is VARCHAR(50) — custom game names cap at 100, so clip.
|
|
|
|
|
return rows.rows[0].name.slice(0, 50);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return CUSTOM_GAME;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
/** Create/update the catalog twin of a custom design; returns card id. */
|
2026-08-24 22:01:04 -04:00
|
|
|
export async function syncCatalogCard(design, fields, catalogGame) {
|
2026-08-24 15:53:06 -04:00
|
|
|
const values = {
|
|
|
|
|
name: fields.name,
|
|
|
|
|
setName: CUSTOM_SET,
|
|
|
|
|
setCode: 'DSGN',
|
|
|
|
|
rarity: fields.rarity,
|
2026-08-24 22:01:04 -04:00
|
|
|
game: catalogGame || CUSTOM_GAME,
|
2026-08-24 15:53:06 -04:00
|
|
|
manaCost: fields.manaCost,
|
|
|
|
|
cardType: fields.cardType,
|
2026-08-24 17:19:24 -04:00
|
|
|
oracleText:
|
|
|
|
|
[fields.actions, fields.rulesText, fields.flavorQuote]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join('\n\n') || null,
|
2026-08-24 15:53:06 -04:00
|
|
|
imageUrl: fields.artworkUrl,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (design.card_id) {
|
|
|
|
|
const updated = await sql`
|
|
|
|
|
UPDATE cards SET
|
|
|
|
|
name = ${values.name}, set_name = ${values.setName},
|
|
|
|
|
set_code = ${values.setCode}, rarity = ${values.rarity},
|
|
|
|
|
mana_cost = ${values.manaCost}, card_type = ${values.cardType},
|
|
|
|
|
oracle_text = ${values.oracleText}, image_url = ${values.imageUrl},
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
WHERE id = ${design.card_id}
|
|
|
|
|
RETURNING id
|
|
|
|
|
`;
|
|
|
|
|
if (updated.rows.length > 0) return updated.rows[0].id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inserted = await sql`
|
|
|
|
|
INSERT INTO cards (name, set_name, set_code, rarity, game,
|
|
|
|
|
mana_cost, card_type, oracle_text, image_url)
|
|
|
|
|
VALUES (${values.name}, ${values.setName}, ${values.setCode},
|
|
|
|
|
${values.rarity}, ${values.game}, ${values.manaCost},
|
|
|
|
|
${values.cardType}, ${values.oracleText}, ${values.imageUrl})
|
|
|
|
|
RETURNING id
|
|
|
|
|
`;
|
|
|
|
|
return inserted.rows[0].id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Idempotently give the designer one copy of their own creation. */
|
|
|
|
|
export async function ensureOwnedRow(userId, cardId) {
|
|
|
|
|
await sql`
|
|
|
|
|
INSERT INTO user_cards (user_id, card_id, quantity)
|
|
|
|
|
VALUES (${userId}, ${cardId}, 1)
|
|
|
|
|
ON CONFLICT (user_id, card_id, is_foil)
|
|
|
|
|
DO NOTHING
|
|
|
|
|
`;
|
|
|
|
|
}
|