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:
Randall Stillwell 2026-08-24 21:01:04 -05:00
parent 147041d292
commit c0051dd6d5
13 changed files with 1028 additions and 22 deletions

View file

@ -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
View 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;
}

View 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`);
};

View file

@ -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) {

View file

@ -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:

View 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' });
}
}

View 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' });
}
}

View file

@ -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
View 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
View 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&apos;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>
);
}

View file

@ -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')}>
@ -129,14 +132,21 @@ export default function MyDesigns() {
>
<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>
<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>
<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}`)}>
Edit

View file

@ -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]', () => {

View 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);
});
});