feat(designer): game targeting and custom game spaces
- 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
This commit is contained in:
parent
147041d292
commit
c0051dd6d5
13 changed files with 1028 additions and 22 deletions
|
|
@ -5,8 +5,13 @@
|
||||||
|
|
||||||
export function pickDesignFields(body = {}) {
|
export function pickDesignFields(body = {}) {
|
||||||
const str = (v) => (typeof v === 'string' ? v.trim() : null);
|
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 artMode = str(body.art_mode) || str(body.artMode) || 'framed';
|
||||||
|
const gameTarget = str(body.game_target) || str(body.gameTarget) || 'custom';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: str(body.name),
|
name: str(body.name),
|
||||||
|
|
@ -21,5 +26,7 @@ export function pickDesignFields(body = {}) {
|
||||||
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
|
frameId: str(body.frame_id) || str(body.frameId) || 'classic',
|
||||||
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
|
artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
|
||||||
artMode: artMode === 'fullart' ? 'fullart' : 'framed',
|
artMode: artMode === 'fullart' ? 'fullart' : 'framed',
|
||||||
|
gameTarget,
|
||||||
|
customGameId: int(body.custom_game_id) || int(body.customGameId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
lib/designer-games.js
Normal file
24
lib/designer-games.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
45
migrations/1787693311000_add-custom-games.js
Normal file
45
migrations/1787693311000_add-custom-games.js
Normal file
|
|
@ -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`);
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
import { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
|
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) {
|
export default async function handler(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -43,13 +43,15 @@ export default async function handler(req, res) {
|
||||||
power = ${f.power}, toughness = ${f.toughness},
|
power = ${f.power}, toughness = ${f.toughness},
|
||||||
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
|
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
|
||||||
art_mode = ${f.artMode},
|
art_mode = ${f.artMode},
|
||||||
|
game_target = ${f.gameTarget}, custom_game_id = ${f.customGameId},
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ${designId}
|
WHERE id = ${designId}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
const design = updated.rows[0];
|
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) {
|
if (cardId) {
|
||||||
await ensureOwnedRow(user.userId, cardId);
|
await ensureOwnedRow(user.userId, cardId);
|
||||||
if (!design.card_id) {
|
if (!design.card_id) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { sql } from '../../../lib/sql.js';
|
import { sql } from '../../../lib/sql.js';
|
||||||
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
import { getUserFromRequest } from '../../../lib/permission-middleware';
|
||||||
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
|
import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
|
||||||
|
import { isExistingGame } from '../../../lib/designer-games.js';
|
||||||
|
|
||||||
const CUSTOM_GAME = 'Custom';
|
const CUSTOM_GAME = 'Custom';
|
||||||
const CUSTOM_SET = 'Designs';
|
const CUSTOM_SET = 'Designs';
|
||||||
|
|
@ -14,12 +15,15 @@ export default async function handler(req, res) {
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
const result = await sql`
|
const result = await sql`
|
||||||
SELECT id, card_id, name, mana_cost, card_type, rarity,
|
SELECT c.id, c.card_id, c.name, c.mana_cost, c.card_type, c.rarity,
|
||||||
rules_text, actions, flavor_quote, power, toughness,
|
c.rules_text, c.actions, c.flavor_quote, c.power, c.toughness,
|
||||||
frame_id, artwork_url, art_mode, created_at, updated_at
|
c.frame_id, c.artwork_url, c.art_mode,
|
||||||
FROM custom_cards
|
c.game_target, c.custom_game_id, g.name AS custom_game_name,
|
||||||
WHERE user_id = ${user.userId}
|
c.created_at, c.updated_at
|
||||||
ORDER BY updated_at DESC
|
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 });
|
return res.status(200).json({ designs: result.rows });
|
||||||
}
|
}
|
||||||
|
|
@ -43,22 +47,25 @@ async function createDesign(userId, body) {
|
||||||
const f = pickDesignFields(body);
|
const f = pickDesignFields(body);
|
||||||
if (!f.name) return null;
|
if (!f.name) return null;
|
||||||
|
|
||||||
|
const catalogGame = await resolveCatalogGame(userId, f);
|
||||||
|
|
||||||
const inserted = await sql`
|
const inserted = await sql`
|
||||||
INSERT INTO custom_cards
|
INSERT INTO custom_cards
|
||||||
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
(user_id, name, mana_cost, card_type, rarity, rules_text,
|
||||||
actions, flavor_quote, power, toughness, frame_id, artwork_url,
|
actions, flavor_quote, power, toughness, frame_id, artwork_url,
|
||||||
art_mode)
|
art_mode, game_target, custom_game_id)
|
||||||
VALUES
|
VALUES
|
||||||
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
|
||||||
${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power},
|
${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 *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
const design = inserted.rows[0];
|
const design = inserted.rows[0];
|
||||||
|
|
||||||
// Mirror into the shared catalog so the design shows up in My Cards,
|
// Mirror into the shared catalog so the design shows up in My Cards,
|
||||||
// lists, and decks through the normal card joins.
|
// lists, and decks through the normal card joins.
|
||||||
const cardId = await syncCatalogCard(design, f);
|
const cardId = await syncCatalogCard(design, f, catalogGame);
|
||||||
if (cardId) {
|
if (cardId) {
|
||||||
await ensureOwnedRow(userId, cardId);
|
await ensureOwnedRow(userId, cardId);
|
||||||
const linked = await sql`
|
const linked = await sql`
|
||||||
|
|
@ -70,14 +77,35 @@ async function createDesign(userId, body) {
|
||||||
return design;
|
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. */
|
/** 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 = {
|
const values = {
|
||||||
name: fields.name,
|
name: fields.name,
|
||||||
setName: CUSTOM_SET,
|
setName: CUSTOM_SET,
|
||||||
setCode: 'DSGN',
|
setCode: 'DSGN',
|
||||||
rarity: fields.rarity,
|
rarity: fields.rarity,
|
||||||
game: CUSTOM_GAME,
|
game: catalogGame || CUSTOM_GAME,
|
||||||
manaCost: fields.manaCost,
|
manaCost: fields.manaCost,
|
||||||
cardType: fields.cardType,
|
cardType: fields.cardType,
|
||||||
oracleText:
|
oracleText:
|
||||||
|
|
|
||||||
78
pages/api/custom-games/[id].js
Normal file
78
pages/api/custom-games/[id].js
Normal file
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
pages/api/custom-games/index.js
Normal file
58
pages/api/custom-games/index.js
Normal file
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import CardPreview from '../components/designer/CardFrame';
|
import CardPreview from '../components/designer/CardFrame';
|
||||||
import { FRAMES, RARITIES } from '../components/designer/frames';
|
import { FRAMES, RARITIES } from '../components/designer/frames';
|
||||||
|
import { EXISTING_GAMES } from '../lib/designer-games.js';
|
||||||
import { Button } from '../components/ui';
|
import { Button } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
|
|
||||||
|
|
@ -21,6 +22,8 @@ const BLANK_DESIGN = {
|
||||||
frame_id: 'classic',
|
frame_id: 'classic',
|
||||||
artwork_url: '',
|
artwork_url: '',
|
||||||
art_mode: 'framed',
|
art_mode: 'framed',
|
||||||
|
game_target: 'custom',
|
||||||
|
custom_game_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Designer() {
|
export default function Designer() {
|
||||||
|
|
@ -33,9 +36,65 @@ export default function Designer() {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [message, setMessage] = useState(null);
|
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 cardRef = useRef(null);
|
||||||
const fileInputRef = 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
|
// Edit mode when ?id= is present
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !router.query.id) return;
|
if (!user || !router.query.id) return;
|
||||||
|
|
@ -60,6 +119,19 @@ export default function Designer() {
|
||||||
loadDesign();
|
loadDesign();
|
||||||
}, [user, router.query.id]);
|
}, [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) => {
|
const setField = useCallback((field, value) => {
|
||||||
setDesign((prev) => ({ ...prev, [field]: value }));
|
setDesign((prev) => ({ ...prev, [field]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -205,6 +277,114 @@ export default function Designer() {
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_460px] gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_460px] gap-6">
|
||||||
{/* ── Form column ─────────────────────────────── */}
|
{/* ── Form column ─────────────────────────────── */}
|
||||||
<div className="space-y-5 min-w-0">
|
<div className="space-y-5 min-w-0">
|
||||||
|
{/* Game targeting */}
|
||||||
|
<section className="glass-panel rounded-2xl p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Game System
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap rounded-xl overflow-hidden border" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
{[
|
||||||
|
{ 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 (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setDesign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
game_target: mode.id === 'existing' ? 'MTG' : 'custom',
|
||||||
|
custom_game_id:
|
||||||
|
mode.id === 'custom-game' ? games[0]?.id || null : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="px-4 py-2 text-xs font-semibold transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: active ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
|
||||||
|
color: active ? 'white' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{design.game_target !== 'custom' && (
|
||||||
|
<select
|
||||||
|
className="input-field w-full"
|
||||||
|
value={design.game_target}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDesign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
game_target: e.target.value,
|
||||||
|
custom_game_id: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{EXISTING_GAMES.map((game) => (
|
||||||
|
<option key={game.value} value={game.value}>
|
||||||
|
{game.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{design.game_target === 'custom' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{games.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="input-field w-full"
|
||||||
|
value={design.custom_game_id || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDesign((prev) => ({
|
||||||
|
...prev,
|
||||||
|
custom_game_id: e.target.value ? parseInt(e.target.value, 10) : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">— None (standalone) —</option>
|
||||||
|
{games.map((game) => (
|
||||||
|
<option key={game.id} value={game.id}>
|
||||||
|
{game.name} ({game.card_count})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input-field flex-1"
|
||||||
|
value={newGameName}
|
||||||
|
onChange={(e) => setNewGameName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateGame()}
|
||||||
|
placeholder="New game name…"
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCreateGame}
|
||||||
|
disabled={creatingGame || !newGameName.trim()}
|
||||||
|
>
|
||||||
|
{creatingGame ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Custom games group your designs — manage them in{' '}
|
||||||
|
<Link href="/games" style={{ color: 'var(--accent-ember)' }}>My Games</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Frame picker */}
|
{/* Frame picker */}
|
||||||
<section className="glass-panel rounded-2xl p-5">
|
<section className="glass-panel rounded-2xl p-5">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
|
|
||||||
212
pages/games/[id].js
Normal file
212
pages/games/[id].js
Normal file
|
|
@ -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 (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !game) {
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="p-6 max-w-2xl mx-auto text-center py-20">
|
||||||
|
<h1 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{error}</h1>
|
||||||
|
<Button variant="primary" onClick={() => router.push('/games')}>Back to My Games</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="pt-2 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} maxLength={100} />
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={saving || !name.trim()}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setEditing(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{game.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{game.description || 'Custom game system'} · {designs.length} card{designs.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => router.push('/games')}>My Games</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setEditing(true)}>Edit</Button>
|
||||||
|
<Button variant="secondary" onClick={handleDelete}>Delete</Button>
|
||||||
|
<Button variant="primary" onClick={() => router.push(`/designer?game=${game.id}`)}>
|
||||||
|
+ New Card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Designs grid */}
|
||||||
|
{designs.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No cards in this game yet
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Design the first card for {game.name}.
|
||||||
|
</p>
|
||||||
|
<Button variant="primary" size="lg" onClick={() => router.push(`/designer?game=${game.id}`)}>
|
||||||
|
Open Card Designer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-5">
|
||||||
|
{designs.map((design) => (
|
||||||
|
<div key={design.id} className="glass-panel rounded-2xl p-4 flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => router.push(`/designer?id=${design.id}`)}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && router.push(`/designer?id=${design.id}`)}
|
||||||
|
>
|
||||||
|
<CardPreview design={design} maxWidth={280} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{design.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
pages/games/index.js
Normal file
173
pages/games/index.js
Normal file
|
|
@ -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 (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--accent-ember)' }} />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="p-4 sm:p-6 max-w-[1500px] mx-auto space-y-6">
|
||||||
|
<div className="pt-2">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
My Games
|
||||||
|
</h1>
|
||||||
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Custom game systems you've created — private to you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="glass-panel rounded-xl px-4 py-3 text-sm" style={{ color: '#f87171' }} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create form */}
|
||||||
|
<form onSubmit={handleCreate} className="glass-panel rounded-2xl p-5">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
New Game
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Game name (e.g. Aetherfall)"
|
||||||
|
maxLength={100}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Short description (optional)"
|
||||||
|
maxLength={300}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" type="submit" disabled={creating || !name.trim()}>
|
||||||
|
{creating ? 'Creating…' : 'Create Game'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Games grid */}
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
No custom games yet
|
||||||
|
</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Create your first game above, then target designs toward it in the{' '}
|
||||||
|
<Link href="/designer" style={{ color: 'var(--accent-ember)' }}>Card Designer</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||||
|
{games.map((game) => (
|
||||||
|
<div
|
||||||
|
key={game.id}
|
||||||
|
className="glass-panel rounded-2xl p-5 cursor-pointer transition-all duration-200 hover:shadow-lg"
|
||||||
|
onClick={() => router.push(`/games/${game.id}`)}
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && router.push(`/games/${game.id}`)}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold mb-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{game.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{game.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--accent-ember)' }}>
|
||||||
|
{game.card_count} card{game.card_count === 1 ? '' : 's'} →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import CardPreview from '../components/designer/CardFrame';
|
import CardPreview from '../components/designer/CardFrame';
|
||||||
|
import { gameLabel } from '../lib/designer-games.js';
|
||||||
import { Button } from '../components/ui';
|
import { Button } from '../components/ui';
|
||||||
import { useAuth } from '../lib/use-auth';
|
import { useAuth } from '../lib/use-auth';
|
||||||
|
|
||||||
|
|
@ -83,7 +85,8 @@ export default function MyDesigns() {
|
||||||
My Designs
|
My Designs
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{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 ·{' '}
|
||||||
|
<Link href="/games" style={{ color: 'var(--accent-ember)' }}>My Games</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" onClick={() => router.push('/designer')}>
|
<Button variant="primary" onClick={() => router.push('/designer')}>
|
||||||
|
|
@ -129,14 +132,21 @@ export default function MyDesigns() {
|
||||||
>
|
>
|
||||||
<CardPreview design={design} maxWidth={280} />
|
<CardPreview design={design} maxWidth={280} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
<p className="font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
{design.name}
|
{design.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
|
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="text-xs truncate" style={{ color: 'var(--accent-ember)' }}>
|
||||||
|
{design.custom_game_name
|
||||||
|
? design.custom_game_name
|
||||||
|
: design.game_target && design.game_target !== 'custom'
|
||||||
|
? gameLabel(design.game_target)
|
||||||
|
: 'Standalone'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 mt-auto">
|
<div className="flex gap-2 mt-auto">
|
||||||
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
|
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
|
||||||
Edit
|
Edit
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,58 @@ describe('POST /api/custom-cards', () => {
|
||||||
expect(insertSql).toContain('flavor_quote');
|
expect(insertSql).toContain('flavor_quote');
|
||||||
expect(sql.mock.calls[0]).toContain('fullart');
|
expect(sql.mock.calls[0]).toContain('fullart');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('targets an existing game system without an extra lookup', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 12, card_id: null, name: 'Bolt Clone' }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 80 }] })
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 12, card_id: 80, name: 'Bolt Clone' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await handler(
|
||||||
|
{ method: 'POST', body: { name: 'Bolt Clone', gameTarget: 'MTG' } },
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
// No custom_games lookup needed for a known system code
|
||||||
|
expect(sql).toHaveBeenCalledTimes(4);
|
||||||
|
const twinInsert = sql.mock.calls[1][0].join('');
|
||||||
|
expect(twinInsert).toContain('INSERT INTO cards');
|
||||||
|
expect(sql.mock.calls[1]).toContain('MTG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves custom game names for the catalog twin', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [{ name: 'Aetherfall' }] }) // game lookup
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 13, card_id: null, name: 'Sky Rune' }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 81 }] })
|
||||||
|
.mockResolvedValueOnce({ rows: [] })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 13, card_id: 81, name: 'Sky Rune' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await handler(
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'Sky Rune', gameTarget: 'custom', customGameId: 9 },
|
||||||
|
},
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(sql).toHaveBeenCalledTimes(5);
|
||||||
|
// [0] game lookup, [1] design insert, [2] catalog twin insert
|
||||||
|
expect(sql.mock.calls[2]).toContain('Aetherfall');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/api/custom-cards/[id]', () => {
|
describe('/api/custom-cards/[id]', () => {
|
||||||
|
|
|
||||||
137
test/api/custom-games.test.js
Normal file
137
test/api/custom-games.test.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../lib/sql.js', () => ({ sql: vi.fn() }));
|
||||||
|
vi.mock('../../lib/permission-middleware', () => ({
|
||||||
|
getUserFromRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { sql } from '../../lib/sql.js';
|
||||||
|
import { getUserFromRequest } from '../../lib/permission-middleware';
|
||||||
|
import handler from '../../pages/api/custom-games/index.js';
|
||||||
|
import itemHandler from '../../pages/api/custom-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/custom-games', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||||
|
sql.mockResolvedValue({ rows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication', async () => {
|
||||||
|
getUserFromRequest.mockResolvedValue(null);
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler({ method: 'GET' }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(sql).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects games without a name', async () => {
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler({ method: 'POST', body: { name: ' ' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(sql).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate names for the same user', async () => {
|
||||||
|
sql.mockResolvedValueOnce({ rows: [{ id: 9 }] }); // duplicate lookup hits
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await handler({ method: 'POST', body: { name: 'Aetherfall' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a game scoped to the user', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [] }) // duplicate lookup misses
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
rows: [{ id: 9, name: 'Aetherfall', description: null }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await handler(
|
||||||
|
{ method: 'POST', body: { name: 'Aetherfall', description: 'Skyborn TCG' } },
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.body.game.name).toBe('Aetherfall');
|
||||||
|
expect(res.body.game.card_count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/api/custom-games/[id]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getUserFromRequest.mockResolvedValue({ userId: 1, email: 'a@b.c', role: 'user' });
|
||||||
|
sql.mockResolvedValue({ rows: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for another user\'s game', async () => {
|
||||||
|
sql.mockResolvedValueOnce({ rows: [] }); // ownership lookup misses
|
||||||
|
const res = createRes();
|
||||||
|
|
||||||
|
await itemHandler({ method: 'GET', query: { id: '9' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the game with its designs', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 9, name: 'Aetherfall' }] }) // ownership
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 3, name: 'Stormsage' }] }); // designs
|
||||||
|
|
||||||
|
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).toEqual([{ id: 3, name: 'Stormsage' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects renames that clash with another game', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 9, name: 'Old Name' }] }) // ownership
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 12 }] }); // clash lookup hits
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await itemHandler(
|
||||||
|
{ method: 'PUT', query: { id: '9' }, body: { name: 'Taken' } },
|
||||||
|
res
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the game row', async () => {
|
||||||
|
sql
|
||||||
|
.mockResolvedValueOnce({ rows: [{ id: 9, name: 'Aetherfall' }] }) // ownership
|
||||||
|
.mockResolvedValueOnce({ rows: [] }); // DELETE
|
||||||
|
|
||||||
|
const res = createRes();
|
||||||
|
await itemHandler({ method: 'DELETE', query: { id: '9' } }, res);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(sql).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue