deckhearth/pages/designer.js

630 lines
24 KiB
JavaScript
Raw Normal View History

import { useCallback, useEffect, useRef, 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 { 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';
const BLANK_DESIGN = {
id: null,
name: '',
mana_cost: '',
card_type: '',
rarity: 'common',
rules_text: '',
actions: '',
flavor_quote: '',
power: '',
toughness: '',
frame_id: 'classic',
artwork_url: '',
art_mode: 'framed',
game_target: 'custom',
custom_game_id: null,
};
export default function Designer() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [design, setDesign] = useState(BLANK_DESIGN);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
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;
const loadDesign = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/custom-cards/${router.query.id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (response.ok) {
const data = await response.json();
setDesign({ ...BLANK_DESIGN, ...data.design });
} else {
setMessage({ kind: 'error', text: 'Design not found.' });
}
} catch {
setMessage({ kind: 'error', text: 'Failed to load design.' });
}
};
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 }));
}, []);
const handleUpload = async (file) => {
if (!file) return;
setUploading(true);
setMessage(null);
try {
const body = new FormData();
body.append('artwork', file);
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/custom-cards/upload-artwork', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body,
});
const data = await response.json();
if (response.ok && data.artwork_url) {
setField('artwork_url', data.artwork_url);
} else {
setMessage({ kind: 'error', text: data.error || 'Upload failed.' });
}
} catch {
setMessage({ kind: 'error', text: 'Upload failed. Please try again.' });
} finally {
setUploading(false);
}
};
const handleSave = async () => {
if (!design.name.trim()) {
setMessage({ kind: 'error', text: 'Give your card a title first.' });
return;
}
setSaving(true);
setMessage(null);
try {
const token = localStorage.getItem('auth_token');
const isNew = !design.id;
const response = await fetch(
isNew ? '/api/custom-cards' : `/api/custom-cards/${design.id}`,
{
method: isNew ? 'POST' : 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(design),
}
);
const data = await response.json();
if (response.ok) {
setDesign({ ...BLANK_DESIGN, ...data.design });
setMessage({
kind: 'success',
text: isNew ? 'Card saved to your designs!' : 'Changes saved.',
});
} else {
setMessage({ kind: 'error', text: data.error || 'Save failed.' });
}
} catch {
setMessage({ kind: 'error', text: 'Save failed. Please try again.' });
} finally {
setSaving(false);
}
};
const handleExportPng = async () => {
if (!cardRef.current) return;
setExporting(true);
try {
const { toPng } = await import('html-to-image');
const dataUrl = await toPng(cardRef.current, {
width: 420,
height: 588,
pixelRatio: 2,
cacheBust: true,
});
const link = document.createElement('a');
link.download = `${(design.name || 'card').replace(/[^a-z0-9-_ ]/gi, '').trim() || 'card'}.png`;
link.href = dataUrl;
link.click();
} catch {
setMessage({ kind: 'error', text: 'Export failed. Please try again.' });
} finally {
setExporting(false);
}
};
const handleNew = () => {
setDesign(BLANK_DESIGN);
setMessage(null);
router.replace('/designer', undefined, { shallow: true });
};
if (authLoading) {
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">
{/* Header */}
<div className="pt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
Card Designer
</h1>
<p className="text-base" style={{ color: 'var(--text-secondary)' }}>
Fill in the details and watch your card come to life.
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleNew}>New</Button>
<Button variant="secondary" onClick={handleExportPng} disabled={exporting}>
{exporting ? 'Exporting…' : 'Download PNG'}
</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : design.id ? 'Save Changes' : 'Save Card'}
</Button>
</div>
</div>
{message && (
<div
className="glass-panel rounded-xl px-4 py-3 text-sm"
style={{ color: message.kind === 'error' ? '#f87171' : '#4ade80' }}
role="status"
>
{message.text}
</div>
)}
<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)' }}>
Frame
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{FRAMES.map((frame) => (
<button
key={frame.id}
type="button"
onClick={() => setField('frame_id', frame.id)}
className={`rounded-xl p-3 text-left transition-all duration-200 border ${
design.frame_id === frame.id
? 'shadow-lg scale-[1.02]'
: 'opacity-80 hover:opacity-100'
}`}
style={{
backgroundColor: frame.palette.outer,
borderColor: design.frame_id === frame.id ? frame.palette.border : 'transparent',
borderWidth: 2,
}}
aria-pressed={design.frame_id === frame.id}
>
<div
className="w-full h-8 rounded mb-2"
style={{
background: `linear-gradient(135deg, ${frame.palette.titleBar}, ${frame.palette.textBox})`,
border: `1px solid ${frame.palette.border}`,
}}
/>
<span className="text-xs font-semibold block" style={{ color: frame.palette.titleText }}>
{frame.name}
</span>
</button>
))}
</div>
</section>
{/* Artwork */}
<section className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3" style={{ color: 'var(--text-secondary)' }}>
Artwork
</h2>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => handleUpload(e.target.files?.[0])}
/>
<div className="flex flex-wrap items-center gap-3">
<div className="flex rounded-xl overflow-hidden border" style={{ borderColor: 'var(--border)' }}>
{[
{ id: 'framed', label: 'Framed' },
{ id: 'fullart', label: 'Full Art' },
].map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => setField('art_mode', mode.id)}
className="px-4 py-2 text-xs font-semibold transition-all duration-200"
style={{
backgroundColor:
design.art_mode === mode.id ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: design.art_mode === mode.id ? 'white' : 'var(--text-secondary)',
}}
aria-pressed={design.art_mode === mode.id}
>
{mode.label}
</button>
))}
</div>
<Button variant="primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? 'Uploading…' : design.artwork_url ? 'Replace Image' : 'Upload Image'}
</Button>
{design.artwork_url && (
<Button variant="secondary" onClick={() => setField('artwork_url', '')}>
Remove
</Button>
)}
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
JPEG, PNG, or WebP · up to 5MB
</span>
</div>
</section>
{/* Details */}
<section className="glass-panel rounded-2xl p-5 space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
Details
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Title" required>
<input
className="input-field w-full"
value={design.name}
onChange={(e) => setField('name', e.target.value)}
placeholder="Emberwing Phoenix"
maxLength={60}
/>
</Field>
<Field label="Cost" hint='e.g. "2RR" or "{2}{R}{R}"'>
<input
className="input-field w-full"
value={design.mana_cost}
onChange={(e) => setField('mana_cost', e.target.value)}
placeholder="2RR"
maxLength={24}
/>
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Type">
<input
className="input-field w-full"
value={design.card_type}
onChange={(e) => setField('card_type', e.target.value)}
placeholder="Creature — Phoenix"
maxLength={80}
/>
</Field>
<Field label="Rarity">
<div className="flex gap-2">
{RARITIES.map((rarity) => (
<button
key={rarity.id}
type="button"
onClick={() => setField('rarity', rarity.id)}
className={`flex-1 rounded-lg px-2 py-2 text-xs font-semibold transition-all duration-200 ${
design.rarity === rarity.id ? 'scale-105 shadow-md' : 'opacity-70 hover:opacity-100'
}`}
style={{
backgroundColor: 'var(--bg-tertiary)',
color: design.rarity === rarity.id ? rarity.color : 'var(--text-secondary)',
borderBottom: `3px solid ${rarity.color}`,
}}
aria-pressed={design.rarity === rarity.id}
>
{rarity.name}
</button>
))}
</div>
</Field>
</div>
<Field label="Description">
<textarea
className="input-field w-full resize-y"
rows={3}
value={design.rules_text}
onChange={(e) => setField('rules_text', e.target.value)}
placeholder="What the card does, or what it describes…"
maxLength={500}
/>
</Field>
<Field label="Actions / Abilities">
<textarea
className="input-field w-full resize-y"
rows={3}
value={design.actions}
onChange={(e) => setField('actions', e.target.value)}
placeholder={'Flying, haste\nWhen Emberwing enters the battlefield, it deals 2 damage to any target.'}
maxLength={800}
/>
</Field>
<Field label="Flavor Quote" hint="shown in italics with decorative dividers">
<textarea
className="input-field w-full resize-y"
rows={2}
value={design.flavor_quote}
onChange={(e) => setField('flavor_quote', e.target.value)}
placeholder="From the ashes, memory takes wing."
maxLength={300}
/>
</Field>
<details className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<summary className="cursor-pointer select-none">Power / Toughness (optional)</summary>
<div className="grid grid-cols-2 gap-4 mt-3">
<Field label="Power">
<input
className="input-field w-full"
value={design.power}
onChange={(e) => setField('power', e.target.value)}
placeholder="3"
maxLength={10}
/>
</Field>
<Field label="Toughness">
<input
className="input-field w-full"
value={design.toughness}
onChange={(e) => setField('toughness', e.target.value)}
placeholder="4"
maxLength={10}
/>
</Field>
</div>
</details>
</section>
</div>
{/* ── Live preview column ─────────────────────── */}
<div>
<div className="sticky top-8 glass-panel rounded-2xl p-6">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-4" style={{ color: 'var(--text-secondary)' }}>
Live Preview
</h2>
<CardPreview design={design} innerRef={cardRef} />
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-secondary)' }}>
Saved designs appear in{' '}
<Link href="/my-designs" style={{ color: 'var(--accent-ember)' }}>My Designs</Link>{' '}
and your collection.
</p>
</div>
</div>
</div>
</div>
</Layout>
);
}
function Field({ label, hint, required, children }) {
return (
<label className="block">
<span className="text-xs font-semibold mb-1.5 block" style={{ color: 'var(--text-primary)' }}>
{label}
{required && <span style={{ color: 'var(--accent-ember)' }}> *</span>}
{hint && (
<span className="font-normal ml-2" style={{ color: 'var(--text-secondary)' }}>
({hint})
</span>
)}
</span>
{children}
</label>
);
}