From c0051dd6d50a450b761b7a12430991e79da70296 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Mon, 24 Aug 2026 21:01:04 -0500 Subject: [PATCH] feat(designer): game targeting and custom game spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game system selector: standalone, existing system (MTG/Pokemon/Lorcana/ SWU/FaB/One Piece/Sorcery/Grand Archive — codes match catalog imports), or a user's custom game - custom_games table + CRUD API (private per user, unique names) - /games hub with create form; /games/[id] space with rename, delete, card gallery, and ?game= deep-link into the designer - catalog twin resolves game: system code, custom game name, or 'Custom' - my-designs shows each design's game association - migration 1787693311000 --- lib/custom-cards-fields.js | 7 + lib/designer-games.js | 24 +++ migrations/1787693311000_add-custom-games.js | 45 ++++ pages/api/custom-cards/[id].js | 6 +- pages/api/custom-cards/index.js | 50 ++++- pages/api/custom-games/[id].js | 78 +++++++ pages/api/custom-games/index.js | 58 +++++ pages/designer.js | 180 ++++++++++++++++ pages/games/[id].js | 212 +++++++++++++++++++ pages/games/index.js | 173 +++++++++++++++ pages/my-designs.js | 28 ++- test/api/custom-cards.test.js | 52 +++++ test/api/custom-games.test.js | 137 ++++++++++++ 13 files changed, 1028 insertions(+), 22 deletions(-) create mode 100644 lib/designer-games.js create mode 100644 migrations/1787693311000_add-custom-games.js create mode 100644 pages/api/custom-games/[id].js create mode 100644 pages/api/custom-games/index.js create mode 100644 pages/games/[id].js create mode 100644 pages/games/index.js create mode 100644 test/api/custom-games.test.js diff --git a/lib/custom-cards-fields.js b/lib/custom-cards-fields.js index 6876982..2c244c2 100644 --- a/lib/custom-cards-fields.js +++ b/lib/custom-cards-fields.js @@ -5,8 +5,13 @@ export function pickDesignFields(body = {}) { const str = (v) => (typeof v === 'string' ? v.trim() : null); + const int = (v) => { + const n = parseInt(v, 10); + return Number.isInteger(n) ? n : null; + }; const artMode = str(body.art_mode) || str(body.artMode) || 'framed'; + const gameTarget = str(body.game_target) || str(body.gameTarget) || 'custom'; return { name: str(body.name), @@ -21,5 +26,7 @@ export function pickDesignFields(body = {}) { frameId: str(body.frame_id) || str(body.frameId) || 'classic', artworkUrl: str(body.artwork_url) || str(body.artworkUrl), artMode: artMode === 'fullart' ? 'fullart' : 'framed', + gameTarget, + customGameId: int(body.custom_game_id) || int(body.customGameId), }; } diff --git a/lib/designer-games.js b/lib/designer-games.js new file mode 100644 index 0000000..00a4d4f --- /dev/null +++ b/lib/designer-games.js @@ -0,0 +1,24 @@ +/** + * Game systems available for card targeting. Values match the codes the + * catalog already uses for imported cards ('MTG', 'Pokemon', 'Lorcana') + * so designer cards filter correctly alongside imports. + */ +export const EXISTING_GAMES = [ + { value: 'MTG', label: 'Magic: The Gathering' }, + { value: 'Pokemon', label: 'Pokémon' }, + { value: 'Lorcana', label: 'Disney Lorcana' }, + { value: 'Star Wars Unlimited', label: 'Star Wars: Unlimited' }, + { value: 'Flesh and Blood', label: 'Flesh and Blood' }, + { value: 'One Piece', label: 'One Piece' }, + { value: 'Sorcery', label: 'Sorcery: Contested Realm' }, + { value: 'Grand Archive', label: 'Grand Archive' }, +]; + +export function isExistingGame(value) { + return EXISTING_GAMES.some((g) => g.value === value); +} + +export function gameLabel(value) { + const match = EXISTING_GAMES.find((g) => g.value === value); + return match ? match.label : value; +} diff --git a/migrations/1787693311000_add-custom-games.js b/migrations/1787693311000_add-custom-games.js new file mode 100644 index 0000000..be9c71e --- /dev/null +++ b/migrations/1787693311000_add-custom-games.js @@ -0,0 +1,45 @@ +/** + * Phase 2 — game targeting + custom game spaces. + * + * custom_games: private, per-user named game systems. Cards, frames, and + * (later) symbols can belong to one. + * + * custom_cards.game_target: 'custom' (default, standalone designs) or the + * code of an existing system ('MTG', 'Pokemon', 'Lorcana', ...). + * custom_cards.custom_game_id: set when the design belongs to a user's + * custom game. + */ +export const up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS custom_games ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_custom_games_user_name UNIQUE (user_id, name) + ) + `); + + pgm.sql(` + ALTER TABLE custom_cards + ADD COLUMN IF NOT EXISTS game_target VARCHAR(50) NOT NULL DEFAULT 'custom', + ADD COLUMN IF NOT EXISTS custom_game_id INTEGER REFERENCES custom_games(id) ON DELETE SET NULL + `); + + pgm.sql(` + CREATE INDEX IF NOT EXISTS idx_custom_cards_custom_game_id + ON custom_cards(custom_game_id) + `); +}; + +export const down = (pgm) => { + pgm.sql(` + ALTER TABLE custom_cards + DROP COLUMN IF EXISTS custom_game_id, + DROP COLUMN IF EXISTS game_target + `); + + pgm.sql(`DROP TABLE IF EXISTS custom_games CASCADE`); +}; diff --git a/pages/api/custom-cards/[id].js b/pages/api/custom-cards/[id].js index 907e79c..dffac09 100644 --- a/pages/api/custom-cards/[id].js +++ b/pages/api/custom-cards/[id].js @@ -1,7 +1,7 @@ import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; import { pickDesignFields } from '../../../lib/custom-cards-fields.js'; -import { syncCatalogCard, ensureOwnedRow } from './index.js'; +import { syncCatalogCard, ensureOwnedRow, resolveCatalogGame } from './index.js'; export default async function handler(req, res) { try { @@ -43,13 +43,15 @@ export default async function handler(req, res) { power = ${f.power}, toughness = ${f.toughness}, frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl}, art_mode = ${f.artMode}, + game_target = ${f.gameTarget}, custom_game_id = ${f.customGameId}, updated_at = CURRENT_TIMESTAMP WHERE id = ${designId} RETURNING * `; const design = updated.rows[0]; - const cardId = await syncCatalogCard(design, f); + const catalogGame = await resolveCatalogGame(user.userId, f); + const cardId = await syncCatalogCard(design, f, catalogGame); if (cardId) { await ensureOwnedRow(user.userId, cardId); if (!design.card_id) { diff --git a/pages/api/custom-cards/index.js b/pages/api/custom-cards/index.js index 21f1565..cc4e9f5 100644 --- a/pages/api/custom-cards/index.js +++ b/pages/api/custom-cards/index.js @@ -1,6 +1,7 @@ import { sql } from '../../../lib/sql.js'; import { getUserFromRequest } from '../../../lib/permission-middleware'; import { pickDesignFields } from '../../../lib/custom-cards-fields.js'; +import { isExistingGame } from '../../../lib/designer-games.js'; const CUSTOM_GAME = 'Custom'; const CUSTOM_SET = 'Designs'; @@ -14,12 +15,15 @@ export default async function handler(req, res) { if (req.method === 'GET') { const result = 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 user_id = ${user.userId} - 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, + c.game_target, c.custom_game_id, g.name AS custom_game_name, + c.created_at, c.updated_at + FROM custom_cards c + LEFT JOIN custom_games g ON g.id = c.custom_game_id + WHERE c.user_id = ${user.userId} + ORDER BY c.updated_at DESC `; return res.status(200).json({ designs: result.rows }); } @@ -43,22 +47,25 @@ async function createDesign(userId, body) { const f = pickDesignFields(body); if (!f.name) return null; + const catalogGame = await resolveCatalogGame(userId, f); + const inserted = await sql` INSERT INTO custom_cards (user_id, name, mana_cost, card_type, rarity, rules_text, actions, flavor_quote, power, toughness, frame_id, artwork_url, - art_mode) + art_mode, game_target, custom_game_id) VALUES (${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity}, ${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power}, - ${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode}) + ${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode}, + ${f.gameTarget}, ${f.customGameId}) RETURNING * `; const design = inserted.rows[0]; // Mirror into the shared catalog so the design shows up in My Cards, // lists, and decks through the normal card joins. - const cardId = await syncCatalogCard(design, f); + const cardId = await syncCatalogCard(design, f, catalogGame); if (cardId) { await ensureOwnedRow(userId, cardId); const linked = await sql` @@ -70,14 +77,35 @@ async function createDesign(userId, body) { return design; } +/** + * Resolve the catalog `game` value for a design: an existing system code, + * the owning custom game's name, or the generic 'Custom' bucket. + */ +export async function resolveCatalogGame(userId, fields) { + if (fields.gameTarget && isExistingGame(fields.gameTarget)) { + return fields.gameTarget; + } + if (fields.customGameId) { + const rows = await sql` + SELECT name FROM custom_games + WHERE id = ${fields.customGameId} AND user_id = ${userId} + `; + if (rows.rows.length > 0) { + // cards.game is VARCHAR(50) — custom game names cap at 100, so clip. + return rows.rows[0].name.slice(0, 50); + } + } + return CUSTOM_GAME; +} + /** Create/update the catalog twin of a custom design; returns card id. */ -export async function syncCatalogCard(design, fields) { +export async function syncCatalogCard(design, fields, catalogGame) { const values = { name: fields.name, setName: CUSTOM_SET, setCode: 'DSGN', rarity: fields.rarity, - game: CUSTOM_GAME, + game: catalogGame || CUSTOM_GAME, manaCost: fields.manaCost, cardType: fields.cardType, oracleText: diff --git a/pages/api/custom-games/[id].js b/pages/api/custom-games/[id].js new file mode 100644 index 0000000..7cccf58 --- /dev/null +++ b/pages/api/custom-games/[id].js @@ -0,0 +1,78 @@ +import { sql } from '../../../lib/sql.js'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; + +export default async function handler(req, res) { + try { + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + 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 * FROM custom_games + WHERE id = ${gameId} AND user_id = ${user.userId} + `; + if (found.rows.length === 0) { + return res.status(404).json({ error: 'Game not found' }); + } + const game = found.rows[0]; + + 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 + `; + return res.status(200).json({ game, designs: cards.rows }); + } + + 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; + + if (!name) { + return res.status(400).json({ error: 'Game name is required' }); + } + + const clash = await sql` + SELECT id FROM custom_games + WHERE user_id = ${user.userId} + AND lower(name) = ${name.toLowerCase()} + AND id <> ${gameId} + `; + if (clash.rows.length > 0) { + return res.status(409).json({ error: 'You already have a game with that name' }); + } + + const updated = await sql` + UPDATE custom_games SET + name = ${name}, description = ${description}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${gameId} + RETURNING * + `; + return res.status(200).json({ game: updated.rows[0] }); + } + + if (req.method === 'DELETE') { + // Designs keep existing (custom_game_id drops to NULL via FK); + // their catalog twins stay in the collection untouched. + await sql`DELETE FROM custom_games WHERE id = ${gameId}`; + return res.status(200).json({ message: 'Game deleted' }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (error) { + console.error('Custom game API error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/api/custom-games/index.js b/pages/api/custom-games/index.js new file mode 100644 index 0000000..2ae0d43 --- /dev/null +++ b/pages/api/custom-games/index.js @@ -0,0 +1,58 @@ +import { sql } from '../../../lib/sql.js'; +import { getUserFromRequest } from '../../../lib/permission-middleware'; + +export default async function handler(req, res) { + try { + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (req.method === 'GET') { + const result = await sql` + SELECT g.id, g.name, g.description, 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 + WHERE g.user_id = ${user.userId} + GROUP BY g.id + ORDER BY g.name ASC + `; + return res.status(200).json({ games: result.rows }); + } + + if (req.method === 'POST') { + const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + const description = + typeof req.body?.description === 'string' ? req.body.description.trim() : null; + + if (!name) { + return res.status(400).json({ error: 'Game name is required' }); + } + if (name.length > 100) { + return res.status(400).json({ error: 'Game name must be 100 characters or fewer' }); + } + + const existing = await sql` + SELECT id FROM custom_games + WHERE user_id = ${user.userId} AND lower(name) = ${name.toLowerCase()} + `; + if (existing.rows.length > 0) { + return res.status(409).json({ error: 'You already have a game with that name' }); + } + + const inserted = await sql` + INSERT INTO custom_games (user_id, name, description) + VALUES (${user.userId}, ${name}, ${description}) + RETURNING id, name, description, created_at, updated_at + `; + const game = { ...inserted.rows[0], card_count: 0 }; + return res.status(201).json({ game }); + } + + return res.status(405).json({ error: 'Method not allowed' }); + } catch (error) { + console.error('Custom games API error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/pages/designer.js b/pages/designer.js index 4881f2b..77dcf2b 100644 --- a/pages/designer.js +++ b/pages/designer.js @@ -4,6 +4,7 @@ import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import CardPreview from '../components/designer/CardFrame'; import { FRAMES, RARITIES } from '../components/designer/frames'; +import { EXISTING_GAMES } from '../lib/designer-games.js'; import { Button } from '../components/ui'; import { useAuth } from '../lib/use-auth'; @@ -21,6 +22,8 @@ const BLANK_DESIGN = { frame_id: 'classic', artwork_url: '', art_mode: 'framed', + game_target: 'custom', + custom_game_id: null, }; export default function Designer() { @@ -33,9 +36,65 @@ export default function Designer() { const [uploading, setUploading] = useState(false); const [message, setMessage] = useState(null); + // Custom games for targeting + const [games, setGames] = useState([]); + const [newGameName, setNewGameName] = useState(''); + const [creatingGame, setCreatingGame] = useState(false); + const cardRef = useRef(null); const fileInputRef = useRef(null); + const authHeaders = useCallback(() => { + const token = localStorage.getItem('auth_token'); + return token ? { Authorization: `Bearer ${token}` } : {}; + }, []); + + const loadGames = useCallback(async () => { + try { + const response = await fetch('/api/custom-games', { headers: authHeaders() }); + if (response.ok) { + const data = await response.json(); + setGames(data.games); + } + } catch { + // Non-fatal — designer works without custom games. + } + }, [authHeaders]); + + useEffect(() => { + if (user) loadGames(); // eslint-disable-line react-hooks/set-state-in-effect -- fetch-driven reload via async loadGames + }, [user, loadGames]); + + const handleCreateGame = async () => { + const name = newGameName.trim(); + if (!name) return; + setCreatingGame(true); + try { + const response = await fetch('/api/custom-games', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ name }), + }); + const data = await response.json(); + if (response.ok) { + setGames((prev) => [...prev, data.game]); + setDesign((prev) => ({ + ...prev, + game_target: 'custom', + custom_game_id: data.game.id, + })); + setNewGameName(''); + setMessage({ kind: 'success', text: `Game "${data.game.name}" created.` }); + } else { + setMessage({ kind: 'error', text: data.error || 'Could not create game.' }); + } + } catch { + setMessage({ kind: 'error', text: 'Could not create game.' }); + } finally { + setCreatingGame(false); + } + }; + // Edit mode when ?id= is present useEffect(() => { if (!user || !router.query.id) return; @@ -60,6 +119,19 @@ export default function Designer() { loadDesign(); }, [user, router.query.id]); + // Preselect a custom game when ?game= is present + useEffect(() => { + if (!user || !router.query.game) return; + const gameId = parseInt(router.query.game, 10); + if (!Number.isInteger(gameId)) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- URL param → form state sync + setDesign((prev) => + prev.custom_game_id === gameId && prev.game_target === 'custom' + ? prev + : { ...prev, game_target: 'custom', custom_game_id: gameId } + ); + }, [user, router.query.game]); + const setField = useCallback((field, value) => { setDesign((prev) => ({ ...prev, [field]: value })); }, []); @@ -205,6 +277,114 @@ export default function Designer() {
{/* ── Form column ─────────────────────────────── */}
+ {/* Game targeting */} +
+

+ Game System +

+
+ {[ + { id: 'custom', label: 'Just my designs' }, + { id: 'existing', label: 'Existing system' }, + { id: 'custom-game', label: 'My custom game' }, + ].map((mode) => { + const active = + mode.id === 'custom' + ? design.game_target === 'custom' && !design.custom_game_id + : mode.id === 'existing' + ? design.game_target !== 'custom' + : Boolean(design.custom_game_id); + return ( + + ); + })} +
+ + {design.game_target !== 'custom' && ( + + )} + + {design.game_target === 'custom' && ( +
+ {games.length > 0 && ( + + )} +
+ setNewGameName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateGame()} + placeholder="New game name…" + maxLength={100} + /> + +
+

+ Custom games group your designs — manage them in{' '} + My Games. +

+
+ )} +
+ {/* Frame picker */}

diff --git a/pages/games/[id].js b/pages/games/[id].js new file mode 100644 index 0000000..9a4bf98 --- /dev/null +++ b/pages/games/[id].js @@ -0,0 +1,212 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import Layout from '../../components/Layout'; +import CardPreview from '../../components/designer/CardFrame'; +import { Button, Input } from '../../components/ui'; +import { useAuth } from '../../lib/use-auth'; + +export default function GameSpace() { + const router = useRouter(); + const { id } = router.query; + const { user, loading: authLoading } = useAuth(); + + const [game, setGame] = useState(null); + const [designs, setDesigns] = 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 [saving, setSaving] = useState(false); + + useEffect(() => { + if (!authLoading && !user) { + router.push('/login'); + } + }, [authLoading, user, router]); + + useEffect(() => { + if (!user || !id) return undefined; + + const loadGame = async () => { + setLoading(true); + try { + const token = localStorage.getItem('auth_token'); + const response = await fetch(`/api/custom-games/${id}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + const data = await response.json(); + if (response.ok) { + setGame(data.game); + setDesigns(data.designs); + setName(data.game.name); + setDescription(data.game.description || ''); + } else { + setError(data.error || 'Game not found.'); + } + } catch { + setError('Failed to load the game.'); + } finally { + setLoading(false); + } + }; + + loadGame(); + return undefined; + }, [user, id]); + + const handleSave = async () => { + if (!name.trim()) return; + setSaving(true); + 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, description }), + }); + const data = await response.json(); + if (response.ok) { + setGame((prev) => ({ ...prev, name: data.game.name, description: data.game.description })); + setEditing(false); + } else { + setError(data.error || 'Save failed.'); + } + } catch { + setError('Save failed.'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!window.confirm(`Delete "${game?.name}"? Cards keep existing but leave the game.`)) return; + try { + const token = localStorage.getItem('auth_token'); + const response = await fetch(`/api/custom-games/${id}`, { + method: 'DELETE', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (response.ok) { + router.push('/games'); + } else { + setError('Delete failed.'); + } + } catch { + setError('Delete failed.'); + } + }; + + if (loading) { + return ( + +
+
+
+ + ); + } + + if (error && !game) { + return ( + +
+

{error}

+ +
+
+ ); + } + + return ( + +
+ {/* Header */} +
+ {editing ? ( +
+ setName(e.target.value)} maxLength={100} /> + setDescription(e.target.value)} + placeholder="Description (optional)" + maxLength={300} + /> +
+ + +
+
+ ) : ( +
+

+ {game.name} +

+

+ {game.description || 'Custom game system'} · {designs.length} card{designs.length === 1 ? '' : 's'} +

+
+ )} +
+ + + + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Designs grid */} + {designs.length === 0 ? ( +
+

+ No cards in this game yet +

+

+ Design the first card for {game.name}. +

+ +
+ ) : ( +
+ {designs.map((design) => ( +
+
router.push(`/designer?id=${design.id}`)} + role="link" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)} + > + +
+
+

+ {design.name} +

+

+ {design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''} +

+
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/pages/games/index.js b/pages/games/index.js new file mode 100644 index 0000000..a0c06c7 --- /dev/null +++ b/pages/games/index.js @@ -0,0 +1,173 @@ +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import Layout from '../../components/Layout'; +import { Button, Input } from '../../components/ui'; +import { useAuth } from '../../lib/use-auth'; + +export default function MyGames() { + const router = useRouter(); + const { user, loading: authLoading } = useAuth(); + + const [games, setGames] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [creating, setCreating] = useState(false); + + useEffect(() => { + if (!authLoading && !user) { + router.push('/login'); + } + }, [authLoading, user, router]); + + useEffect(() => { + if (!user) return undefined; + + const loadGames = async () => { + try { + const token = localStorage.getItem('auth_token'); + const response = await fetch('/api/custom-games', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (response.ok) { + const data = await response.json(); + setGames(data.games); + } else { + setError('Failed to load your games.'); + } + } catch { + setError('Failed to load your games.'); + } finally { + setLoading(false); + } + }; + + loadGames(); + return undefined; + }, [user]); + + const handleCreate = async (e) => { + e.preventDefault(); + if (!name.trim()) return; + setCreating(true); + setError(null); + try { + const token = localStorage.getItem('auth_token'); + const response = await fetch('/api/custom-games', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ name, description }), + }); + const data = await response.json(); + if (response.ok) { + setGames((prev) => + [...prev, data.game].sort((a, b) => a.name.localeCompare(b.name)) + ); + setName(''); + setDescription(''); + } else { + setError(data.error || 'Could not create the game.'); + } + } catch { + setError('Could not create the game.'); + } finally { + setCreating(false); + } + }; + + if (loading) { + return ( + +
+
+
+ + ); + } + + return ( + +
+
+

+ My Games +

+

+ Custom game systems you've created — private to you. +

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Create form */} +
+

+ New Game +

+
+ setName(e.target.value)} + placeholder="Game name (e.g. Aetherfall)" + maxLength={100} + required + /> + setDescription(e.target.value)} + placeholder="Short description (optional)" + maxLength={300} + /> + +
+
+ + {/* Games grid */} + {games.length === 0 ? ( +
+

+ No custom games yet +

+

+ Create your first game above, then target designs toward it in the{' '} + Card Designer. +

+
+ ) : ( +
+ {games.map((game) => ( +
router.push(`/games/${game.id}`)} + role="link" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && router.push(`/games/${game.id}`)} + > +

+ {game.name} +

+

+ {game.description || 'No description'} +

+ + {game.card_count} card{game.card_count === 1 ? '' : 's'} → + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/pages/my-designs.js b/pages/my-designs.js index 8847907..6959716 100644 --- a/pages/my-designs.js +++ b/pages/my-designs.js @@ -1,7 +1,9 @@ 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'; +import { gameLabel } from '../lib/designer-games.js'; import { Button } from '../components/ui'; import { useAuth } from '../lib/use-auth'; @@ -83,7 +85,8 @@ export default function MyDesigns() { My Designs

- {designs.length} custom card{designs.length === 1 ? '' : 's'} · also visible in your collection + {designs.length} custom card{designs.length === 1 ? '' : 's'} · also visible in your collection ·{' '} + My Games

-
-

- {design.name} -

-

- {design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''} -

-
+
+

+ {design.name} +

+

+ {design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''} +

+

+ {design.custom_game_name + ? design.custom_game_name + : design.game_target && design.game_target !== 'custom' + ? gameLabel(design.game_target) + : 'Standalone'} +

+