feat(scanner): add debug instrumentation for vision pipeline timing #155
20 changed files with 1251 additions and 46 deletions
|
|
@ -253,6 +253,7 @@ function NavigationContent({ user, router, onItemClick }) {
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
|
{ name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
|
||||||
{ name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' },
|
{ name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' },
|
||||||
|
{ name: 'Games', href: '/community/games', active: router.pathname.startsWith('/community/games') },
|
||||||
{ name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' }
|
{ name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ export function resolvePalette(design) {
|
||||||
return getFrame(design.frame_id).palette;
|
return getFrame(design.frame_id).palette;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional background texture from the linked custom frame. */
|
||||||
|
function resolveTexture(design) {
|
||||||
|
return design.custom_frame?.texture_url || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|
@ -33,6 +38,7 @@ export default function CardFrame({ design, innerRef, symbols }) {
|
||||||
|
|
||||||
function FramedCard({ design, innerRef, symbols }) {
|
function FramedCard({ design, innerRef, symbols }) {
|
||||||
const p = resolvePalette(design);
|
const p = resolvePalette(design);
|
||||||
|
const texture = resolveTexture(design);
|
||||||
const showPt = Boolean(design.power || design.toughness);
|
const showPt = Boolean(design.power || design.toughness);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -42,6 +48,9 @@ function FramedCard({ design, innerRef, symbols }) {
|
||||||
width: CARD_W,
|
width: CARD_W,
|
||||||
height: CARD_H,
|
height: CARD_H,
|
||||||
backgroundColor: p.outer,
|
backgroundColor: p.outer,
|
||||||
|
backgroundImage: texture ? `url(${texture})` : undefined,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
border: `6px solid ${p.border}`,
|
border: `6px solid ${p.border}`,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
|
@ -137,7 +146,7 @@ function FramedCard({ design, innerRef, symbols }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text box */}
|
{/* Text box */}
|
||||||
<TextBox design={design} p={p} showPt={showPt} />
|
<TextBox design={design} p={p} showPt={showPt} symbols={symbols} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +155,7 @@ function FramedCard({ design, innerRef, symbols }) {
|
||||||
|
|
||||||
function FullArtCard({ design, innerRef, symbols }) {
|
function FullArtCard({ design, innerRef, symbols }) {
|
||||||
const p = resolvePalette(design);
|
const p = resolvePalette(design);
|
||||||
|
const texture = resolveTexture(design);
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -161,6 +171,9 @@ function FullArtCard({ design, innerRef, symbols }) {
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: p.artBacking,
|
backgroundColor: p.artBacking,
|
||||||
|
backgroundImage: texture ? `url(${texture})` : undefined,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
fontFamily: 'Georgia, "Times New Roman", serif',
|
fontFamily: 'Georgia, "Times New Roman", serif',
|
||||||
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
|
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
|
@ -257,7 +270,7 @@ function FullArtCard({ design, innerRef, symbols }) {
|
||||||
<RarityBadge rarityId={design.rarity} size={18} />
|
<RarityBadge rarityId={design.rarity} size={18} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextBlocks design={design} color="#f2ecdd" dividerColor={`${p.accent}99`} compact />
|
<TextBlocks design={design} color="#f2ecdd" dividerColor={`${p.accent}99`} compact symbols={symbols} />
|
||||||
|
|
||||||
{showPt && (
|
{showPt && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -311,7 +324,7 @@ function ArtPlaceholder({ p, full = false }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextBox({ design, p, showPt }) {
|
function TextBox({ design, p, showPt, symbols }) {
|
||||||
const hasAny = design.rules_text || design.actions || design.flavor_quote;
|
const hasAny = design.rules_text || design.actions || design.flavor_quote;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -329,7 +342,7 @@ function TextBox({ design, p, showPt }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasAny ? (
|
{hasAny ? (
|
||||||
<TextBlocks design={design} color={p.text} dividerColor={`${p.accent}55`} />
|
<TextBlocks design={design} color={p.text} dividerColor={`${p.accent}55`} symbols={symbols} />
|
||||||
) : (
|
) : (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -367,34 +380,35 @@ function TextBox({ design, p, showPt }) {
|
||||||
/**
|
/**
|
||||||
* Ordered text content: description, actions, then the flavor quotation.
|
* Ordered text content: description, actions, then the flavor quotation.
|
||||||
* Sections are separated by ornamental dividers; the quote renders in
|
* Sections are separated by ornamental dividers; the quote renders in
|
||||||
* italics wrapped in decorative quotation marks.
|
* italics wrapped in decorative quotation marks. `{CODE}` tokens render
|
||||||
|
* inline as symbol icons when the user has defined them.
|
||||||
*/
|
*/
|
||||||
function TextBlocks({ design, color, dividerColor, compact = false }) {
|
function TextBlocks({ design, color, dividerColor, compact = false, symbols }) {
|
||||||
const fontSize = compact ? 11.5 : 12.5;
|
const fontSize = compact ? 11.5 : 12.5;
|
||||||
const blocks = [];
|
const blocks = [];
|
||||||
|
|
||||||
if (design.rules_text) {
|
if (design.rules_text) {
|
||||||
blocks.push(
|
blocks.push(
|
||||||
<p key="desc" style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
|
<RichText key="desc" text={design.rules_text} symbols={symbols}
|
||||||
{design.rules_text}
|
style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }} />
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (design.actions) {
|
if (design.actions) {
|
||||||
if (blocks.length > 0) blocks.push(<Divider key="d1" color={dividerColor} />);
|
if (blocks.length > 0) blocks.push(<Divider key="d1" color={dividerColor} />);
|
||||||
blocks.push(
|
blocks.push(
|
||||||
<p key="actions" style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }}>
|
<RichText key="actions" text={design.actions} symbols={symbols}
|
||||||
{design.actions}
|
style={{ margin: 0, color, fontSize, lineHeight: 1.35, whiteSpace: 'pre-wrap', overflow: 'hidden' }} />
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (design.flavor_quote) {
|
if (design.flavor_quote) {
|
||||||
if (blocks.length > 0) blocks.push(<Divider key="d2" color={dividerColor} ornament />);
|
if (blocks.length > 0) blocks.push(<Divider key="d2" color={dividerColor} ornament />);
|
||||||
blocks.push(
|
blocks.push(
|
||||||
<p
|
<RichText
|
||||||
key="quote"
|
key="quote"
|
||||||
|
text={design.flavor_quote}
|
||||||
|
symbols={symbols}
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
color,
|
color,
|
||||||
|
|
@ -405,17 +419,56 @@ function TextBlocks({ design, color, dividerColor, compact = false }) {
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
prefix={<span style={{ opacity: 0.7, marginRight: 2, fontSize: fontSize + 3 }}>“</span>}
|
||||||
<span style={{ opacity: 0.7, marginRight: 2, fontSize: fontSize + 3 }}>“</span>
|
suffix={<span style={{ opacity: 0.7, marginLeft: 2, fontSize: fontSize + 3 }}>”</span>}
|
||||||
{design.flavor_quote}
|
/>
|
||||||
<span style={{ opacity: 0.7, marginLeft: 2, fontSize: fontSize + 3 }}>”</span>
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>{blocks}</div>;
|
return <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>{blocks}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renders text with `{CODE}` tokens replaced by inline symbol icons. */
|
||||||
|
export function RichText({ text, symbols, style, prefix, suffix }) {
|
||||||
|
const hasSymbols = symbols && Object.keys(symbols).length > 0;
|
||||||
|
const iconFor = (code) => {
|
||||||
|
if (!hasSymbols) return null;
|
||||||
|
return symbols[code] || symbols[code.toLowerCase()] || symbols[code.toUpperCase()] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parts = hasSymbols ? text.split(/(\{[^}]+\})/g) : [text];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p style={style}>
|
||||||
|
{prefix}
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
const match = part.match(/^\{([^}]+)\}$/);
|
||||||
|
if (match) {
|
||||||
|
const icon = iconFor(match[1]);
|
||||||
|
if (icon) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={`${match[1]}-${i}`}
|
||||||
|
src={icon}
|
||||||
|
alt={match[1]}
|
||||||
|
style={{
|
||||||
|
height: '1.2em',
|
||||||
|
width: 'auto',
|
||||||
|
verticalAlign: '-0.22em',
|
||||||
|
display: 'inline-block',
|
||||||
|
margin: '0 1px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
})}
|
||||||
|
{suffix}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Thin rule; ornament adds a small diamond at center. */
|
/** Thin rule; ornament adds a small diamond at center. */
|
||||||
function Divider({ color, ornament = false }) {
|
function Divider({ color, ornament = false }) {
|
||||||
if (!ornament) {
|
if (!ornament) {
|
||||||
|
|
|
||||||
33
migrations/1787711511000_add-frame-textures-game-sharing.js
Normal file
33
migrations/1787711511000_add-frame-textures-game-sharing.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* Designer follow-ups:
|
||||||
|
* - custom_frames.texture_url: optional background texture image for
|
||||||
|
* custom frames (renders behind the card's panels).
|
||||||
|
* - custom_games.is_public: share a custom game space to the community
|
||||||
|
* section (read-only).
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_frames
|
||||||
|
ADD COLUMN IF NOT EXISTS texture_url TEXT
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_games
|
||||||
|
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_games_public
|
||||||
|
ON custom_games(is_public)
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_games DROP COLUMN IF EXISTS is_public
|
||||||
|
`);
|
||||||
|
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE custom_frames DROP COLUMN IF EXISTS texture_url
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
@ -16,7 +16,7 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const found = await sql`
|
const found = await sql`
|
||||||
SELECT c.*, f.name AS frame_name, f.palette AS frame_palette
|
SELECT c.*, f.name AS frame_name, f.palette AS frame_palette, f.texture_url AS frame_texture
|
||||||
FROM custom_cards c
|
FROM custom_cards c
|
||||||
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
||||||
WHERE c.id = ${designId} AND c.user_id = ${user.userId}
|
WHERE c.id = ${designId} AND c.user_id = ${user.userId}
|
||||||
|
|
@ -24,12 +24,17 @@ export default async function handler(req, res) {
|
||||||
if (found.rows.length === 0) {
|
if (found.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'Design not found' });
|
return res.status(404).json({ error: 'Design not found' });
|
||||||
}
|
}
|
||||||
const { frame_name, frame_palette, ...row } = found.rows[0];
|
const { frame_name, frame_palette, frame_texture, ...row } = found.rows[0];
|
||||||
const existing = {
|
const existing = {
|
||||||
...row,
|
...row,
|
||||||
custom_frame:
|
custom_frame:
|
||||||
frame_name != null
|
frame_name != null
|
||||||
? { id: row.custom_frame_id, name: frame_name, palette: frame_palette }
|
? {
|
||||||
|
id: row.custom_frame_id,
|
||||||
|
name: frame_name,
|
||||||
|
palette: frame_palette,
|
||||||
|
texture_url: frame_texture,
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export default async function handler(req, res) {
|
||||||
c.frame_id, c.artwork_url, c.art_mode,
|
c.frame_id, c.artwork_url, c.art_mode,
|
||||||
c.game_target, c.custom_game_id, g.name AS custom_game_name,
|
c.game_target, c.custom_game_id, g.name AS custom_game_name,
|
||||||
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
|
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
|
||||||
|
f.texture_url AS frame_texture,
|
||||||
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
|
||||||
|
|
@ -31,11 +32,18 @@ export default async function handler(req, res) {
|
||||||
...row,
|
...row,
|
||||||
custom_frame:
|
custom_frame:
|
||||||
row.frame_pk != null
|
row.frame_pk != null
|
||||||
? { id: row.frame_pk, name: row.frame_name, palette: row.frame_palette }
|
? {
|
||||||
|
id: row.frame_pk,
|
||||||
|
name: row.frame_name,
|
||||||
|
palette: row.frame_palette,
|
||||||
|
texture_url: row.frame_texture,
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
designs: designs.map(({ frame_pk, frame_name, frame_palette, ...rest }) => rest),
|
designs: designs.map(
|
||||||
|
({ frame_pk, frame_name, frame_palette, frame_texture, ...rest }) => rest
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,20 @@ export default async function handler(req, res) {
|
||||||
return res.status(409).json({ error: 'You already have a frame with that name' });
|
return res.status(409).json({ error: 'You already have a frame with that name' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textureUrl =
|
||||||
|
req.body?.texture_url === null || req.body?.texture_url === ''
|
||||||
|
? null
|
||||||
|
: typeof req.body?.texture_url === 'string'
|
||||||
|
? req.body.texture_url.trim()
|
||||||
|
: found.rows[0].texture_url;
|
||||||
|
|
||||||
const updated = await sql`
|
const updated = await sql`
|
||||||
UPDATE custom_frames SET
|
UPDATE custom_frames SET
|
||||||
name = ${name}, palette = ${sql.json(palette)},
|
name = ${name}, palette = ${sql.json(palette)},
|
||||||
|
texture_url = ${textureUrl},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ${frameId}
|
WHERE id = ${frameId}
|
||||||
RETURNING id, name, palette, created_at, updated_at
|
RETURNING id, name, palette, texture_url, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
return res.status(200).json({ frame: updated.rows[0] });
|
return res.status(200).json({ frame: updated.rows[0] });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
163
pages/api/custom-frames/[id]/texture.js
Normal file
163
pages/api/custom-frames/[id]/texture.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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: '5mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — upload a background texture for a custom frame
|
||||||
|
* DELETE — remove the frame's texture
|
||||||
|
*/
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest(req);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameId = parseInt(req.query.id, 10);
|
||||||
|
if (!Number.isInteger(frameId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid frame id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await sql`
|
||||||
|
SELECT id, texture_url FROM custom_frames
|
||||||
|
WHERE id = ${frameId} AND user_id = ${user.userId}
|
||||||
|
`;
|
||||||
|
if (found.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Frame not found' });
|
||||||
|
}
|
||||||
|
const frame = found.rows[0];
|
||||||
|
|
||||||
|
const deleteStoredTexture = async () => {
|
||||||
|
if (!frame.texture_url) return;
|
||||||
|
try {
|
||||||
|
await del(frame.texture_url);
|
||||||
|
} catch (blobError) {
|
||||||
|
console.warn('Failed to delete old frame texture:', blobError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
await deleteStoredTexture();
|
||||||
|
await sql`
|
||||||
|
UPDATE custom_frames SET texture_url = NULL, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${frameId}
|
||||||
|
`;
|
||||||
|
return res.status(200).json({ texture_url: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const { allowed, reset } = await checkUploadRateLimit(req, user.userId);
|
||||||
|
if (!allowed) {
|
||||||
|
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
|
||||||
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = req.headers['content-type'];
|
||||||
|
if (!contentType || !contentType.startsWith('multipart/form-data')) {
|
||||||
|
return res.status(400).json({ error: 'Content-Type must be multipart/form-data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await parseMultipartFormData(req);
|
||||||
|
const file = formData.texture;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return res.status(400).json({ error: 'No texture 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteStoredTexture();
|
||||||
|
|
||||||
|
const extension = file.type === 'image/jpeg' ? 'jpg' : file.type.split('/')[1];
|
||||||
|
const filename = `frame-textures/${user.userId}-${frameId}-${Date.now()}.${extension}`;
|
||||||
|
const blob = await put(filename, file.buffer, {
|
||||||
|
access: 'public',
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE custom_frames SET texture_url = ${blob.url}, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${frameId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return res.status(200).json({ texture_url: blob.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Frame texture API error:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to handle frame texture' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT id, name, palette, created_at, updated_at
|
SELECT id, name, palette, texture_url, created_at, updated_at
|
||||||
FROM custom_frames
|
FROM custom_frames
|
||||||
WHERE user_id = ${user.userId}
|
WHERE user_id = ${user.userId}
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
|
|
@ -30,6 +30,11 @@ export default async function handler(req, res) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textureUrl =
|
||||||
|
typeof req.body?.texture_url === 'string' && req.body.texture_url.trim()
|
||||||
|
? req.body.texture_url.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
const clash = await sql`
|
const clash = await sql`
|
||||||
SELECT id FROM custom_frames
|
SELECT id FROM custom_frames
|
||||||
WHERE user_id = ${user.userId} AND lower(name) = ${name.toLowerCase()}
|
WHERE user_id = ${user.userId} AND lower(name) = ${name.toLowerCase()}
|
||||||
|
|
@ -39,9 +44,9 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = await sql`
|
const inserted = await sql`
|
||||||
INSERT INTO custom_frames (user_id, name, palette)
|
INSERT INTO custom_frames (user_id, name, palette, texture_url)
|
||||||
VALUES (${user.userId}, ${name}, ${sql.json(palette)})
|
VALUES (${user.userId}, ${name}, ${sql.json(palette)}, ${textureUrl})
|
||||||
RETURNING id, name, palette, created_at, updated_at
|
RETURNING id, name, palette, texture_url, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
return res.status(201).json({ frame: inserted.rows[0] });
|
return res.status(201).json({ frame: inserted.rows[0] });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,20 +24,41 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const cards = await sql`
|
const cards = await sql`
|
||||||
SELECT id, card_id, name, mana_cost, card_type, rarity,
|
SELECT c.id, c.card_id, c.name, c.mana_cost, c.card_type, c.rarity,
|
||||||
rules_text, actions, flavor_quote, power, toughness,
|
c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
|
||||||
frame_id, artwork_url, art_mode, created_at, updated_at
|
c.frame_id, c.artwork_url, c.art_mode,
|
||||||
FROM custom_cards
|
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
|
||||||
WHERE custom_game_id = ${gameId}
|
f.texture_url AS frame_texture,
|
||||||
ORDER BY updated_at DESC
|
c.created_at, c.updated_at
|
||||||
|
FROM custom_cards c
|
||||||
|
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
||||||
|
WHERE c.custom_game_id = ${gameId}
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
`;
|
`;
|
||||||
return res.status(200).json({ game, designs: cards.rows });
|
const designs = cards.rows.map((row) => {
|
||||||
|
const { frame_pk, frame_name, frame_palette, frame_texture, ...rest } = row;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
custom_frame:
|
||||||
|
frame_pk != null
|
||||||
|
? {
|
||||||
|
id: frame_pk,
|
||||||
|
name: frame_name,
|
||||||
|
palette: frame_palette,
|
||||||
|
texture_url: frame_texture,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return res.status(200).json({ game, designs });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
||||||
const description =
|
const description =
|
||||||
typeof req.body?.description === 'string' ? req.body.description.trim() : null;
|
typeof req.body?.description === 'string' ? req.body.description.trim() : null;
|
||||||
|
const isPublic =
|
||||||
|
typeof req.body?.is_public === 'boolean' ? req.body.is_public : game.is_public;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Game name is required' });
|
return res.status(400).json({ error: 'Game name is required' });
|
||||||
|
|
@ -55,7 +76,7 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
const updated = await sql`
|
const updated = await sql`
|
||||||
UPDATE custom_games SET
|
UPDATE custom_games SET
|
||||||
name = ${name}, description = ${description},
|
name = ${name}, description = ${description}, is_public = ${isPublic},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ${gameId}
|
WHERE id = ${gameId}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT g.id, g.name, g.description, g.created_at, g.updated_at,
|
SELECT g.id, g.name, g.description, g.is_public, g.created_at, g.updated_at,
|
||||||
COUNT(c.id) AS card_count
|
COUNT(c.id) AS card_count
|
||||||
FROM custom_games g
|
FROM custom_games g
|
||||||
LEFT JOIN custom_cards c ON c.custom_game_id = g.id
|
LEFT JOIN custom_cards c ON c.custom_game_id = g.id
|
||||||
|
|
|
||||||
62
pages/api/public/games/[id].js
Normal file
62
pages/api/public/games/[id].js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { sql } from '../../../../lib/sql.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public detail for one community-shared custom game plus its designs.
|
||||||
|
* No auth — read-only; 404 unless the game is marked public.
|
||||||
|
*/
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameId = parseInt(req.query.id, 10);
|
||||||
|
if (!Number.isInteger(gameId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid game id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await sql`
|
||||||
|
SELECT g.id, g.name, g.description, g.updated_at,
|
||||||
|
COALESCE(NULLIF(u.username, ''), split_part(u.email, '@', 1)) AS author
|
||||||
|
FROM custom_games g
|
||||||
|
JOIN users u ON u.id = g.user_id
|
||||||
|
WHERE g.id = ${gameId} AND g.is_public = true
|
||||||
|
`;
|
||||||
|
if (found.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Game not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = await sql`
|
||||||
|
SELECT c.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,
|
||||||
|
f.id AS frame_pk, f.name AS frame_name, f.palette AS frame_palette,
|
||||||
|
f.texture_url AS frame_texture
|
||||||
|
FROM custom_cards c
|
||||||
|
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
|
||||||
|
WHERE c.custom_game_id = ${gameId}
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const designs = cards.rows.map((row) => {
|
||||||
|
const { frame_pk, frame_name, frame_palette, frame_texture, ...rest } = row;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
custom_frame:
|
||||||
|
frame_pk != null
|
||||||
|
? {
|
||||||
|
id: frame_pk,
|
||||||
|
name: frame_name,
|
||||||
|
palette: frame_palette,
|
||||||
|
texture_url: frame_texture,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ game: found.rows[0], designs });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Public game API error:', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
30
pages/api/public/games/index.js
Normal file
30
pages/api/public/games/index.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { sql } from '../../../../lib/sql.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public listing of community-shared custom games. No auth — read-only.
|
||||||
|
*/
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sql`
|
||||||
|
SELECT g.id, g.name, g.description, g.updated_at,
|
||||||
|
COUNT(c.id) AS card_count,
|
||||||
|
COALESCE(NULLIF(u.username, ''), split_part(u.email, '@', 1)) AS author
|
||||||
|
FROM custom_games g
|
||||||
|
JOIN users u ON u.id = g.user_id
|
||||||
|
LEFT JOIN custom_cards c ON c.custom_game_id = g.id
|
||||||
|
WHERE g.is_public = true
|
||||||
|
GROUP BY g.id, u.username, u.email
|
||||||
|
ORDER BY card_count DESC, g.updated_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
|
||||||
|
return res.status(200).json({ games: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Public games API error:', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
111
pages/community/games/[id].js
Normal file
111
pages/community/games/[id].js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/* eslint-disable @next/next/no-img-element -- Artwork/symbols come from the MinIO CDN; next/image is out of scope for the designer canvas. */
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** Read-only public view of a community-shared custom game. */
|
||||||
|
export default function PublicGameSpace() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = router.query;
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [game, setGame] = useState(null);
|
||||||
|
const [designs, setDesigns] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return undefined;
|
||||||
|
|
||||||
|
const loadGame = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/public/games/${id}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
setGame(data.game);
|
||||||
|
setDesigns(data.designs);
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Game not found.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load the game.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadGame();
|
||||||
|
return undefined;
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const symbolsMap = {};
|
||||||
|
// Symbol icons render from per-user libraries; public views fall back
|
||||||
|
// to text pips unless codes are embedded later.
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !game) {
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="p-6 max-w-2xl mx-auto text-center py-20">
|
||||||
|
<h1 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{error}</h1>
|
||||||
|
<Button variant="primary" onClick={() => router.push('/community/games')}>
|
||||||
|
Back to Community Games
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||||
|
<div className="pt-2">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{game.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{game.description || 'Custom game system'} · by {game.author} · {designs.length} card{designs.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{designs.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No cards in this game yet
|
||||||
|
</h3>
|
||||||
|
</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">
|
||||||
|
<CardPreview design={design} maxWidth={280} symbols={symbolsMap} />
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
pages/community/games/index.js
Normal file
107
pages/community/games/index.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Layout from '../../../components/Layout';
|
||||||
|
import { useAuth } from '../../../lib/use-auth';
|
||||||
|
|
||||||
|
export default function CommunityGames() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [games, setGames] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadGames = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/public/games');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setGames(data.games);
|
||||||
|
} else {
|
||||||
|
setError('Failed to load community games.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load community games.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadGames();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||||
|
<div className="pt-2">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Community Games
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Custom game systems shared by the community — browse their cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
||||||
|
</div>
|
||||||
|
) : games.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No shared games yet
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{user
|
||||||
|
? 'Share one of your custom games from its game space to see it here.'
|
||||||
|
: 'Sign in and share a custom game to see it here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||||
|
{games.map((game) => (
|
||||||
|
<div
|
||||||
|
key={game.id}
|
||||||
|
className="glass-panel rounded-2xl p-5 cursor-pointer transition-all duration-200 hover:shadow-lg"
|
||||||
|
onClick={() => router.push(`/community/games/${game.id}`)}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && router.push(`/community/games/${game.id}`)}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold mb-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{game.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{game.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--accent-ember)' }}>
|
||||||
|
{game.card_count} card{game.card_count === 1 ? '' : 's'} →
|
||||||
|
</span>
|
||||||
|
<span className="text-xs truncate ml-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
by {game.author}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Managing your own games? Head to{' '}
|
||||||
|
<Link href="/games" style={{ color: 'var(--accent-ember)' }}>My Games</Link>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,8 @@ export default function Designer() {
|
||||||
const [symbolCode, setSymbolCode] = useState('');
|
const [symbolCode, setSymbolCode] = useState('');
|
||||||
const symbolFileRef = useRef(null);
|
const symbolFileRef = useRef(null);
|
||||||
const [uploadingSymbol, setUploadingSymbol] = useState(false);
|
const [uploadingSymbol, setUploadingSymbol] = useState(false);
|
||||||
|
const [uploadingTexture, setUploadingTexture] = useState(false);
|
||||||
|
const textureFileRef = useRef(null);
|
||||||
|
|
||||||
const cardRef = useRef(null);
|
const cardRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
@ -144,7 +146,12 @@ export default function Designer() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditFrameEditor = (frame) => {
|
const openEditFrameEditor = (frame) => {
|
||||||
setFrameEditor({ id: frame.id, name: frame.name, palette: { ...frame.palette } });
|
setFrameEditor({
|
||||||
|
id: frame.id,
|
||||||
|
name: frame.name,
|
||||||
|
palette: { ...frame.palette },
|
||||||
|
texture_url: frame.texture_url || null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveFrame = async () => {
|
const handleSaveFrame = async () => {
|
||||||
|
|
@ -254,6 +261,59 @@ export default function Designer() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUploadTexture = async (file) => {
|
||||||
|
if (!file || !frameEditor?.id) return;
|
||||||
|
setUploadingTexture(true);
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('texture', file);
|
||||||
|
const response = await fetch(`/api/custom-frames/${frameEditor.id}/texture`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok && data.texture_url) {
|
||||||
|
setFrames((prev) =>
|
||||||
|
prev.map((f) =>
|
||||||
|
f.id === frameEditor.id ? { ...f, texture_url: data.texture_url } : f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setFrameEditor((prev) => ({ ...prev, texture_url: data.texture_url }));
|
||||||
|
setMessage({ kind: 'success', text: 'Texture uploaded.' });
|
||||||
|
} else {
|
||||||
|
setMessage({ kind: 'error', text: data.error || 'Texture upload failed.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ kind: 'error', text: 'Texture upload failed.' });
|
||||||
|
} finally {
|
||||||
|
setUploadingTexture(false);
|
||||||
|
if (textureFileRef.current) textureFileRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTexture = async () => {
|
||||||
|
if (!frameEditor?.id) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/custom-frames/${frameEditor.id}/texture`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setFrames((prev) =>
|
||||||
|
prev.map((f) =>
|
||||||
|
f.id === frameEditor.id ? { ...f, texture_url: null } : f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setFrameEditor((prev) => ({ ...prev, texture_url: null }));
|
||||||
|
} else {
|
||||||
|
setMessage({ kind: 'error', text: 'Could not remove texture.' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ kind: 'error', text: 'Could not remove texture.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const symbolsMap = Object.fromEntries(symbols.map((s) => [s.code, s.image_url]));
|
const symbolsMap = Object.fromEntries(symbols.map((s) => [s.code, s.image_url]));
|
||||||
|
|
||||||
// Edit mode when ?id= is present
|
// Edit mode when ?id= is present
|
||||||
|
|
@ -680,9 +740,43 @@ export default function Designer() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{frameEditor.id && (
|
{frameEditor.id && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={textureFileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleUploadTexture(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => textureFileRef.current?.click()}
|
||||||
|
disabled={uploadingTexture}
|
||||||
|
>
|
||||||
|
{uploadingTexture
|
||||||
|
? 'Uploading…'
|
||||||
|
: frameEditor.texture_url
|
||||||
|
? 'Replace Texture'
|
||||||
|
: 'Upload Texture'}
|
||||||
|
</Button>
|
||||||
|
{frameEditor.texture_url && (
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleRemoveTexture}>
|
||||||
|
Remove Texture
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
|
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
|
||||||
Delete Frame
|
Delete Frame
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Textures show behind the frame panels.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!frameEditor.id && (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Save the frame first to add a background texture.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full max-w-[240px] shrink-0">
|
<div className="w-full max-w-[240px] shrink-0">
|
||||||
|
|
@ -692,7 +786,10 @@ export default function Designer() {
|
||||||
<CardFrame
|
<CardFrame
|
||||||
design={{
|
design={{
|
||||||
...design,
|
...design,
|
||||||
custom_frame: { palette: frameEditor.palette },
|
custom_frame: {
|
||||||
|
palette: frameEditor.palette,
|
||||||
|
texture_url: frameEditor.texture_url,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
symbols={symbolsMap}
|
symbols={symbolsMap}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
329
pages/designer/print.js
Normal file
329
pages/designer/print.js
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Layout from '../../components/Layout';
|
||||||
|
import CardFrame, { CARD_W, CARD_H } from '../../components/designer/CardFrame';
|
||||||
|
import { Button } from '../../components/ui';
|
||||||
|
import { useAuth } from '../../lib/use-auth';
|
||||||
|
|
||||||
|
// US Letter at 300 DPI. Standard TCG card = 63 x 88 mm -> 744 x 1040 px.
|
||||||
|
const SHEET_W = 2550;
|
||||||
|
const SHEET_H = 3300;
|
||||||
|
const CARD_PRINT_W = 744;
|
||||||
|
const CARD_PRINT_H = 1040;
|
||||||
|
const CARD_SCALE = CARD_PRINT_W / CARD_W; // 420 -> 744
|
||||||
|
|
||||||
|
const LAYOUTS = {
|
||||||
|
'3x3': { cols: 3, rows: 3, label: '3 × 3 (9 cards)' },
|
||||||
|
'2x2': { cols: 2, rows: 2, label: '2 × 2 (4 cards)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PrintSheet() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
const [designs, setDesigns] = useState([]);
|
||||||
|
const [symbolsMap, setSymbolsMap] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [layout, setLayout] = useState('3x3');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const sheetRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && !user) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [authLoading, user, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
const [cardsRes, symbolsRes] = await Promise.all([
|
||||||
|
fetch('/api/custom-cards', { headers }),
|
||||||
|
fetch('/api/custom-symbols', { headers }),
|
||||||
|
]);
|
||||||
|
if (cardsRes.ok) {
|
||||||
|
const data = await cardsRes.json();
|
||||||
|
setDesigns(data.designs);
|
||||||
|
// Preselect via ?ids=1,2,3 — otherwise everything.
|
||||||
|
const idsParam = router.query.ids;
|
||||||
|
if (typeof idsParam === 'string' && idsParam.length > 0) {
|
||||||
|
const ids = new Set(
|
||||||
|
idsParam.split(',').map((n) => parseInt(n, 10)).filter(Number.isInteger)
|
||||||
|
);
|
||||||
|
setSelected(new Set(data.designs.filter((d) => ids.has(d.id)).map((d) => d.id)));
|
||||||
|
} else {
|
||||||
|
setSelected(new Set(data.designs.map((d) => d.id)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('Failed to load your designs.');
|
||||||
|
}
|
||||||
|
if (symbolsRes.ok) {
|
||||||
|
const data = await symbolsRes.json();
|
||||||
|
setSymbolsMap(Object.fromEntries(data.symbols.map((s) => [s.code, s.image_url])));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load your designs.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
return undefined;
|
||||||
|
}, [user, router.query.ids]);
|
||||||
|
|
||||||
|
const toggle = (id) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedDesigns = useMemo(
|
||||||
|
() => designs.filter((d) => selected.has(d.id)),
|
||||||
|
[designs, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preview scale so the full sheet fits the preview pane.
|
||||||
|
const [previewScale, setPreviewScale] = useState(0.25);
|
||||||
|
const previewWrapRef = useRef(null);
|
||||||
|
|
||||||
|
const updatePreviewScale = useCallback(() => {
|
||||||
|
const el = previewWrapRef.current;
|
||||||
|
if (el) setPreviewScale(Math.min(0.35, el.clientWidth / SHEET_W));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updatePreviewScale();
|
||||||
|
const observer = new ResizeObserver(updatePreviewScale);
|
||||||
|
if (previewWrapRef.current) observer.observe(previewWrapRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [updatePreviewScale]);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!sheetRef.current || selectedDesigns.length === 0) return;
|
||||||
|
setExporting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { toPng } = await import('html-to-image');
|
||||||
|
const dataUrl = await toPng(sheetRef.current, {
|
||||||
|
width: SHEET_W,
|
||||||
|
height: SHEET_H,
|
||||||
|
pixelRatio: 1,
|
||||||
|
cacheBust: true,
|
||||||
|
});
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `deckhearth-print-sheet-${selectedDesigns.length}cards.png`;
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.click();
|
||||||
|
} catch {
|
||||||
|
setError('Export failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cols, rows } = LAYOUTS[layout];
|
||||||
|
const perSheet = cols * rows;
|
||||||
|
const slots = Array.from({ length: perSheet }, (_, i) => selectedDesigns[i] || null);
|
||||||
|
const sheetCount = Math.max(1, Math.ceil(selectedDesigns.length / perSheet));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||||
|
<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)' }}>
|
||||||
|
Print Sheet
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Arrange cards on a US Letter sheet (63 × 88 mm cards, cut guides included).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => router.push('/my-designs')}>Back</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exporting || selectedDesigns.length === 0}
|
||||||
|
>
|
||||||
|
{exporting ? 'Exporting…' : 'Download Sheet PNG'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,380px)_minmax(0,1fr)] gap-6">
|
||||||
|
{/* Controls + card picker */}
|
||||||
|
<div className="space-y-5 min-w-0">
|
||||||
|
<section className="glass-panel rounded-2xl p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Layout
|
||||||
|
</h2>
|
||||||
|
<div className="flex rounded-xl overflow-hidden border" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{Object.entries(LAYOUTS).map(([key, cfg]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLayout(key)}
|
||||||
|
className="flex-1 px-4 py-2 text-xs font-semibold transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: layout === key ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||||||
|
color: layout === key ? 'white' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
aria-pressed={layout === key}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{selectedDesigns.length} card{selectedDesigns.length === 1 ? '' : 's'} selected
|
||||||
|
{selectedDesigns.length > perSheet
|
||||||
|
? ` · ${sheetCount} sheets (export one at a time)`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-panel rounded-2xl p-5">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Cards
|
||||||
|
</h2>
|
||||||
|
{designs.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
No designs yet — create some in the{' '}
|
||||||
|
<Link href="/designer" style={{ color: 'var(--accent-ember)' }}>designer</Link> first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[480px] overflow-y-auto pr-1">
|
||||||
|
{designs.map((design) => (
|
||||||
|
<label
|
||||||
|
key={design.id}
|
||||||
|
className="flex items-center gap-3 rounded-xl px-3 py-2 cursor-pointer transition-all duration-150 hover:shadow-md"
|
||||||
|
style={{ backgroundColor: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(design.id)}
|
||||||
|
onChange={() => toggle(design.id)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{design.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sheet preview */}
|
||||||
|
<div>
|
||||||
|
<div className="glass-panel rounded-2xl p-5">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Sheet Preview
|
||||||
|
</h2>
|
||||||
|
<div ref={previewWrapRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: SHEET_W * previewScale,
|
||||||
|
height: SHEET_H * previewScale,
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ transform: `scale(${previewScale})`, transformOrigin: 'top left' }}>
|
||||||
|
<PrintSheetSurface
|
||||||
|
slots={slots}
|
||||||
|
cols={cols}
|
||||||
|
rows={rows}
|
||||||
|
sheetRef={sheetRef}
|
||||||
|
symbolsMap={symbolsMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actual 2550x3300 print surface. Cards render at natural 420x588
|
||||||
|
* then scale up 1.771x to true print size; dashed guides mark cut lines.
|
||||||
|
*/
|
||||||
|
function PrintSheetSurface({ slots, cols, rows, sheetRef, symbolsMap }) {
|
||||||
|
const marginX = (SHEET_W - cols * CARD_PRINT_W) / 2;
|
||||||
|
const marginTop = (SHEET_H - rows * CARD_PRINT_H) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
style={{
|
||||||
|
width: SHEET_W,
|
||||||
|
height: SHEET_H,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: 'Georgia, serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slots.map((design, i) => {
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
const x = marginX + col * CARD_PRINT_W;
|
||||||
|
const y = marginTop + row * CARD_PRINT_H;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={design ? design.id : `empty-${i}`}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
width: CARD_PRINT_W,
|
||||||
|
height: CARD_PRINT_H,
|
||||||
|
outline: '2px dashed rgba(0,0,0,0.25)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: design ? 'transparent' : '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{design && (
|
||||||
|
<div style={{ transform: `scale(${CARD_SCALE})`, transformOrigin: 'top left' }}>
|
||||||
|
<CardFrame design={design} symbols={symbolsMap} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
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 CardPreview from '../../components/designer/CardFrame';
|
||||||
|
|
@ -12,11 +13,13 @@ export default function GameSpace() {
|
||||||
|
|
||||||
const [game, setGame] = useState(null);
|
const [game, setGame] = useState(null);
|
||||||
const [designs, setDesigns] = useState([]);
|
const [designs, setDesigns] = useState([]);
|
||||||
|
const [symbolsMap, setSymbolsMap] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -41,6 +44,7 @@ export default function GameSpace() {
|
||||||
setDesigns(data.designs);
|
setDesigns(data.designs);
|
||||||
setName(data.game.name);
|
setName(data.game.name);
|
||||||
setDescription(data.game.description || '');
|
setDescription(data.game.description || '');
|
||||||
|
setIsPublic(Boolean(data.game.is_public));
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Game not found.');
|
setError(data.error || 'Game not found.');
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +59,29 @@ export default function GameSpace() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [user, id]);
|
}, [user, id]);
|
||||||
|
|
||||||
|
// Load the owner's symbols so icon pips render in previews.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
const loadSymbols = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const response = await fetch('/api/custom-symbols', {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSymbolsMap(Object.fromEntries(data.symbols.map((s) => [s.code, s.image_url])));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — text pips still work.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSymbols();
|
||||||
|
return undefined;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -63,11 +90,16 @@ export default function GameSpace() {
|
||||||
const response = await fetch(`/api/custom-games/${id}`, {
|
const response = await fetch(`/api/custom-games/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||||
body: JSON.stringify({ name, description }),
|
body: JSON.stringify({ name, description, is_public: isPublic }),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setGame((prev) => ({ ...prev, name: data.game.name, description: data.game.description }));
|
setGame((prev) => ({
|
||||||
|
...prev,
|
||||||
|
name: data.game.name,
|
||||||
|
description: data.game.description,
|
||||||
|
is_public: data.game.is_public,
|
||||||
|
}));
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || 'Save failed.');
|
setError(data.error || 'Save failed.');
|
||||||
|
|
@ -79,6 +111,29 @@ export default function GameSpace() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleShare = async () => {
|
||||||
|
const next = !isPublic;
|
||||||
|
setIsPublic(next);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const response = await fetch(`/api/custom-games/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||||
|
body: JSON.stringify({ name: game.name, description: game.description, is_public: next }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
setGame((prev) => ({ ...prev, is_public: data.game.is_public }));
|
||||||
|
} else {
|
||||||
|
setIsPublic(!next);
|
||||||
|
setError(data.error || 'Could not update sharing.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsPublic(!next);
|
||||||
|
setError('Could not update sharing.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!window.confirm(`Delete "${game?.name}"? Cards keep existing but leave the game.`)) return;
|
if (!window.confirm(`Delete "${game?.name}"? Cards keep existing but leave the game.`)) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -146,12 +201,20 @@ export default function GameSpace() {
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{game.description || 'Custom game system'} · {designs.length} card{designs.length === 1 ? '' : 's'}
|
{game.description || 'Custom game system'} · {designs.length} card{designs.length === 1 ? '' : 's'}
|
||||||
|
{isPublic && (
|
||||||
|
<>
|
||||||
|
{' '}· <Link href={`/community/games/${game.id}`} style={{ color: 'var(--accent-ember)' }}>public view</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="secondary" onClick={() => router.push('/games')}>My Games</Button>
|
<Button variant="secondary" onClick={() => router.push('/games')}>My Games</Button>
|
||||||
<Button variant="secondary" onClick={() => setEditing(true)}>Edit</Button>
|
<Button variant="secondary" onClick={() => setEditing(true)}>Edit</Button>
|
||||||
|
<Button variant="secondary" onClick={toggleShare}>
|
||||||
|
{isPublic ? 'Shared ✓' : 'Share'}
|
||||||
|
</Button>
|
||||||
<Button variant="secondary" onClick={handleDelete}>Delete</Button>
|
<Button variant="secondary" onClick={handleDelete}>Delete</Button>
|
||||||
<Button variant="primary" onClick={() => router.push(`/designer?game=${game.id}`)}>
|
<Button variant="primary" onClick={() => router.push(`/designer?game=${game.id}`)}>
|
||||||
+ New Card
|
+ New Card
|
||||||
|
|
@ -189,7 +252,7 @@ export default function GameSpace() {
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
|
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
|
||||||
>
|
>
|
||||||
<CardPreview design={design} maxWidth={280} />
|
<CardPreview design={design} maxWidth={280} symbols={symbolsMap} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,11 @@ export default function MyDesigns() {
|
||||||
<Button variant="primary" onClick={() => router.push('/designer')}>
|
<Button variant="primary" onClick={() => router.push('/designer')}>
|
||||||
+ New Design
|
+ New Design
|
||||||
</Button>
|
</Button>
|
||||||
|
{designs.length > 0 && (
|
||||||
|
<Button variant="secondary" onClick={() => router.push('/designer/print')}>
|
||||||
|
Print Sheet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ describe('/api/custom-games/[id]', () => {
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body.game.name).toBe('Aetherfall');
|
expect(res.body.game.name).toBe('Aetherfall');
|
||||||
expect(res.body.designs).toEqual([{ id: 3, name: 'Stormsage' }]);
|
expect(res.body.designs).toEqual([{ id: 3, name: 'Stormsage', custom_frame: null }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects renames that clash with another game', async () => {
|
it('rejects renames that clash with another game', async () => {
|
||||||
|
|
|
||||||
104
test/api/public-games.test.js
Normal file
104
test/api/public-games.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
|
||||||
|
|
||||||
|
import { sql } from '../../lib/sql.js';
|
||||||
|
import listHandler from '../../pages/api/public/games/index.js';
|
||||||
|
import itemHandler from '../../pages/api/public/games/[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('/api/public/games', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sql.mockResolvedValue({ rows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-GET methods', async () => {
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await listHandler({ method: 'POST' }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(405);
|
||||||
|
expect(sql).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists public games with author and card counts', async () => {
|
||||||
|
sql.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 9, name: 'Aetherfall', author: 'rstillw', card_count: 4 }],
|
||||||
|
});
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await listHandler({ method: 'GET' }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.games).toHaveLength(1);
|
||||||
|
const listed = sql.mock.calls[0][0].join('');
|
||||||
|
expect(listed).toContain('is_public = true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/public/games/[id]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sql.mockResolvedValue({ rows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid ids', async () => {
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await itemHandler({ method: 'GET', query: { id: 'abc' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s private or missing games', async () => {
|
||||||
|
sql.mockResolvedValueOnce({ rows: [] }); // public filter misses
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await itemHandler({ method: 'GET', query: { id: '9' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the game and its designs with resolved frames', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 9, name: 'Aetherfall', author: 'rstillw' }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Stormsage',
|
||||||
|
frame_pk: 2,
|
||||||
|
frame_name: 'Molten',
|
||||||
|
frame_palette: { outer: '#111111' },
|
||||||
|
frame_texture: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await itemHandler({ method: 'GET', query: { id: '9' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.body.game.name).toBe('Aetherfall');
|
||||||
|
expect(res.body.designs[0].custom_frame.name).toBe('Molten');
|
||||||
|
expect(res.body.designs[0].frame_pk).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue