diff --git a/components/Layout.js b/components/Layout.js
index 02e2006..ef6ce9a 100644
--- a/components/Layout.js
+++ b/components/Layout.js
@@ -253,6 +253,7 @@ function NavigationContent({ user, router, onItemClick }) {
items: [
{ name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
{ 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' }
]
};
diff --git a/components/designer/CardFrame.js b/components/designer/CardFrame.js
index a8d1cd1..ef43af5 100644
--- a/components/designer/CardFrame.js
+++ b/components/designer/CardFrame.js
@@ -17,6 +17,11 @@ export function resolvePalette(design) {
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
* 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 }) {
const p = resolvePalette(design);
+ const texture = resolveTexture(design);
const showPt = Boolean(design.power || design.toughness);
return (
@@ -42,6 +48,9 @@ function FramedCard({ design, innerRef, symbols }) {
width: CARD_W,
height: CARD_H,
backgroundColor: p.outer,
+ backgroundImage: texture ? `url(${texture})` : undefined,
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
borderRadius: 18,
border: `6px solid ${p.border}`,
boxSizing: 'border-box',
@@ -137,7 +146,7 @@ function FramedCard({ design, innerRef, symbols }) {
{/* Text box */}
-
+
);
}
@@ -146,6 +155,7 @@ function FramedCard({ design, innerRef, symbols }) {
function FullArtCard({ design, innerRef, symbols }) {
const p = resolvePalette(design);
+ const texture = resolveTexture(design);
const showPt = Boolean(design.power || design.toughness);
const hasArt = Boolean(design.artwork_url);
@@ -161,6 +171,9 @@ function FullArtCard({ design, innerRef, symbols }) {
boxSizing: 'border-box',
overflow: 'hidden',
backgroundColor: p.artBacking,
+ backgroundImage: texture ? `url(${texture})` : undefined,
+ backgroundSize: 'cover',
+ backgroundPosition: 'center',
fontFamily: 'Georgia, "Times New Roman", serif',
boxShadow: '0 10px 30px rgba(0,0,0,0.45)',
userSelect: 'none',
@@ -257,7 +270,7 @@ function FullArtCard({ design, innerRef, symbols }) {
-
+
{showPt && (
{hasAny ? (
-
+
) : (
- {design.rules_text}
-
+
);
}
if (design.actions) {
if (blocks.length > 0) blocks.push(
);
blocks.push(
-
- {design.actions}
-
+
);
}
if (design.flavor_quote) {
if (blocks.length > 0) blocks.push(
);
blocks.push(
-
- “
- {design.flavor_quote}
- ”
-
+ prefix={
“}
+ suffix={
”}
+ />
);
}
return
{blocks}
;
}
+/** 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 (
+
+ {prefix}
+ {parts.map((part, i) => {
+ const match = part.match(/^\{([^}]+)\}$/);
+ if (match) {
+ const icon = iconFor(match[1]);
+ if (icon) {
+ return (
+
+ );
+ }
+ }
+ return {part};
+ })}
+ {suffix}
+
+ );
+}
+
/** Thin rule; ornament adds a small diamond at center. */
function Divider({ color, ornament = false }) {
if (!ornament) {
diff --git a/migrations/1787711511000_add-frame-textures-game-sharing.js b/migrations/1787711511000_add-frame-textures-game-sharing.js
new file mode 100644
index 0000000..d5482d4
--- /dev/null
+++ b/migrations/1787711511000_add-frame-textures-game-sharing.js
@@ -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
+ `);
+};
diff --git a/pages/api/custom-cards/[id].js b/pages/api/custom-cards/[id].js
index f1bad78..10347cb 100644
--- a/pages/api/custom-cards/[id].js
+++ b/pages/api/custom-cards/[id].js
@@ -16,7 +16,7 @@ export default async function handler(req, res) {
}
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
LEFT JOIN custom_frames f ON f.id = c.custom_frame_id
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) {
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 = {
...row,
custom_frame:
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,
};
diff --git a/pages/api/custom-cards/index.js b/pages/api/custom-cards/index.js
index 145b331..4f1bd4b 100644
--- a/pages/api/custom-cards/index.js
+++ b/pages/api/custom-cards/index.js
@@ -20,6 +20,7 @@ export default async function handler(req, res) {
c.frame_id, c.artwork_url, c.art_mode,
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.texture_url AS frame_texture,
c.created_at, c.updated_at
FROM custom_cards c
LEFT JOIN custom_games g ON g.id = c.custom_game_id
@@ -31,11 +32,18 @@ export default async function handler(req, res) {
...row,
custom_frame:
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,
}));
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
+ ),
});
}
diff --git a/pages/api/custom-frames/[id].js b/pages/api/custom-frames/[id].js
index cae7da6..4e7b174 100644
--- a/pages/api/custom-frames/[id].js
+++ b/pages/api/custom-frames/[id].js
@@ -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' });
}
+ 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`
UPDATE custom_frames SET
name = ${name}, palette = ${sql.json(palette)},
+ texture_url = ${textureUrl},
updated_at = CURRENT_TIMESTAMP
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] });
}
diff --git a/pages/api/custom-frames/[id]/texture.js b/pages/api/custom-frames/[id]/texture.js
new file mode 100644
index 0000000..6c5f201
--- /dev/null
+++ b/pages/api/custom-frames/[id]/texture.js
@@ -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);
+ });
+}
diff --git a/pages/api/custom-frames/index.js b/pages/api/custom-frames/index.js
index dc4171b..3523af0 100644
--- a/pages/api/custom-frames/index.js
+++ b/pages/api/custom-frames/index.js
@@ -11,7 +11,7 @@ export default async function handler(req, res) {
if (req.method === 'GET') {
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
WHERE user_id = ${user.userId}
ORDER BY name ASC
@@ -30,6 +30,11 @@ export default async function handler(req, res) {
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`
SELECT id FROM custom_frames
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`
- INSERT INTO custom_frames (user_id, name, palette)
- VALUES (${user.userId}, ${name}, ${sql.json(palette)})
- RETURNING id, name, palette, created_at, updated_at
+ INSERT INTO custom_frames (user_id, name, palette, texture_url)
+ VALUES (${user.userId}, ${name}, ${sql.json(palette)}, ${textureUrl})
+ RETURNING id, name, palette, texture_url, created_at, updated_at
`;
return res.status(201).json({ frame: inserted.rows[0] });
}
diff --git a/pages/api/custom-games/[id].js b/pages/api/custom-games/[id].js
index 7cccf58..de01419 100644
--- a/pages/api/custom-games/[id].js
+++ b/pages/api/custom-games/[id].js
@@ -24,20 +24,41 @@ export default async function handler(req, res) {
if (req.method === 'GET') {
const cards = await sql`
- SELECT id, card_id, name, mana_cost, card_type, rarity,
- rules_text, actions, flavor_quote, power, toughness,
- frame_id, artwork_url, art_mode, created_at, updated_at
- FROM custom_cards
- WHERE custom_game_id = ${gameId}
- ORDER BY updated_at DESC
+ SELECT c.id, c.card_id, c.name, c.mana_cost, c.card_type, c.rarity,
+ c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
+ c.frame_id, c.artwork_url, c.art_mode,
+ 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
+ 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') {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
const description =
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) {
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`
UPDATE custom_games SET
- name = ${name}, description = ${description},
+ name = ${name}, description = ${description}, is_public = ${isPublic},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${gameId}
RETURNING *
diff --git a/pages/api/custom-games/index.js b/pages/api/custom-games/index.js
index 2ae0d43..6530079 100644
--- a/pages/api/custom-games/index.js
+++ b/pages/api/custom-games/index.js
@@ -10,7 +10,7 @@ export default async function handler(req, res) {
if (req.method === 'GET') {
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
FROM custom_games g
LEFT JOIN custom_cards c ON c.custom_game_id = g.id
diff --git a/pages/api/public/games/[id].js b/pages/api/public/games/[id].js
new file mode 100644
index 0000000..df07029
--- /dev/null
+++ b/pages/api/public/games/[id].js
@@ -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' });
+ }
+}
diff --git a/pages/api/public/games/index.js b/pages/api/public/games/index.js
new file mode 100644
index 0000000..90bcb65
--- /dev/null
+++ b/pages/api/public/games/index.js
@@ -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' });
+ }
+}
diff --git a/pages/community/games/[id].js b/pages/community/games/[id].js
new file mode 100644
index 0000000..60185db
--- /dev/null
+++ b/pages/community/games/[id].js
@@ -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 (
+
+
+
+ );
+ }
+
+ if (error && !game) {
+ return (
+
+
+
{error}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {game.name}
+
+
+ {game.description || 'Custom game system'} · by {game.author} · {designs.length} card{designs.length === 1 ? '' : 's'}
+
+
+
+ {designs.length === 0 ? (
+
+
+ No cards in this game yet
+
+
+ ) : (
+
+ {designs.map((design) => (
+
+
+
+
+ {design.name}
+
+
+ {design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/pages/community/games/index.js b/pages/community/games/index.js
new file mode 100644
index 0000000..40ef645
--- /dev/null
+++ b/pages/community/games/index.js
@@ -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 (
+
+
+
+
+ Community Games
+
+
+ Custom game systems shared by the community — browse their cards.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
+ ) : games.length === 0 ? (
+
+
+ No shared games yet
+
+
+ {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.'}
+
+
+ ) : (
+
+ {games.map((game) => (
+
router.push(`/community/games/${game.id}`)}
+ role="link"
+ tabIndex={0}
+ onKeyDown={(e) => e.key === 'Enter' && router.push(`/community/games/${game.id}`)}
+ >
+
+ {game.name}
+
+
+ {game.description || 'No description'}
+
+
+
+ {game.card_count} card{game.card_count === 1 ? '' : 's'} →
+
+
+ by {game.author}
+
+
+
+ ))}
+
+ )}
+
+ {user && (
+
+ Managing your own games? Head to{' '}
+ My Games.
+
+ )}
+
+
+ );
+}
diff --git a/pages/designer.js b/pages/designer.js
index 39a7e56..2637444 100644
--- a/pages/designer.js
+++ b/pages/designer.js
@@ -55,6 +55,8 @@ export default function Designer() {
const [symbolCode, setSymbolCode] = useState('');
const symbolFileRef = useRef(null);
const [uploadingSymbol, setUploadingSymbol] = useState(false);
+ const [uploadingTexture, setUploadingTexture] = useState(false);
+ const textureFileRef = useRef(null);
const cardRef = useRef(null);
const fileInputRef = useRef(null);
@@ -144,7 +146,12 @@ export default function Designer() {
};
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 () => {
@@ -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]));
// Edit mode when ?id= is present
@@ -680,9 +740,43 @@ export default function Designer() {
))}
{frameEditor.id && (
-
+ Save the frame first to add a background texture.
+
)}
@@ -692,7 +786,10 @@ export default function Designer() {
diff --git a/pages/designer/print.js b/pages/designer/print.js
new file mode 100644
index 0000000..94f2977
--- /dev/null
+++ b/pages/designer/print.js
@@ -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 (
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+ Print Sheet
+
+
+ Arrange cards on a US Letter sheet (63 × 88 mm cards, cut guides included).
+
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Controls + card picker */}
+
+
+
+ Layout
+
+
+ {Object.entries(LAYOUTS).map(([key, cfg]) => (
+
+ ))}
+
+
+ {selectedDesigns.length} card{selectedDesigns.length === 1 ? '' : 's'} selected
+ {selectedDesigns.length > perSheet
+ ? ` · ${sheetCount} sheets (export one at a time)`
+ : ''}
+
+
+
+
+
+
+ {/* Sheet preview */}
+
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ {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 (
+
+ {design && (
+
+
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/pages/games/[id].js b/pages/games/[id].js
index 9a4bf98..53b1ace 100644
--- a/pages/games/[id].js
+++ b/pages/games/[id].js
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import Link from 'next/link';
import { useRouter } from 'next/router';
import Layout from '../../components/Layout';
import CardPreview from '../../components/designer/CardFrame';
@@ -12,11 +13,13 @@ export default function GameSpace() {
const [game, setGame] = useState(null);
const [designs, setDesigns] = useState([]);
+ const [symbolsMap, setSymbolsMap] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
+ const [isPublic, setIsPublic] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -41,6 +44,7 @@ export default function GameSpace() {
setDesigns(data.designs);
setName(data.game.name);
setDescription(data.game.description || '');
+ setIsPublic(Boolean(data.game.is_public));
} else {
setError(data.error || 'Game not found.');
}
@@ -55,6 +59,29 @@ export default function GameSpace() {
return undefined;
}, [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 () => {
if (!name.trim()) return;
setSaving(true);
@@ -63,11 +90,16 @@ export default function GameSpace() {
const response = await fetch(`/api/custom-games/${id}`, {
method: 'PUT',
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();
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);
} else {
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 () => {
if (!window.confirm(`Delete "${game?.name}"? Cards keep existing but leave the game.`)) return;
try {
@@ -146,12 +201,20 @@ export default function GameSpace() {
{game.description || 'Custom game system'} · {designs.length} card{designs.length === 1 ? '' : 's'}
+ {isPublic && (
+ <>
+ {' '}· public view
+ >
+ )}
)}