import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import CardFrame, { CardPreview } from '../components/designer/CardFrame'; import { FRAMES, RARITIES, getFrame } from '../components/designer/frames'; import { EXISTING_GAMES } from '../lib/designer-games.js'; import { PALETTE_SLOTS } from '../lib/frame-palette.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, custom_frame_id: null, }; const BLANK_PALETTE = Object.fromEntries( PALETTE_SLOTS.map(({ key }) => [key, getFrame('classic').palette[key]]) ); 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); // Custom frames + symbols const [frames, setFrames] = useState([]); const [symbols, setSymbols] = useState([]); const [frameEditor, setFrameEditor] = useState(null); // { id, name, palette } | null const [savingFrame, setSavingFrame] = useState(false); const [symbolCode, setSymbolCode] = useState(''); const symbolFileRef = useRef(null); const [uploadingSymbol, setUploadingSymbol] = 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]); const loadFrames = useCallback(async () => { try { const response = await fetch('/api/custom-frames', { headers: authHeaders() }); if (response.ok) { const data = await response.json(); setFrames(data.frames); } } catch { // Non-fatal — starter frames still work. } }, [authHeaders]); const loadSymbols = useCallback(async () => { try { const response = await fetch('/api/custom-symbols', { headers: authHeaders() }); if (response.ok) { const data = await response.json(); setSymbols(data.symbols); } } catch { // Non-fatal — text pips still work. } }, [authHeaders]); useEffect(() => { if (user) { loadGames(); // eslint-disable-line react-hooks/set-state-in-effect -- fetch-driven reload via async loadGames loadFrames(); loadSymbols(); } }, [user, loadGames, loadFrames, loadSymbols]); 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); } }; const openNewFrameEditor = () => { const base = getFrame(design.frame_id).palette; setFrameEditor({ id: null, name: '', palette: { ...base } }); }; const openEditFrameEditor = (frame) => { setFrameEditor({ id: frame.id, name: frame.name, palette: { ...frame.palette } }); }; const handleSaveFrame = async () => { if (!frameEditor) return; const name = frameEditor.name.trim(); if (!name) { setMessage({ kind: 'error', text: 'Give the frame a name first.' }); return; } setSavingFrame(true); try { const isNew = !frameEditor.id; const response = await fetch( isNew ? '/api/custom-frames' : `/api/custom-frames/${frameEditor.id}`, { method: isNew ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ name, palette: frameEditor.palette }), } ); const data = await response.json(); if (response.ok) { setFrames((prev) => isNew ? [...prev, data.frame].sort((a, b) => a.name.localeCompare(b.name)) : prev.map((f) => (f.id === data.frame.id ? data.frame : f)) ); setDesign((prev) => ({ ...prev, custom_frame_id: data.frame.id })); setFrameEditor(null); setMessage({ kind: 'success', text: `Frame "${data.frame.name}" saved.` }); } else { setMessage({ kind: 'error', text: data.error || 'Could not save frame.' }); } } catch { setMessage({ kind: 'error', text: 'Could not save frame.' }); } finally { setSavingFrame(false); } }; const handleDeleteFrame = async (frameId) => { if (!window.confirm('Delete this frame? Cards using it fall back to their starter frame.')) return; try { const response = await fetch(`/api/custom-frames/${frameId}`, { method: 'DELETE', headers: authHeaders(), }); if (response.ok) { setFrames((prev) => prev.filter((f) => f.id !== frameId)); setDesign((prev) => prev.custom_frame_id === frameId ? { ...prev, custom_frame_id: null } : prev ); if (frameEditor?.id === frameId) setFrameEditor(null); } else { setMessage({ kind: 'error', text: 'Could not delete frame.' }); } } catch { setMessage({ kind: 'error', text: 'Could not delete frame.' }); } }; const handleSymbolUpload = async (file) => { const code = symbolCode.trim(); if (!file || !code) return; setUploadingSymbol(true); try { const body = new FormData(); body.append('code', code); body.append('image', file); const response = await fetch('/api/custom-symbols', { method: 'POST', headers: authHeaders(), body, }); const data = await response.json(); if (response.ok) { setSymbols((prev) => { const next = prev.filter((s) => s.code !== data.symbol.code); return [...next, data.symbol].sort((a, b) => a.code.localeCompare(b.code)); }); setSymbolCode(''); setMessage({ kind: 'success', text: `Symbol {${data.symbol.code}} saved.` }); } else { setMessage({ kind: 'error', text: data.error || 'Symbol upload failed.' }); } } catch { setMessage({ kind: 'error', text: 'Symbol upload failed.' }); } finally { setUploadingSymbol(false); if (symbolFileRef.current) symbolFileRef.current.value = ''; } }; const handleDeleteSymbol = async (symbolId) => { try { const response = await fetch(`/api/custom-symbols/${symbolId}`, { method: 'DELETE', headers: authHeaders(), }); if (response.ok) { setSymbols((prev) => prev.filter((s) => s.id !== symbolId)); } else { setMessage({ kind: 'error', text: 'Could not delete symbol.' }); } } catch { setMessage({ kind: 'error', text: 'Could not delete symbol.' }); } }; const symbolsMap = Object.fromEntries(symbols.map((s) => [s.code, s.image_url])); // 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 (
); } return (
{/* Header */}

Card Designer

Fill in the details and watch your card come to life.

{message && (
{message.text}
)}
{/* ── Form column ─────────────────────────────── */}
{/* Game targeting */}

Game System

{[ { 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 ( ); })}
{design.game_target !== 'custom' && ( )} {design.game_target === 'custom' && (
{games.length > 0 && ( )}
setNewGameName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreateGame()} placeholder="New game name…" maxLength={100} />

Custom games group your designs — manage them in{' '} My Games.

)}
{/* Frame picker */}

Frame

{FRAMES.map((frame) => { const active = !design.custom_frame_id && design.frame_id === frame.id; return ( ); })} {frames.map((frame) => { const p = frame.palette; const active = design.custom_frame_id === frame.id; return (
); })}
{/* Frame editor */} {frameEditor && (
setFrameEditor((prev) => ({ ...prev, name: e.target.value })) } placeholder="Frame name…" maxLength={100} />
{PALETTE_SLOTS.map(({ key, label }) => ( ))}
{frameEditor.id && ( )}

Live frame preview

)}
{/* Cost symbols */}

Cost Symbols

Upload small icons and use them in the Cost field as {'{CODE}'} or plain letters — e.g.{' '} {'2{F}{F}'}.

{symbols.length > 0 && (
{symbols.map((symbol) => ( {/* eslint-disable-next-line @next/next/no-img-element -- CDN icon */} {symbol.code} {symbol.code} ))}
)} handleSymbolUpload(e.target.files?.[0])} />
setSymbolCode(e.target.value)} placeholder="Code (F)" maxLength={10} /> PNG, WebP, or SVG · up to 2MB · re-uploading a code replaces it
{/* Artwork */}

Artwork

handleUpload(e.target.files?.[0])} />
{[ { id: 'framed', label: 'Framed' }, { id: 'fullart', label: 'Full Art' }, ].map((mode) => ( ))}
{design.artwork_url && ( )} JPEG, PNG, or WebP · up to 5MB
{/* Details */}

Details

setField('name', e.target.value)} placeholder="Emberwing Phoenix" maxLength={60} /> setField('mana_cost', e.target.value)} placeholder="2RR" maxLength={24} />
setField('card_type', e.target.value)} placeholder="Creature — Phoenix" maxLength={80} />
{RARITIES.map((rarity) => ( ))}