feat(designer): add card designer with starter frames, live preview, and PNG export
- custom_cards migration + CRUD API with catalog twin sync so designs appear in My Cards, lists, and decks via normal card joins - artwork upload to MinIO under card-art/ - /designer page: form-driven live preview, 4 starter frames, PNG export - /my-designs gallery with edit/delete - Designer nav entry in sidebar + mobile drawer
This commit is contained in:
parent
f2ba333daf
commit
421c5e5ee5
12 changed files with 1553 additions and 0 deletions
|
|
@ -209,6 +209,14 @@ function NavigationContent({ user, router, onItemClick }) {
|
|||
router.pathname === '/decks' ||
|
||||
router.pathname.startsWith('/deck/'),
|
||||
},
|
||||
{
|
||||
name: 'Designer',
|
||||
href: '/my-designs',
|
||||
icon: 'designer',
|
||||
active:
|
||||
router.pathname === '/designer' ||
|
||||
router.pathname === '/my-designs',
|
||||
},
|
||||
{
|
||||
name: 'Scanner',
|
||||
href: '/scanner',
|
||||
|
|
@ -318,6 +326,11 @@ function NavigationContent({ user, router, onItemClick }) {
|
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
designer: (
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
return icons[iconName] || icons.grid;
|
||||
|
|
|
|||
316
components/designer/CardFrame.js
Normal file
316
components/designer/CardFrame.js
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/* eslint-disable @next/next/no-img-element -- Artwork comes from MinIO CDN / data URLs; next/image is out of scope for the designer canvas. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { getFrame, getRarity } from './frames';
|
||||
|
||||
/** Natural render size of the card (5:7). Everything inside is px-based
|
||||
* so screen preview and PNG export are pixel-identical. */
|
||||
export const CARD_W = 420;
|
||||
export const CARD_H = 588;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* so html-to-image can rasterize it 1:1 during PNG export.
|
||||
*/
|
||||
export default function CardFrame({ design, innerRef }) {
|
||||
const frame = getFrame(design.frame_id);
|
||||
const rarity = getRarity(design.rarity);
|
||||
const p = frame.palette;
|
||||
const showPt = Boolean(design.power || design.toughness);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
style={{
|
||||
width: CARD_W,
|
||||
height: CARD_H,
|
||||
backgroundColor: p.outer,
|
||||
borderRadius: 18,
|
||||
border: `6px solid ${p.border}`,
|
||||
boxSizing: 'border-box',
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'Georgia, "Times New Roman", serif',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
|
||||
userSelect: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
backgroundColor: p.titleBar,
|
||||
border: `1px solid ${p.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '6px 10px',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: p.titleText,
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.15,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{design.name || 'Untitled Card'}
|
||||
</span>
|
||||
<ManaPips cost={design.mana_cost} accent={p.accent} />
|
||||
</div>
|
||||
|
||||
{/* Artwork window */}
|
||||
<div
|
||||
style={{
|
||||
height: 234,
|
||||
flexShrink: 0,
|
||||
backgroundColor: p.artBacking,
|
||||
border: `1px solid ${p.border}`,
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{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',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
opacity: 0.55,
|
||||
}}
|
||||
>
|
||||
<svg width="96" height="96" viewBox="0 0 24 24" fill="none" stroke={p.border} strokeWidth="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
<span style={{ color: p.border, fontSize: 13, fontStyle: 'italic', fontFamily: 'inherit' }}>
|
||||
Upload artwork
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type line + rarity gem */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: p.typeBar,
|
||||
border: `1px solid ${p.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '5px 34px 5px 10px',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: p.titleText,
|
||||
fontSize: 13,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{design.card_type || '— Type —'}
|
||||
</span>
|
||||
{/* Rarity gem straddles art/type boundary */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
top: -14,
|
||||
width: 16,
|
||||
height: 16,
|
||||
transform: 'rotate(45deg)',
|
||||
backgroundColor: rarity.color,
|
||||
border: '2px solid rgba(0,0,0,0.55)',
|
||||
boxShadow: 'inset 0 0 4px rgba(255,255,255,0.65)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rules text box */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: p.textBox,
|
||||
border: `1px solid ${p.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{design.rules_text && (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color: p.text,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.35,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{design.rules_text}
|
||||
</p>
|
||||
)}
|
||||
{design.rules_text && design.actions && (
|
||||
<div style={{ borderTop: `1px solid ${p.accent}55`, width: '100%' }} />
|
||||
)}
|
||||
{design.actions && (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color: p.text,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.35,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{design.actions}
|
||||
</p>
|
||||
)}
|
||||
{!design.rules_text && !design.actions && (
|
||||
<p
|
||||
style={{
|
||||
margin: 'auto',
|
||||
color: `${p.text}66`,
|
||||
fontSize: 12.5,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Description & actions appear here
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales CardFrame to fit the available width while preserving the
|
||||
* natural 420x588 layout (transform keeps export coordinates intact).
|
||||
*/
|
||||
export function CardPreview({ design, innerRef, maxWidth = 420 }) {
|
||||
const containerRef = useRef(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return undefined;
|
||||
|
||||
const update = () => {
|
||||
const available = Math.min(el.clientWidth, maxWidth);
|
||||
setScale(Math.min(1, available / CARD_W));
|
||||
};
|
||||
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [maxWidth]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'visible' }}>
|
||||
<div
|
||||
style={{
|
||||
width: CARD_W * scale,
|
||||
height: CARD_H * scale,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<div style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}>
|
||||
<CardFrame design={design} innerRef={innerRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mana cost string as pip circles. Accepts both scryfall
|
||||
* braces ("{2}{R}{R}") and plain notation ("2RR"), plus arbitrary
|
||||
* symbols for custom games.
|
||||
*/
|
||||
export function ManaPips({ cost, accent }) {
|
||||
if (!cost || !cost.trim()) return null;
|
||||
|
||||
const tokens = /\{/.test(cost)
|
||||
? (cost.match(/\{[^}]+\}/g) || []).map((t) => t.slice(1, -1))
|
||||
: cost.split(/\s+/).flatMap((chunk) => chunk.split(''));
|
||||
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', gap: 3, flexShrink: 0 }}>
|
||||
{tokens.slice(0, 8).map((token, i) => (
|
||||
<span
|
||||
key={`${token}-${i}`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#0e0c08',
|
||||
border: `1.5px solid ${accent}`,
|
||||
color: '#f4efe2',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Georgia, serif',
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
86
components/designer/frames.js
Normal file
86
components/designer/frames.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Starter frame definitions for the card designer.
|
||||
* Colors are concrete values (no CSS vars) so the rendered frame
|
||||
* rasterizes faithfully during PNG export.
|
||||
*/
|
||||
export const FRAMES = [
|
||||
{
|
||||
id: 'classic',
|
||||
name: 'Ember Classic',
|
||||
description: 'Warm creature frame with gold trim',
|
||||
palette: {
|
||||
outer: '#2b1d12',
|
||||
border: '#c9a227',
|
||||
titleBar: '#513a20',
|
||||
typeBar: '#5d4426',
|
||||
textBox: '#e8dcc3',
|
||||
artBacking: '#1a1108',
|
||||
text: '#241a0e',
|
||||
titleText: '#f5ead1',
|
||||
accent: '#c9a227',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sorcery',
|
||||
name: 'Azure Sorcery',
|
||||
description: 'Cool spell frame with arcane blue',
|
||||
palette: {
|
||||
outer: '#101b2b',
|
||||
border: '#6ea8dc',
|
||||
titleBar: '#1d3a57',
|
||||
typeBar: '#24466a',
|
||||
textBox: '#dfe9f4',
|
||||
artBacking: '#0a1220',
|
||||
text: '#152232',
|
||||
titleText: '#e3eefb',
|
||||
accent: '#6ea8dc',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'verdant',
|
||||
name: 'Verdant Wilds',
|
||||
description: 'Nature frame with deep green growth',
|
||||
palette: {
|
||||
outer: '#14210f',
|
||||
border: '#8fbc6f',
|
||||
titleBar: '#2c4420',
|
||||
typeBar: '#36512a',
|
||||
textBox: '#e4ecd8',
|
||||
artBacking: '#0c1408',
|
||||
text: '#1c2913',
|
||||
titleText: '#eaf3de',
|
||||
accent: '#8fbc6f',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'void',
|
||||
name: 'Void Artifact',
|
||||
description: 'Neutral dark frame for anything',
|
||||
palette: {
|
||||
outer: '#17161a',
|
||||
border: '#9d93b8',
|
||||
titleBar: '#2e2b36',
|
||||
typeBar: '#383442',
|
||||
textBox: '#e6e3ee',
|
||||
artBacking: '#0e0d11',
|
||||
text: '#211f27',
|
||||
titleText: '#ece9f4',
|
||||
accent: '#9d93b8',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const RARITIES = [
|
||||
{ id: 'common', name: 'Common', color: '#9ca3af' },
|
||||
{ id: 'uncommon', name: 'Uncommon', color: '#a8b6c8' },
|
||||
{ id: 'rare', name: 'Rare', color: '#d4af37' },
|
||||
{ id: 'mythic', name: 'Mythic', color: '#e0662f' },
|
||||
];
|
||||
|
||||
export function getFrame(frameId) {
|
||||
return FRAMES.find((f) => f.id === frameId) || FRAMES[0];
|
||||
}
|
||||
|
||||
export function getRarity(rarityId) {
|
||||
return RARITIES.find((r) => r.id === rarityId) || RARITIES[0];
|
||||
}
|
||||
43
migrations/1787600511000_create-custom-cards.js
Normal file
43
migrations/1787600511000_create-custom-cards.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Custom card designs (card designer feature).
|
||||
*
|
||||
* One row per user-designed card. Designer-specific fields live here
|
||||
* (frame, artwork, actions); on save we also upsert a matching catalog
|
||||
* row in `cards` (game='Custom') + a `user_cards` row so designed cards
|
||||
* appear in My Cards, lists, and decks through the normal joins.
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS custom_cards (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
card_id INTEGER REFERENCES cards(id) ON DELETE SET NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
mana_cost VARCHAR(50),
|
||||
card_type VARCHAR(255),
|
||||
rarity VARCHAR(50),
|
||||
rules_text TEXT,
|
||||
actions TEXT,
|
||||
power VARCHAR(10),
|
||||
toughness VARCHAR(10),
|
||||
frame_id VARCHAR(50) DEFAULT 'classic',
|
||||
artwork_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_cards_user_id
|
||||
ON custom_cards(user_id)
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_cards_card_id
|
||||
ON custom_cards(card_id)
|
||||
`);
|
||||
};
|
||||
|
||||
export const down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS custom_cards CASCADE`);
|
||||
};
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@neondatabase/serverless": "^1.1.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^17.2.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"ioredis": "^5.7.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"next": "^16.2.6",
|
||||
|
|
@ -6159,6 +6160,12 @@
|
|||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"@neondatabase/serverless": "^1.1.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^17.2.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"ioredis": "^5.7.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"next": "^16.2.6",
|
||||
|
|
|
|||
91
pages/api/custom-cards/[id].js
Normal file
91
pages/api/custom-cards/[id].js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { sql } from '../../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||
import { syncCatalogCard, ensureOwnedRow } from './index.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 designId = parseInt(req.query.id, 10);
|
||||
if (!Number.isInteger(designId)) {
|
||||
return res.status(400).json({ error: 'Invalid design id' });
|
||||
}
|
||||
|
||||
const found = await sql`
|
||||
SELECT * FROM custom_cards
|
||||
WHERE id = ${designId} AND user_id = ${user.userId}
|
||||
`;
|
||||
if (found.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Design not found' });
|
||||
}
|
||||
const existing = found.rows[0];
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return res.status(200).json({ design: existing });
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
const f = pickFields(req.body);
|
||||
if (!f.name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const updated = await sql`
|
||||
UPDATE custom_cards SET
|
||||
name = ${f.name}, mana_cost = ${f.manaCost},
|
||||
card_type = ${f.cardType}, rarity = ${f.rarity},
|
||||
rules_text = ${f.rulesText}, actions = ${f.actions},
|
||||
power = ${f.power}, toughness = ${f.toughness},
|
||||
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${designId}
|
||||
RETURNING *
|
||||
`;
|
||||
const design = updated.rows[0];
|
||||
|
||||
const cardId = await syncCatalogCard(design, f);
|
||||
if (cardId) {
|
||||
await ensureOwnedRow(user.userId, cardId);
|
||||
if (!design.card_id) {
|
||||
const linked = await sql`
|
||||
UPDATE custom_cards SET card_id = ${cardId}
|
||||
WHERE id = ${designId} RETURNING *
|
||||
`;
|
||||
return res.status(200).json({ design: linked.rows[0] });
|
||||
}
|
||||
}
|
||||
return res.status(200).json({ design });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
// The catalog twin stays (it may already live in lists/decks);
|
||||
// removing the design row simply detaches future edits from it.
|
||||
await sql`DELETE FROM custom_cards WHERE id = ${designId}`;
|
||||
return res.status(200).json({ message: 'Design deleted' });
|
||||
}
|
||||
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
} catch (error) {
|
||||
console.error('Custom card API error:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
function pickFields(body) {
|
||||
const str = (v) => (typeof v === 'string' ? v.trim() : null);
|
||||
return {
|
||||
name: str(body.name),
|
||||
manaCost: str(body.mana_cost) || str(body.manaCost),
|
||||
cardType: str(body.card_type) || str(body.cardType),
|
||||
rarity: str(body.rarity),
|
||||
rulesText: str(body.rules_text) || str(body.description),
|
||||
actions: str(body.actions),
|
||||
power: str(body.power),
|
||||
toughness: str(body.toughness),
|
||||
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
|
||||
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
|
||||
};
|
||||
}
|
||||
134
pages/api/custom-cards/index.js
Normal file
134
pages/api/custom-cards/index.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { sql } from '../../../lib/sql.js';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||
|
||||
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`
|
||||
SELECT id, card_id, name, mana_cost, card_type, rarity,
|
||||
rules_text, actions, power, toughness, frame_id,
|
||||
artwork_url, created_at, updated_at
|
||||
FROM custom_cards
|
||||
WHERE user_id = ${user.userId}
|
||||
ORDER BY updated_at DESC
|
||||
`;
|
||||
return res.status(200).json({ designs: result.rows });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
function pickFields(body) {
|
||||
const str = (v) => (typeof v === 'string' ? v.trim() : null);
|
||||
return {
|
||||
name: str(body.name),
|
||||
manaCost: str(body.mana_cost) || str(body.manaCost),
|
||||
cardType: str(body.card_type) || str(body.cardType),
|
||||
rarity: str(body.rarity),
|
||||
rulesText: str(body.rules_text) || str(body.description),
|
||||
actions: str(body.actions),
|
||||
power: str(body.power),
|
||||
toughness: str(body.toughness),
|
||||
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
|
||||
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
|
||||
};
|
||||
}
|
||||
|
||||
async function createDesign(userId, body) {
|
||||
const f = pickFields(body);
|
||||
if (!f.name) return null;
|
||||
|
||||
const inserted = await sql`
|
||||
INSERT INTO custom_cards
|
||||
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
||||
actions, power, toughness, frame_id, artwork_url)
|
||||
VALUES
|
||||
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
||||
${f.rulesText}, ${f.actions}, ${f.power}, ${f.toughness},
|
||||
${f.frameId}, ${f.artworkUrl})
|
||||
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.
|
||||
const cardId = await syncCatalogCard(design, f);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Create/update the catalog twin of a custom design; returns card id. */
|
||||
export async function syncCatalogCard(design, fields) {
|
||||
const values = {
|
||||
name: fields.name,
|
||||
setName: CUSTOM_SET,
|
||||
setCode: 'DSGN',
|
||||
rarity: fields.rarity,
|
||||
game: CUSTOM_GAME,
|
||||
manaCost: fields.manaCost,
|
||||
cardType: fields.cardType,
|
||||
oracleText: [fields.rulesText, fields.actions].filter(Boolean).join('\n\n') || null,
|
||||
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
|
||||
`;
|
||||
}
|
||||
122
pages/api/custom-cards/upload-artwork.js
Normal file
122
pages/api/custom-cards/upload-artwork.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { put } from '../../../lib/object-storage.js';
|
||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '5mb',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
|
||||
export default async function handler(req, res) {
|
||||
try {
|
||||
const user = await getUserFromRequest(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
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.' });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
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.artwork;
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({ error: 'No artwork file provided' });
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid file type. Please upload a JPEG, PNG, or WebP image.',
|
||||
});
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return res.status(400).json({ error: 'File size must be less than 5MB' });
|
||||
}
|
||||
|
||||
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type.split('/')[1];
|
||||
const filename = `card-art/${user.userId}-${Date.now()}.${extension}`;
|
||||
|
||||
const blob = await put(filename, file.buffer, {
|
||||
access: 'public',
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
return res.status(200).json({ artwork_url: blob.url });
|
||||
} catch (error) {
|
||||
console.error('Artwork upload API error:', error);
|
||||
return res.status(500).json({ error: 'Failed to upload artwork' });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
415
pages/designer.js
Normal file
415
pages/designer.js
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import Layout from '../components/Layout';
|
||||
import CardPreview from '../components/designer/CardFrame';
|
||||
import { FRAMES, RARITIES } from '../components/designer/frames';
|
||||
import { Button } from '../components/ui';
|
||||
import { useAuth } from '../lib/use-auth';
|
||||
|
||||
const BLANK_DESIGN = {
|
||||
id: null,
|
||||
name: '',
|
||||
mana_cost: '',
|
||||
card_type: '',
|
||||
rarity: 'common',
|
||||
rules_text: '',
|
||||
actions: '',
|
||||
power: '',
|
||||
toughness: '',
|
||||
frame_id: 'classic',
|
||||
artwork_url: '',
|
||||
};
|
||||
|
||||
export default function Designer() {
|
||||
const router = useRouter();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
|
||||
const [design, setDesign] = useState(BLANK_DESIGN);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const cardRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Edit mode when ?id= is present
|
||||
useEffect(() => {
|
||||
if (!user || !router.query.id) return;
|
||||
|
||||
const loadDesign = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch(`/api/custom-cards/${router.query.id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDesign({ ...BLANK_DESIGN, ...data.design });
|
||||
} else {
|
||||
setMessage({ kind: 'error', text: 'Design not found.' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ kind: 'error', text: 'Failed to load design.' });
|
||||
}
|
||||
};
|
||||
|
||||
loadDesign();
|
||||
}, [user, router.query.id]);
|
||||
|
||||
const setField = useCallback((field, value) => {
|
||||
setDesign((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (file) => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('artwork', file);
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch('/api/custom-cards/upload-artwork', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
body,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.artwork_url) {
|
||||
setField('artwork_url', data.artwork_url);
|
||||
} else {
|
||||
setMessage({ kind: 'error', text: data.error || 'Upload failed.' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ kind: 'error', text: 'Upload failed. Please try again.' });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!design.name.trim()) {
|
||||
setMessage({ kind: 'error', text: 'Give your card a title first.' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const isNew = !design.id;
|
||||
const response = await fetch(
|
||||
isNew ? '/api/custom-cards' : `/api/custom-cards/${design.id}`,
|
||||
{
|
||||
method: isNew ? 'POST' : 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(design),
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
setDesign({ ...BLANK_DESIGN, ...data.design });
|
||||
setMessage({
|
||||
kind: 'success',
|
||||
text: isNew ? 'Card saved to your designs!' : 'Changes saved.',
|
||||
});
|
||||
} else {
|
||||
setMessage({ kind: 'error', text: data.error || 'Save failed.' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ kind: 'error', text: 'Save failed. Please try again.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPng = async () => {
|
||||
if (!cardRef.current) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const { toPng } = await import('html-to-image');
|
||||
const dataUrl = await toPng(cardRef.current, {
|
||||
width: 420,
|
||||
height: 588,
|
||||
pixelRatio: 2,
|
||||
cacheBust: true,
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.download = `${(design.name || 'card').replace(/[^a-z0-9-_ ]/gi, '').trim() || 'card'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
} catch {
|
||||
setMessage({ kind: 'error', text: 'Export failed. Please try again.' });
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
setDesign(BLANK_DESIGN);
|
||||
setMessage(null);
|
||||
router.replace('/designer', undefined, { shallow: true });
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
Card Designer
|
||||
</h1>
|
||||
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||
Fill in the details and watch your card come to life.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={handleNew}>New</Button>
|
||||
<Button variant="secondary" onClick={handleExportPng} disabled={exporting}>
|
||||
{exporting ? 'Exporting…' : 'Download PNG'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : design.id ? 'Save Changes' : 'Save Card'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className="glass-panel rounded-xl px-4 py-3 text-sm"
|
||||
style={{ color: message.kind === 'error' ? '#f87171' : '#4ade80' }}
|
||||
role="status"
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_460px] gap-6">
|
||||
{/* ── Form column ─────────────────────────────── */}
|
||||
<div className="space-y-5 min-w-0">
|
||||
{/* Frame picker */}
|
||||
<section className="glass-panel rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Frame
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{FRAMES.map((frame) => (
|
||||
<button
|
||||
key={frame.id}
|
||||
type="button"
|
||||
onClick={() => setField('frame_id', frame.id)}
|
||||
className={`rounded-xl p-3 text-left transition-all duration-200 border ${
|
||||
design.frame_id === frame.id
|
||||
? 'shadow-lg scale-[1.02]'
|
||||
: 'opacity-80 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: frame.palette.outer,
|
||||
borderColor: design.frame_id === frame.id ? frame.palette.border : 'transparent',
|
||||
borderWidth: 2,
|
||||
}}
|
||||
aria-pressed={design.frame_id === frame.id}
|
||||
>
|
||||
<div
|
||||
className="w-full h-8 rounded mb-2"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${frame.palette.titleBar}, ${frame.palette.textBox})`,
|
||||
border: `1px solid ${frame.palette.border}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs font-semibold block" style={{ color: frame.palette.titleText }}>
|
||||
{frame.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Artwork */}
|
||||
<section className="glass-panel rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||
Artwork
|
||||
</h2>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => handleUpload(e.target.files?.[0])}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button variant="primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? 'Uploading…' : design.artwork_url ? 'Replace Image' : 'Upload Image'}
|
||||
</Button>
|
||||
{design.artwork_url && (
|
||||
<Button variant="secondary" onClick={() => setField('artwork_url', '')}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
JPEG, PNG, or WebP · up to 5MB
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Details */}
|
||||
<section className="glass-panel rounded-2xl p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
|
||||
Details
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Title" required>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
value={design.name}
|
||||
onChange={(e) => setField('name', e.target.value)}
|
||||
placeholder="Emberwing Phoenix"
|
||||
maxLength={60}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Cost" hint='e.g. "2RR" or "{2}{R}{R}"'>
|
||||
<input
|
||||
className="input-field w-full"
|
||||
value={design.mana_cost}
|
||||
onChange={(e) => setField('mana_cost', e.target.value)}
|
||||
placeholder="2RR"
|
||||
maxLength={24}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field label="Type">
|
||||
<input
|
||||
className="input-field w-full"
|
||||
value={design.card_type}
|
||||
onChange={(e) => setField('card_type', e.target.value)}
|
||||
placeholder="Creature — Phoenix"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Rarity">
|
||||
<div className="flex gap-2">
|
||||
{RARITIES.map((rarity) => (
|
||||
<button
|
||||
key={rarity.id}
|
||||
type="button"
|
||||
onClick={() => setField('rarity', rarity.id)}
|
||||
className={`flex-1 rounded-lg px-2 py-2 text-xs font-semibold transition-all duration-200 ${
|
||||
design.rarity === rarity.id ? 'scale-105 shadow-md' : 'opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
color: design.rarity === rarity.id ? rarity.color : 'var(--text-secondary)',
|
||||
borderBottom: `3px solid ${rarity.color}`,
|
||||
}}
|
||||
aria-pressed={design.rarity === rarity.id}
|
||||
>
|
||||
{rarity.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description / Flavor">
|
||||
<textarea
|
||||
className="input-field w-full resize-y"
|
||||
rows={3}
|
||||
value={design.rules_text}
|
||||
onChange={(e) => setField('rules_text', e.target.value)}
|
||||
placeholder="Flavor text or lore…"
|
||||
maxLength={500}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Actions / Abilities">
|
||||
<textarea
|
||||
className="input-field w-full resize-y"
|
||||
rows={3}
|
||||
value={design.actions}
|
||||
onChange={(e) => setField('actions', e.target.value)}
|
||||
placeholder={'Flying, haste\nWhen Emberwing enters the battlefield, it deals 2 damage to any target.'}
|
||||
maxLength={800}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<details className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<summary className="cursor-pointer select-none">Power / Toughness (optional)</summary>
|
||||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||
<Field label="Power">
|
||||
<input
|
||||
className="input-field w-full"
|
||||
value={design.power}
|
||||
onChange={(e) => setField('power', e.target.value)}
|
||||
placeholder="3"
|
||||
maxLength={10}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Toughness">
|
||||
<input
|
||||
className="input-field w-full"
|
||||
value={design.toughness}
|
||||
onChange={(e) => setField('toughness', e.target.value)}
|
||||
placeholder="4"
|
||||
maxLength={10}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ── Live preview column ─────────────────────── */}
|
||||
<div>
|
||||
<div className="sticky top-8 glass-panel rounded-2xl p-6">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
Live Preview
|
||||
</h2>
|
||||
<CardPreview design={design} innerRef={cardRef} />
|
||||
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-secondary)' }}>
|
||||
Saved designs appear in{' '}
|
||||
<Link href="/my-designs" style={{ color: 'var(--accent-ember)' }}>My Designs</Link>{' '}
|
||||
and your collection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, required, children }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold mb-1.5 block" style={{ color: 'var(--text-primary)' }}>
|
||||
{label}
|
||||
{required && <span style={{ color: 'var(--accent-ember)' }}> *</span>}
|
||||
{hint && (
|
||||
<span className="font-normal ml-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
({hint})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
154
pages/my-designs.js
Normal file
154
pages/my-designs.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Layout from '../components/Layout';
|
||||
import CardPreview from '../components/designer/CardFrame';
|
||||
import { Button } from '../components/ui';
|
||||
import { useAuth } from '../lib/use-auth';
|
||||
|
||||
export default function MyDesigns() {
|
||||
const router = useRouter();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
|
||||
const [designs, setDesigns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [authLoading, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return undefined;
|
||||
|
||||
const loadDesigns = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch('/api/custom-cards', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDesigns(data.designs);
|
||||
} else {
|
||||
setError('Failed to load your designs.');
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load your designs.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDesigns();
|
||||
return undefined;
|
||||
}, [user]);
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Delete this design? The card will remain in your collection.')) return;
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch(`/api/custom-cards/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (response.ok) {
|
||||
setDesigns((prev) => prev.filter((d) => d.id !== id));
|
||||
} else {
|
||||
setError('Delete failed.');
|
||||
}
|
||||
} catch {
|
||||
setError('Delete failed.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
My Designs
|
||||
</h1>
|
||||
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||
{designs.length} custom card{designs.length === 1 ? '' : 's'} · also visible in your collection
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => router.push('/designer')}>
|
||||
+ New Design
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Designs grid */}
|
||||
{!loading && designs.length === 0 && !error && (
|
||||
<div className="text-center py-20">
|
||||
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
No designs yet
|
||||
</h3>
|
||||
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
Create your first custom card in the designer.
|
||||
</p>
|
||||
<Button variant="primary" size="lg" onClick={() => router.push('/designer')}>
|
||||
Open Card Designer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-5">
|
||||
{designs.map((design) => (
|
||||
<div key={design.id} className="glass-panel rounded-2xl p-4 flex flex-col gap-3">
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/designer?id=${design.id}`)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
|
||||
>
|
||||
<CardPreview design={design} maxWidth={280} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{design.name}
|
||||
</p>
|
||||
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-auto">
|
||||
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleDelete(design.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
171
test/api/custom-cards.test.js
Normal file
171
test/api/custom-cards.test.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
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-cards/index.js';
|
||||
import itemHandler from '../../pages/api/custom-cards/[id].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;
|
||||
}
|
||||
|
||||
describe('GET /api/custom-cards', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
sql.mockResolvedValue({ rows: [] });
|
||||
});
|
||||
|
||||
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('returns the user designs', async () => {
|
||||
const design = { id: 5, name: 'Emberwing', card_id: 77 };
|
||||
sql.mockResolvedValueOnce({ rows: [design] });
|
||||
const res = createRes();
|
||||
|
||||
await handler({ method: 'GET' }, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.designs).toEqual([design]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/custom-cards', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
sql.mockResolvedValue({ rows: [] });
|
||||
});
|
||||
|
||||
it('rejects designs without a name', async () => {
|
||||
const res = createRes();
|
||||
|
||||
await handler(
|
||||
{ method: 'POST', body: { name: ' ', rarity: 'rare' } },
|
||||
res
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(sql).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates the design, mirrors a catalog card, and links ownership', async () => {
|
||||
sql
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{ id: 10, card_id: null, name: 'Emberwing Phoenix' }],
|
||||
}) // INSERT custom_cards
|
||||
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // INSERT cards twin
|
||||
.mockResolvedValueOnce({ rows: [] }) // INSERT user_cards
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{ id: 10, card_id: 77, name: 'Emberwing Phoenix' }],
|
||||
}); // link card_id back onto the design
|
||||
|
||||
const res = createRes();
|
||||
await handler(
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Emberwing Phoenix',
|
||||
mana_cost: '2RR',
|
||||
rarity: 'rare',
|
||||
frameId: 'classic',
|
||||
description: 'A phoenix reborn.',
|
||||
actions: 'Flying, haste',
|
||||
},
|
||||
},
|
||||
res
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.body.design.card_id).toBe(77);
|
||||
// 4 statements: insert design, insert catalog twin, own row, link back
|
||||
expect(sql).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/custom-cards/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||
sql.mockResolvedValue({ rows: [] });
|
||||
});
|
||||
|
||||
it('returns 404 when the design belongs to someone else', async () => {
|
||||
sql.mockResolvedValueOnce({ rows: [] }); // ownership lookup misses
|
||||
const res = createRes();
|
||||
|
||||
await itemHandler({ method: 'GET', query: { id: '12' } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('rejects invalid ids', async () => {
|
||||
const res = createRes();
|
||||
|
||||
await itemHandler({ method: 'GET', query: { id: 'abc' } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('updates the design and keeps the catalog twin in sync', async () => {
|
||||
sql
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{ id: 10, user_id: 1, card_id: 77, name: 'Old Name' }],
|
||||
}) // ownership lookup hits
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{ id: 10, card_id: 77, name: 'New Name' }],
|
||||
}) // UPDATE custom_cards
|
||||
.mockResolvedValueOnce({ rows: [{ id: 77 }] }) // UPDATE cards twin
|
||||
.mockResolvedValueOnce({ rows: [] }); // ensureOwnedRow (no-op if exists)
|
||||
|
||||
const res = createRes();
|
||||
await itemHandler(
|
||||
{ method: 'PUT', query: { id: '10' }, body: { name: 'New Name' } },
|
||||
res
|
||||
);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.design.name).toBe('New Name');
|
||||
// 4 statements: ownership, update design, update twin, own row
|
||||
expect(sql).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('deletes only the design row', async () => {
|
||||
sql
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{ id: 10, user_id: 1, card_id: 77 }],
|
||||
}) // ownership lookup
|
||||
.mockResolvedValueOnce({ rows: [] }); // DELETE custom_cards
|
||||
|
||||
const res = createRes();
|
||||
await itemHandler({ method: 'DELETE', query: { id: '10' } }, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(sql).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue