449 lines
18 KiB
HTML
449 lines
18 KiB
HTML
import { useState, useRef, useCallback } from "react";
|
||
|
||
const CARD_TYPES = ["Creature", "Instant", "Sorcery", "Enchantment", "Artifact", "Land", "Planeswalker", "Battle"];
|
||
const COLORS = [
|
||
{ id: "W", label: "White", symbol: "☀", bg: "#f9f6d5", border: "#c8b87a", text: "#333" },
|
||
{ id: "U", label: "Blue", symbol: "💧", bg: "#c6d9e8", border: "#3a6b9c", text: "#fff" },
|
||
{ id: "B", label: "Black", symbol: "💀", bg: "#1a1a1a", border: "#5a4a6e", text: "#d0c8e0" },
|
||
{ id: "R", label: "Red", symbol: "🔥", bg: "#e87040", border: "#8b2500", text: "#fff" },
|
||
{ id: "G", label: "Green", symbol: "🌲", bg: "#3a7a48", border: "#1a4025", text: "#d4f5d0" },
|
||
{ id: "C", label: "Colorless", symbol: "◇", bg: "#c0bdb5", border: "#7a7068", text: "#333" },
|
||
{ id: "M", label: "Multi", symbol: "★", bg: "linear-gradient(135deg,#f9f6d5,#c6d9e8,#1a1a1a,#e87040,#3a7a48)", border: "#b8960c", text: "#fff" },
|
||
];
|
||
|
||
const RARITY_COLORS = { Common: "#aaa", Uncommon: "#a0c0d0", Rare: "#d4af37", "Mythic Rare": "#e07020" };
|
||
|
||
const DEFAULT_CARD = {
|
||
name: "Blazing Champion",
|
||
manaCost: "{2}{R}{R}",
|
||
type: "Creature",
|
||
subtype: "Human Warrior",
|
||
color: "R",
|
||
rarity: "Rare",
|
||
power: "4",
|
||
toughness: "3",
|
||
rulesText: "Haste\nWhen Blazing Champion enters the battlefield, it deals 2 damage to any target.",
|
||
flavorText: "\"No retreat. No mercy. Only fire.\"",
|
||
artist: "Proxy Artist",
|
||
setSymbol: "★",
|
||
loyalty: "",
|
||
};
|
||
|
||
function CardPreview({ card, imageUrl }) {
|
||
const colorObj = COLORS.find(c => c.id === card.color) || COLORS[5];
|
||
const isMulti = card.color === "M";
|
||
const isLand = card.type === "Land";
|
||
const isPW = card.type === "Planeswalker";
|
||
|
||
const frameBg = isMulti
|
||
? "linear-gradient(160deg,#f9f6d5 0%,#c6d9e8 25%,#1a1a1a 50%,#e87040 75%,#3a7a48 100%)"
|
||
: colorObj.bg;
|
||
|
||
const rarityColor = RARITY_COLORS[card.rarity] || "#aaa";
|
||
|
||
return (
|
||
<div style={{
|
||
width: 280,
|
||
minHeight: 390,
|
||
borderRadius: 16,
|
||
border: `4px solid ${colorObj.border}`,
|
||
background: frameBg,
|
||
boxShadow: `0 0 0 2px #111, 0 0 30px ${colorObj.border}55, 0 8px 32px #0008`,
|
||
fontFamily: "'Palatino Linotype', Palatino, 'Book Antiqua', serif",
|
||
color: colorObj.text,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
overflow: "hidden",
|
||
position: "relative",
|
||
userSelect: "none",
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
padding: "7px 10px 4px",
|
||
background: `${colorObj.border}cc`,
|
||
borderBottom: `2px solid ${colorObj.border}`,
|
||
}}>
|
||
<div style={{ fontWeight: 700, fontSize: 15, letterSpacing: 0.3, textShadow: "0 1px 2px #0005", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||
{card.name || "Card Name"}
|
||
</div>
|
||
<div style={{ fontSize: 13, fontFamily: "monospace", marginLeft: 8, whiteSpace: "nowrap", opacity: 0.95, letterSpacing: 1 }}>
|
||
{card.manaCost || ""}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Art Frame */}
|
||
<div style={{
|
||
margin: "6px 8px",
|
||
height: 140,
|
||
borderRadius: 8,
|
||
border: `2px solid ${colorObj.border}`,
|
||
overflow: "hidden",
|
||
background: imageUrl ? "transparent" : `linear-gradient(135deg, ${colorObj.border}44, ${colorObj.border}99)`,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
position: "relative",
|
||
}}>
|
||
{imageUrl ? (
|
||
<img src={imageUrl} alt="card art" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||
) : (
|
||
<div style={{ opacity: 0.4, fontSize: 48, textAlign: "center" }}>
|
||
{colorObj.symbol}
|
||
</div>
|
||
)}
|
||
{/* Set & rarity */}
|
||
<div style={{
|
||
position: "absolute", bottom: 4, right: 6,
|
||
fontSize: 11, color: rarityColor, textShadow: "0 1px 3px #000a",
|
||
fontWeight: 700,
|
||
}}>
|
||
{card.setSymbol || "★"} {card.rarity ? card.rarity[0] : ""}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Type Line */}
|
||
<div style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
padding: "3px 10px",
|
||
background: `${colorObj.border}99`,
|
||
fontSize: 12,
|
||
fontStyle: "italic",
|
||
borderTop: `1px solid ${colorObj.border}66`,
|
||
borderBottom: `1px solid ${colorObj.border}66`,
|
||
}}>
|
||
<span>{[card.type, card.subtype].filter(Boolean).join(" — ") || "Type"}</span>
|
||
<span style={{ fontSize: 14 }}>{card.setSymbol || "★"}</span>
|
||
</div>
|
||
|
||
{/* Text Box */}
|
||
<div style={{
|
||
flex: 1,
|
||
margin: "5px 8px 5px",
|
||
background: "#f5f0e044",
|
||
borderRadius: 6,
|
||
border: `1px solid ${colorObj.border}55`,
|
||
padding: "7px 9px",
|
||
minHeight: 80,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
}}>
|
||
<div style={{ fontSize: 12, lineHeight: 1.45, whiteSpace: "pre-wrap" }}>
|
||
{card.rulesText || ""}
|
||
</div>
|
||
{card.flavorText && (
|
||
<div style={{ fontSize: 11, fontStyle: "italic", opacity: 0.75, borderTop: `1px solid ${colorObj.border}44`, paddingTop: 5, lineHeight: 1.35 }}>
|
||
{card.flavorText}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
padding: "4px 10px 6px",
|
||
fontSize: 10,
|
||
opacity: 0.7,
|
||
borderTop: `1px solid ${colorObj.border}44`,
|
||
}}>
|
||
<span>✦ {card.artist || "Unknown"}</span>
|
||
{(card.type === "Creature" || card.type === "Battle") && (
|
||
<div style={{
|
||
background: colorObj.border,
|
||
color: "#fff",
|
||
borderRadius: 4,
|
||
padding: "1px 7px",
|
||
fontWeight: 700,
|
||
fontSize: 13,
|
||
boxShadow: "0 1px 4px #0006",
|
||
}}>
|
||
{card.power}/{card.toughness}
|
||
</div>
|
||
)}
|
||
{isPW && card.loyalty && (
|
||
<div style={{
|
||
background: "#1a5ab8",
|
||
color: "#fff",
|
||
borderRadius: "50%",
|
||
width: 26, height: 26,
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
fontWeight: 700, fontSize: 14,
|
||
boxShadow: "0 1px 4px #0006",
|
||
}}>
|
||
{card.loyalty}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }) {
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<label style={{ fontSize: 11, fontWeight: 600, color: "#8a7a6a", letterSpacing: 0.5, textTransform: "uppercase" }}>{label}</label>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const inputStyle = {
|
||
background: "#1e1a14",
|
||
border: "1px solid #3a3020",
|
||
borderRadius: 6,
|
||
color: "#e8dfc8",
|
||
padding: "7px 10px",
|
||
fontSize: 13,
|
||
fontFamily: "inherit",
|
||
outline: "none",
|
||
width: "100%",
|
||
boxSizing: "border-box",
|
||
transition: "border-color 0.2s",
|
||
};
|
||
|
||
export default function App() {
|
||
const [card, setCard] = useState(DEFAULT_CARD);
|
||
const [imageUrl, setImageUrl] = useState("");
|
||
const [imageInput, setImageInput] = useState("");
|
||
const [tab, setTab] = useState("basic");
|
||
const previewRef = useRef();
|
||
const fileInputRef = useRef();
|
||
|
||
const update = useCallback((field, val) => setCard(c => ({ ...c, [field]: val })), []);
|
||
|
||
const handleImageFile = (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => setImageUrl(ev.target.result);
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const handleImageUrl = () => {
|
||
setImageUrl(imageInput.trim());
|
||
};
|
||
|
||
const handlePrint = () => window.print();
|
||
|
||
const isPW = card.type === "Planeswalker";
|
||
const isCreature = card.type === "Creature" || card.type === "Battle";
|
||
|
||
return (
|
||
<div style={{
|
||
minHeight: "100vh",
|
||
background: "#0e0b07",
|
||
fontFamily: "'Palatino Linotype', Palatino, 'Book Antiqua', serif",
|
||
color: "#e8dfc8",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}>
|
||
{/* Header */}
|
||
<div style={{
|
||
background: "linear-gradient(90deg,#1a0f00,#2e1a00,#1a0f00)",
|
||
borderBottom: "2px solid #5a3a10",
|
||
padding: "18px 28px",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 14,
|
||
}}>
|
||
<div style={{ fontSize: 30 }}>🃏</div>
|
||
<div>
|
||
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: 1, color: "#d4af37" }}>MTG Proxy Maker</div>
|
||
<div style={{ fontSize: 12, color: "#8a7060", letterSpacing: 0.3 }}>Design · Print · Play</div>
|
||
</div>
|
||
<button onClick={handlePrint} style={{
|
||
marginLeft: "auto",
|
||
background: "linear-gradient(135deg,#8b6010,#d4af37,#8b6010)",
|
||
color: "#1a0f00",
|
||
border: "none",
|
||
borderRadius: 8,
|
||
padding: "9px 22px",
|
||
fontWeight: 700,
|
||
fontSize: 14,
|
||
cursor: "pointer",
|
||
letterSpacing: 0.5,
|
||
fontFamily: "inherit",
|
||
boxShadow: "0 2px 8px #d4af3744",
|
||
}}>
|
||
🖨 Print
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", flex: 1, padding: "24px 28px", gap: 32, flexWrap: "wrap" }}>
|
||
{/* Editor Panel */}
|
||
<div style={{ flex: "1 1 360px", minWidth: 300, maxWidth: 480, display: "flex", flexDirection: "column", gap: 16 }}>
|
||
{/* Tabs */}
|
||
<div style={{ display: "flex", gap: 4, borderBottom: "1px solid #3a3020", paddingBottom: 4 }}>
|
||
{["basic", "art", "stats"].map(t => (
|
||
<button key={t} onClick={() => setTab(t)} style={{
|
||
background: tab === t ? "#3a2a10" : "transparent",
|
||
border: tab === t ? "1px solid #d4af37" : "1px solid transparent",
|
||
color: tab === t ? "#d4af37" : "#8a7060",
|
||
borderRadius: 6,
|
||
padding: "5px 14px",
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontFamily: "inherit",
|
||
fontWeight: tab === t ? 700 : 400,
|
||
textTransform: "capitalize",
|
||
letterSpacing: 0.3,
|
||
}}>{t}</button>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||
{tab === "basic" && <>
|
||
<Field label="Card Name">
|
||
<input style={inputStyle} value={card.name} onChange={e => update("name", e.target.value)} placeholder="e.g. Blazing Champion" />
|
||
</Field>
|
||
<Field label="Mana Cost">
|
||
<input style={inputStyle} value={card.manaCost} onChange={e => update("manaCost", e.target.value)} placeholder="{2}{R}{R}" />
|
||
</Field>
|
||
<div style={{ display: "flex", gap: 10 }}>
|
||
<Field label="Type">
|
||
<select style={{ ...inputStyle }} value={card.type} onChange={e => update("type", e.target.value)}>
|
||
{CARD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Subtype">
|
||
<input style={inputStyle} value={card.subtype} onChange={e => update("subtype", e.target.value)} placeholder="e.g. Wizard" />
|
||
</Field>
|
||
</div>
|
||
<Field label="Color">
|
||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||
{COLORS.map(c => (
|
||
<button key={c.id} onClick={() => update("color", c.id)} style={{
|
||
width: 36, height: 36, borderRadius: "50%",
|
||
background: c.id === "M" ? "linear-gradient(135deg,gold,blue,black)" : c.bg,
|
||
border: card.color === c.id ? "3px solid #d4af37" : `2px solid ${c.border}`,
|
||
cursor: "pointer",
|
||
fontWeight: 700, fontSize: 15,
|
||
boxShadow: card.color === c.id ? "0 0 8px #d4af37" : "none",
|
||
transition: "all 0.15s",
|
||
}} title={c.label}>{c.symbol}</button>
|
||
))}
|
||
</div>
|
||
</Field>
|
||
<Field label="Rarity">
|
||
<select style={{ ...inputStyle }} value={card.rarity} onChange={e => update("rarity", e.target.value)}>
|
||
{Object.keys(RARITY_COLORS).map(r => <option key={r} value={r}>{r}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Rules Text">
|
||
<textarea style={{ ...inputStyle, minHeight: 80, resize: "vertical" }} value={card.rulesText} onChange={e => update("rulesText", e.target.value)} placeholder="Card abilities..." />
|
||
</Field>
|
||
<Field label="Flavor Text">
|
||
<textarea style={{ ...inputStyle, minHeight: 50, resize: "vertical", fontStyle: "italic" }} value={card.flavorText} onChange={e => update("flavorText", e.target.value)} placeholder='"A whisper in the dark..."' />
|
||
</Field>
|
||
<Field label="Artist">
|
||
<input style={inputStyle} value={card.artist} onChange={e => update("artist", e.target.value)} placeholder="Artist name" />
|
||
</Field>
|
||
</>}
|
||
|
||
{tab === "art" && <>
|
||
<Field label="Upload Card Art">
|
||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageFile} style={{ display: "none" }} />
|
||
<button onClick={() => fileInputRef.current?.click()} style={{
|
||
...inputStyle, cursor: "pointer", textAlign: "center", background: "#2a1e0e",
|
||
border: "2px dashed #5a3a10", padding: "20px", fontSize: 14,
|
||
}}>
|
||
📁 Click to upload image
|
||
</button>
|
||
</Field>
|
||
<div style={{ color: "#8a7060", fontSize: 12, textAlign: "center" }}>— or paste an image URL —</div>
|
||
<Field label="Image URL">
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
<input style={{ ...inputStyle, flex: 1 }} value={imageInput} onChange={e => setImageInput(e.target.value)} placeholder="https://..." />
|
||
<button onClick={handleImageUrl} style={{
|
||
background: "#3a2a10", border: "1px solid #d4af37", color: "#d4af37",
|
||
borderRadius: 6, padding: "0 12px", cursor: "pointer", fontFamily: "inherit", fontSize: 13,
|
||
}}>Set</button>
|
||
</div>
|
||
</Field>
|
||
{imageUrl && (
|
||
<div>
|
||
<img src={imageUrl} alt="preview" style={{ width: "100%", borderRadius: 8, border: "1px solid #3a3020", maxHeight: 180, objectFit: "cover" }} />
|
||
<button onClick={() => { setImageUrl(""); setImageInput(""); }} style={{
|
||
marginTop: 6, background: "transparent", border: "1px solid #5a3020", color: "#c06040",
|
||
borderRadius: 6, padding: "4px 12px", cursor: "pointer", fontSize: 12, fontFamily: "inherit", width: "100%",
|
||
}}>Remove Image</button>
|
||
</div>
|
||
)}
|
||
<Field label="Set Symbol">
|
||
<input style={inputStyle} value={card.setSymbol} onChange={e => update("setSymbol", e.target.value)} placeholder="★ or set code" maxLength={4} />
|
||
</Field>
|
||
</>}
|
||
|
||
{tab === "stats" && <>
|
||
{isCreature && <>
|
||
<div style={{ display: "flex", gap: 10 }}>
|
||
<Field label="Power">
|
||
<input style={inputStyle} value={card.power} onChange={e => update("power", e.target.value)} placeholder="4" />
|
||
</Field>
|
||
<Field label="Toughness">
|
||
<input style={inputStyle} value={card.toughness} onChange={e => update("toughness", e.target.value)} placeholder="3" />
|
||
</Field>
|
||
</div>
|
||
</>}
|
||
{isPW && (
|
||
<Field label="Starting Loyalty">
|
||
<input style={inputStyle} value={card.loyalty} onChange={e => update("loyalty", e.target.value)} placeholder="4" />
|
||
</Field>
|
||
)}
|
||
{!isCreature && !isPW && (
|
||
<div style={{ color: "#8a7060", fontSize: 13, fontStyle: "italic", padding: "12px 0" }}>
|
||
No stats for this card type. Switch to Creature or Planeswalker to edit stats.
|
||
</div>
|
||
)}
|
||
<div style={{ borderTop: "1px solid #3a3020", paddingTop: 12 }}>
|
||
<div style={{ fontSize: 12, color: "#8a7060", marginBottom: 8 }}>Quick Reset</div>
|
||
<button onClick={() => setCard(DEFAULT_CARD)} style={{
|
||
background: "#2a1e0e", border: "1px solid #5a3a10", color: "#c8a060",
|
||
borderRadius: 6, padding: "7px 16px", cursor: "pointer", fontFamily: "inherit", fontSize: 13,
|
||
}}>↩ Reset to Default</button>
|
||
</div>
|
||
</>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Preview */}
|
||
<div style={{ flex: "1 1 300px", display: "flex", flexDirection: "column", alignItems: "center", gap: 20 }}>
|
||
<div style={{ fontSize: 12, color: "#8a7060", letterSpacing: 1, textTransform: "uppercase" }}>Preview</div>
|
||
<div ref={previewRef} id="card-preview" style={{ transform: "scale(1)", transformOrigin: "top center" }}>
|
||
<CardPreview card={card} imageUrl={imageUrl} />
|
||
</div>
|
||
<div style={{ fontSize: 11, color: "#5a4a30", textAlign: "center", maxWidth: 280 }}>
|
||
Use the Print button to print on card stock.<br />
|
||
Cut to 2.5" × 3.5" for standard size.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>{`
|
||
@media print {
|
||
body * { visibility: hidden; }
|
||
#card-preview, #card-preview * { visibility: visible; }
|
||
#card-preview {
|
||
position: fixed; left: 50%; top: 50%;
|
||
transform: translate(-50%,-50%) scale(2.5);
|
||
transform-origin: center center;
|
||
}
|
||
}
|
||
input:focus, textarea:focus, select:focus {
|
||
border-color: #d4af37 !important;
|
||
box-shadow: 0 0 0 2px #d4af3733;
|
||
}
|
||
select option { background: #1e1a14; color: #e8dfc8; }
|
||
* { box-sizing: border-box; }
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|