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() {
+ Custom games group your designs — manage them in{' '} + My Games. +
++ {game.description || 'Custom game system'} · {designs.length} card{designs.length === 1 ? '' : 's'} +
++ Design the first card for {game.name}. +
+ ++ {design.name} +
++ {design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''} +
++ Custom game systems you've created — private to you. +
++ Create your first game above, then target designs toward it in the{' '} + Card Designer. +
++ {game.description || 'No description'} +
+ + {game.card_count} card{game.card_count === 1 ? '' : 's'} → + +- {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'} +
+