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 = {}) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
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 { 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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
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 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() {
|
|||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_460px] gap-6">
|
||||
{/* ── Form column ─────────────────────────────── */}
|
||||
<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 */}
|
||||
<section className="glass-panel rounded-2xl p-5">
|
||||
<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 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
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => router.push('/designer')}>
|
||||
|
|
@ -136,6 +139,13 @@ export default function MyDesigns() {
|
|||
<p className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
{design.card_type || 'No type'} {design.mana_cost ? `· ${design.mana_cost}` : ''}
|
||||
</p>
|
||||
<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">
|
||||
<Button variant="secondary" size="sm" onClick={() => router.push(`/designer?id=${design.id}`)}>
|
||||
|
|
|
|||
|
|
@ -138,6 +138,58 @@ describe('POST /api/custom-cards', () => {
|
|||
expect(insertSql).toContain('flavor_quote');
|
||||
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]', () => {
|
||||
|
|
|
|||
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