deckhearth/pages/designer.js
Randall Stillwell 5b9a278ca2 feat(designer): inline symbol icons, frame textures, community sharing, print sheets
- {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
2026-08-24 21:48:52 -05:00

1071 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 [uploadingTexture, setUploadingTexture] = useState(false);
const textureFileRef = useRef(null);
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 },
texture_url: frame.texture_url || null,
});
};
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 handleUploadTexture = async (file) => {
if (!file || !frameEditor?.id) return;
setUploadingTexture(true);
try {
const body = new FormData();
body.append('texture', file);
const response = await fetch(`/api/custom-frames/${frameEditor.id}/texture`, {
method: 'POST',
headers: authHeaders(),
body,
});
const data = await response.json();
if (response.ok && data.texture_url) {
setFrames((prev) =>
prev.map((f) =>
f.id === frameEditor.id ? { ...f, texture_url: data.texture_url } : f
)
);
setFrameEditor((prev) => ({ ...prev, texture_url: data.texture_url }));
setMessage({ kind: 'success', text: 'Texture uploaded.' });
} else {
setMessage({ kind: 'error', text: data.error || 'Texture upload failed.' });
}
} catch {
setMessage({ kind: 'error', text: 'Texture upload failed.' });
} finally {
setUploadingTexture(false);
if (textureFileRef.current) textureFileRef.current.value = '';
}
};
const handleRemoveTexture = async () => {
if (!frameEditor?.id) return;
try {
const response = await fetch(`/api/custom-frames/${frameEditor.id}/texture`, {
method: 'DELETE',
headers: authHeaders(),
});
if (response.ok) {
setFrames((prev) =>
prev.map((f) =>
f.id === frameEditor.id ? { ...f, texture_url: null } : f
)
);
setFrameEditor((prev) => ({ ...prev, texture_url: null }));
} else {
setMessage({ kind: 'error', text: 'Could not remove texture.' });
}
} catch {
setMessage({ kind: 'error', text: 'Could not remove texture.' });
}
};
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 (
<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">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
Frame
</h2>
<Button variant="secondary" size="sm" onClick={openNewFrameEditor}>
+ New Frame
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{FRAMES.map((frame) => {
const active = !design.custom_frame_id && design.frame_id === frame.id;
return (
<button
key={frame.id}
type="button"
onClick={() =>
setDesign((prev) => ({
...prev,
frame_id: frame.id,
custom_frame_id: null,
}))
}
className={`rounded-xl p-3 text-left transition-all duration-200 border ${
active ? 'shadow-lg scale-[1.02]' : 'opacity-80 hover:opacity-100'
}`}
style={{
backgroundColor: frame.palette.outer,
borderColor: active ? frame.palette.border : 'transparent',
borderWidth: 2,
}}
aria-pressed={active}
>
<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>
);
})}
{frames.map((frame) => {
const p = frame.palette;
const active = design.custom_frame_id === frame.id;
return (
<div key={frame.id} className="relative">
<button
type="button"
onClick={() => setDesign((prev) => ({ ...prev, custom_frame_id: frame.id }))}
onDoubleClick={() => openEditFrameEditor(frame)}
className={`w-full rounded-xl p-3 text-left transition-all duration-200 border ${
active ? 'shadow-lg scale-[1.02]' : 'opacity-80 hover:opacity-100'
}`}
style={{
backgroundColor: p.outer,
borderColor: active ? p.border : 'transparent',
borderWidth: 2,
}}
aria-pressed={active}
title={`${frame.name} — double-click to edit`}
>
<div
className="w-full h-8 rounded mb-2"
style={{
background: `linear-gradient(135deg, ${p.titleBar}, ${p.textBox})`,
border: `1px solid ${p.border}`,
}}
/>
<span className="text-xs font-semibold block truncate" style={{ color: p.titleText }}>
{frame.name}
</span>
</button>
<button
type="button"
onClick={() => openEditFrameEditor(frame)}
className="absolute top-1 right-1 text-[10px] px-1.5 py-0.5 rounded"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: p.titleText }}
aria-label={`Edit ${frame.name}`}
>
Edit
</button>
</div>
);
})}
</div>
{/* Frame editor */}
{frameEditor && (
<div className="mt-4 rounded-xl p-4 border" style={{ borderColor: 'var(--border)' }}>
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 space-y-3">
<div className="flex gap-2">
<input
className="input-field flex-1"
value={frameEditor.name}
onChange={(e) =>
setFrameEditor((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Frame name…"
maxLength={100}
/>
<Button variant="primary" onClick={handleSaveFrame} disabled={savingFrame}>
{savingFrame ? 'Saving…' : frameEditor.id ? 'Update' : 'Save'}
</Button>
<Button variant="secondary" onClick={() => setFrameEditor(null)}>
Cancel
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{PALETTE_SLOTS.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-secondary)' }}>
<input
type="color"
value={frameEditor.palette[key]}
onChange={(e) =>
setFrameEditor((prev) => ({
...prev,
palette: { ...prev.palette, [key]: e.target.value },
}))
}
className="w-7 h-7 rounded cursor-pointer bg-transparent"
aria-label={label}
/>
{label}
</label>
))}
</div>
{frameEditor.id && (
<div className="flex flex-wrap items-center gap-2">
<input
ref={textureFileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => handleUploadTexture(e.target.files?.[0])}
/>
<Button
variant="secondary"
size="sm"
onClick={() => textureFileRef.current?.click()}
disabled={uploadingTexture}
>
{uploadingTexture
? 'Uploading…'
: frameEditor.texture_url
? 'Replace Texture'
: 'Upload Texture'}
</Button>
{frameEditor.texture_url && (
<Button variant="secondary" size="sm" onClick={handleRemoveTexture}>
Remove Texture
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
Delete Frame
</Button>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Textures show behind the frame panels.
</span>
</div>
)}
{!frameEditor.id && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Save the frame first to add a background texture.
</p>
)}
</div>
<div className="w-full max-w-[240px] shrink-0">
<p className="text-xs mb-2 text-center" style={{ color: 'var(--text-secondary)' }}>
Live frame preview
</p>
<CardFrame
design={{
...design,
custom_frame: {
palette: frameEditor.palette,
texture_url: frameEditor.texture_url,
},
}}
symbols={symbolsMap}
/>
</div>
</div>
</div>
)}
</section>
{/* Cost symbols */}
<section className="glass-panel rounded-2xl p-5">
<h2 className="text-sm font-semibold uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
Cost Symbols
</h2>
<p className="text-xs mb-3" style={{ color: 'var(--text-secondary)' }}>
Upload small icons and use them in the Cost field as <code>{'{CODE}'}</code> or plain letters e.g.{' '}
<code>{'2{F}{F}'}</code>.
</p>
{symbols.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{symbols.map((symbol) => (
<span
key={symbol.id}
className="inline-flex items-center gap-2 rounded-full pl-1 pr-2 py-1 border text-xs font-semibold"
style={{ borderColor: 'var(--border)', color: 'var(--text-primary)' }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- CDN icon */}
<img
src={symbol.image_url}
alt={symbol.code}
className="w-6 h-6 rounded-full object-cover"
/>
{symbol.code}
<button
type="button"
onClick={() => handleDeleteSymbol(symbol.id)}
className="ml-1 opacity-60 hover:opacity-100"
aria-label={`Delete symbol ${symbol.code}`}
>
×
</button>
</span>
))}
</div>
)}
<input
ref={symbolFileRef}
type="file"
accept="image/png,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => handleSymbolUpload(e.target.files?.[0])}
/>
<div className="flex flex-wrap gap-2 items-center">
<input
className="input-field w-28"
value={symbolCode}
onChange={(e) => setSymbolCode(e.target.value)}
placeholder="Code (F)"
maxLength={10}
/>
<Button
variant="primary"
onClick={() => symbolFileRef.current?.click()}
disabled={uploadingSymbol || !symbolCode.trim()}
>
{uploadingSymbol ? 'Uploading…' : 'Upload Icon'}
</Button>
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
PNG, WebP, or SVG · up to 2MB · re-uploading a code replaces it
</span>
</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} symbols={symbolsMap} />
<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>
);
}