2026-08-24 15:53:06 -04:00
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
|
import Layout from '../components/Layout';
|
2026-08-24 22:13:12 -04:00
|
|
|
|
import CardFrame, { CardPreview } from '../components/designer/CardFrame';
|
|
|
|
|
|
import { FRAMES, RARITIES, getFrame } from '../components/designer/frames';
|
2026-08-24 22:01:04 -04:00
|
|
|
|
import { EXISTING_GAMES } from '../lib/designer-games.js';
|
2026-08-24 22:13:12 -04:00
|
|
|
|
import { PALETTE_SLOTS } from '../lib/frame-palette.js';
|
2026-08-24 15:53:06 -04:00
|
|
|
|
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: '',
|
2026-08-24 17:19:24 -04:00
|
|
|
|
flavor_quote: '',
|
2026-08-24 15:53:06 -04:00
|
|
|
|
power: '',
|
|
|
|
|
|
toughness: '',
|
|
|
|
|
|
frame_id: 'classic',
|
|
|
|
|
|
artwork_url: '',
|
2026-08-24 17:19:24 -04:00
|
|
|
|
art_mode: 'framed',
|
2026-08-24 22:01:04 -04:00
|
|
|
|
game_target: 'custom',
|
|
|
|
|
|
custom_game_id: null,
|
2026-08-24 22:13:12 -04:00
|
|
|
|
custom_frame_id: null,
|
2026-08-24 15:53:06 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
|
const BLANK_PALETTE = Object.fromEntries(
|
|
|
|
|
|
PALETTE_SLOTS.map(({ key }) => [key, getFrame('classic').palette[key]])
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-08-24 22:01:04 -04:00
|
|
|
|
// Custom games for targeting
|
|
|
|
|
|
const [games, setGames] = useState([]);
|
|
|
|
|
|
const [newGameName, setNewGameName] = useState('');
|
|
|
|
|
|
const [creatingGame, setCreatingGame] = useState(false);
|
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
|
const cardRef = useRef(null);
|
|
|
|
|
|
const fileInputRef = useRef(null);
|
|
|
|
|
|
|
2026-08-24 22:01:04 -04:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-08-24 22:01:04 -04:00
|
|
|
|
useEffect(() => {
|
2026-08-24 22:13:12 -04:00
|
|
|
|
if (user) {
|
|
|
|
|
|
loadGames(); // eslint-disable-line react-hooks/set-state-in-effect -- fetch-driven reload via async loadGames
|
|
|
|
|
|
loadFrames();
|
|
|
|
|
|
loadSymbols();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [user, loadGames, loadFrames, loadSymbols]);
|
2026-08-24 22:01:04 -04:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-24 22:13:12 -04:00
|
|
|
|
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]));
|
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-08-24 22:01:04 -04:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
|
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">
|
2026-08-24 22:01:04 -04:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
|
{/* Frame picker */}
|
|
|
|
|
|
<section className="glass-panel rounded-2xl p-5">
|
2026-08-24 22:13:12 -04:00
|
|
|
|
<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>
|
2026-08-24 15:53:06 -04:00
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
2026-08-24 22:13:12 -04:00
|
|
|
|
{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'
|
|
|
|
|
|
}`}
|
2026-08-24 15:53:06 -04:00
|
|
|
|
style={{
|
2026-08-24 22:13:12 -04:00
|
|
|
|
backgroundColor: frame.palette.outer,
|
|
|
|
|
|
borderColor: active ? frame.palette.border : 'transparent',
|
|
|
|
|
|
borderWidth: 2,
|
2026-08-24 15:53:06 -04:00
|
|
|
|
}}
|
2026-08-24 22:13:12 -04:00
|
|
|
|
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 && (
|
|
|
|
|
|
<Button variant="secondary" size="sm" onClick={() => handleDeleteFrame(frameEditor.id)}>
|
|
|
|
|
|
Delete Frame
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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 },
|
|
|
|
|
|
}}
|
|
|
|
|
|
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>
|
2026-08-24 15:53:06 -04:00
|
|
|
|
</span>
|
2026-08-24 22:13:12 -04:00
|
|
|
|
))}
|
|
|
|
|
|
</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>
|
2026-08-24 15:53:06 -04:00
|
|
|
|
</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">
|
2026-08-24 17:19:24 -04:00
|
|
|
|
<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>
|
2026-08-24 15:53:06 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-08-24 17:19:24 -04:00
|
|
|
|
<Field label="Description">
|
2026-08-24 15:53:06 -04:00
|
|
|
|
<textarea
|
|
|
|
|
|
className="input-field w-full resize-y"
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
value={design.rules_text}
|
|
|
|
|
|
onChange={(e) => setField('rules_text', e.target.value)}
|
2026-08-24 17:19:24 -04:00
|
|
|
|
placeholder="What the card does, or what it describes…"
|
2026-08-24 15:53:06 -04:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-08-24 17:19:24 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-08-24 15:53:06 -04:00
|
|
|
|
<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>
|
2026-08-24 22:13:12 -04:00
|
|
|
|
<CardPreview design={design} innerRef={cardRef} symbols={symbolsMap} />
|
2026-08-24 15:53:06 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|