- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
275 lines
9.7 KiB
JavaScript
275 lines
9.7 KiB
JavaScript
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 { 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 [symbolsMap, setSymbolsMap] = 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 [isPublic, setIsPublic] = useState(false);
|
|
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 || '');
|
|
setIsPublic(Boolean(data.game.is_public));
|
|
} else {
|
|
setError(data.error || 'Game not found.');
|
|
}
|
|
} catch {
|
|
setError('Failed to load the game.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadGame();
|
|
return undefined;
|
|
}, [user, id]);
|
|
|
|
// Load the owner's symbols so icon pips render in previews.
|
|
useEffect(() => {
|
|
if (!user) return undefined;
|
|
|
|
const loadSymbols = async () => {
|
|
try {
|
|
const token = localStorage.getItem('auth_token');
|
|
const response = await fetch('/api/custom-symbols', {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setSymbolsMap(Object.fromEntries(data.symbols.map((s) => [s.code, s.image_url])));
|
|
}
|
|
} catch {
|
|
// Non-fatal — text pips still work.
|
|
}
|
|
};
|
|
|
|
loadSymbols();
|
|
return undefined;
|
|
}, [user]);
|
|
|
|
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, is_public: isPublic }),
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
setGame((prev) => ({
|
|
...prev,
|
|
name: data.game.name,
|
|
description: data.game.description,
|
|
is_public: data.game.is_public,
|
|
}));
|
|
setEditing(false);
|
|
} else {
|
|
setError(data.error || 'Save failed.');
|
|
}
|
|
} catch {
|
|
setError('Save failed.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const toggleShare = async () => {
|
|
const next = !isPublic;
|
|
setIsPublic(next);
|
|
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: game.name, description: game.description, is_public: next }),
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
setGame((prev) => ({ ...prev, is_public: data.game.is_public }));
|
|
} else {
|
|
setIsPublic(!next);
|
|
setError(data.error || 'Could not update sharing.');
|
|
}
|
|
} catch {
|
|
setIsPublic(!next);
|
|
setError('Could not update sharing.');
|
|
}
|
|
};
|
|
|
|
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'}
|
|
{isPublic && (
|
|
<>
|
|
{' '}· <Link href={`/community/games/${game.id}`} style={{ color: 'var(--accent-ember)' }}>public view</Link>
|
|
</>
|
|
)}
|
|
</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={toggleShare}>
|
|
{isPublic ? 'Shared ✓' : 'Share'}
|
|
</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} symbols={symbolsMap} />
|
|
</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>
|
|
);
|
|
}
|