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 ( + {match[1]} + ); + } + } + 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 && ( - +
+ handleUploadTexture(e.target.files?.[0])} + /> + + {frameEditor.texture_url && ( + + )} + + + Textures show behind the frame panels. + +
+ )} + {!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)` + : ''} +

+
+ +
+

+ Cards +

+ {designs.length === 0 ? ( +

+ No designs yet — create some in the{' '} + designer first. +

+ ) : ( +
+ {designs.map((design) => ( + + ))} +
+ )} +
+
+ + {/* Sheet preview */} +
+
+

+ 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 + + )}

)}
+

diff --git a/pages/my-designs.js b/pages/my-designs.js index 6959716..a951f60 100644 --- a/pages/my-designs.js +++ b/pages/my-designs.js @@ -92,6 +92,11 @@ export default function MyDesigns() { + {designs.length > 0 && ( + + )}

{error && ( diff --git a/test/api/custom-games.test.js b/test/api/custom-games.test.js index 98886fe..64568de 100644 --- a/test/api/custom-games.test.js +++ b/test/api/custom-games.test.js @@ -106,7 +106,7 @@ describe('/api/custom-games/[id]', () => { expect(res.statusCode).toBe(200); 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 () => { diff --git a/test/api/public-games.test.js b/test/api/public-games.test.js new file mode 100644 index 0000000..c4a85a6 --- /dev/null +++ b/test/api/public-games.test.js @@ -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(); + }); +});