import { useState, useRef, useCallback } from "react";
const SLOT = 64;
function loadImg(src) {
return new Promise((res) => {
const i = new Image();
i.onload = () => res(i);
i.src = src;
});
}
function fileToUrl(file) {
return new Promise((res) => {
const r = new FileReader();
r.onload = (e) => res(e.target.result);
r.readAsDataURL(file);
});
}
async function renderBlock(topSrc, sideSrc, frontSrc, size) {
const [top, side] = await Promise.all([loadImg(topSrc), loadImg(sideSrc)]);
const front = frontSrc ? await loadImg(frontSrc) : null;
const s = size;
const c = document.createElement("canvas");
c.width = s * 2;
c.height = s * 2;
const x = c.getContext("2d");
x.imageSmoothingEnabled = false;
const cx = s, cy = s;
x.save(); x.setTransform(1, -0.5, 1, 0.5, cx - s, cy - s / 2);
x.drawImage(top, 0, 0, s, s); x.restore();
x.save(); x.setTransform(1, 0.5, 0, 1, cx - s, cy - s / 2);
x.drawImage(front || side, 0, 0, s, s);
x.fillStyle = "rgba(0,0,0,0.22)"; x.fillRect(0, 0, s, s); x.restore();
x.save(); x.setTransform(1, -0.5, 0, 1, cx, cy);
x.drawImage(side, 0, 0, s, s);
x.fillStyle = "rgba(0,0,0,0.38)"; x.fillRect(0, 0, s, s); x.restore();
return c.toDataURL("image/png");
}
function drawSlot(ctx, x, y, s, img) {
ctx.fillStyle = "#373737"; ctx.fillRect(x, y, s, 2); ctx.fillRect(x, y, 2, s);
ctx.fillStyle = "#FFF"; ctx.fillRect(x, y + s - 2, s, 2); ctx.fillRect(x + s - 2, y, 2, s);
ctx.fillStyle = "#8B8B8B"; ctx.fillRect(x + 2, y + 2, s - 4, s - 4);
if (img) {
const p = Math.floor(s * 0.08);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, x + p, y + p, s - p * 2, s - p * 2);
}
}
async function exportRecipe(grid, output, scale, transparent) {
const s = SLOT * scale, g = 4 * scale, p = 16 * scale;
const gridW = 3 * s + 2 * g, gridH = gridW;
const arrowW = Math.floor(s * 0.8), outputS = s;
const totalW = p + gridW + p + arrowW + p + outputS + p;
const totalH = p + gridH + p;
const cv = document.createElement("canvas");
cv.width = totalW; cv.height = totalH;
const ctx = cv.getContext("2d");
ctx.imageSmoothingEnabled = false;
if (!transparent) {
ctx.fillStyle = "#C6C6C6"; ctx.fillRect(0, 0, totalW, totalH);
const bw = 2 * scale;
ctx.fillStyle = "#FFF"; ctx.fillRect(0, 0, totalW, bw); ctx.fillRect(0, 0, bw, totalH);
ctx.fillStyle = "#555"; ctx.fillRect(0, totalH - bw, totalW, bw); ctx.fillRect(totalW - bw, 0, bw, totalH);
}
const imgs = {};
for (let i = 0; i < 9; i++) if (grid[i]) imgs[i] = await loadImg(grid[i].dataUrl);
let outImg = output ? await loadImg(output.dataUrl) : null;
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) {
drawSlot(ctx, p + c * (s + g), p + r * (s + g), s, imgs[r * 3 + c] || null);
}
const ax = p + gridW + p, mid = p + gridH / 2, ah = Math.floor(s * 0.4);
const shaftH = Math.floor(ah * 0.35), shaftW = Math.floor(arrowW * 0.6);
ctx.fillStyle = "#8B8B8B";
ctx.fillRect(ax, mid - shaftH, shaftW, shaftH * 2);
ctx.beginPath();
ctx.moveTo(ax + shaftW, mid - ah);
ctx.lineTo(ax + arrowW, mid);
ctx.lineTo(ax + shaftW, mid + ah);
ctx.closePath(); ctx.fill();
ctx.fillStyle = "#6B6B6B";
ctx.fillRect(ax, mid - shaftH, shaftW, 2 * scale);
ctx.beginPath();
ctx.moveTo(ax + shaftW, mid - ah);
ctx.lineTo(ax + arrowW, mid);
ctx.lineTo(ax + shaftW, mid - ah + 2 * scale);
ctx.closePath(); ctx.fill();
const ox = ax + arrowW + p, oy = p + (gridH - s) / 2;
drawSlot(ctx, ox, oy, s, outImg);
return cv.toDataURL("image/png");
}
const S = {
wrap: { fontFamily: "'Segoe UI', system-ui, sans-serif", background: "#1a1c24", color: "#c8cec9", minHeight: "100vh", padding: "0" },
header: { background: "#22242d", borderBottom: "1px solid #33363f", padding: "20px 24px", marginBottom: "24px" },
title: { fontSize: "1.5rem", fontWeight: 700, color: "#04E2DC", margin: 0 },
subtitle: { fontSize: "13px", color: "#5c6360", marginTop: 4 },
body: { maxWidth: 960, margin: "0 auto", padding: "0 24px 80px" },
section: { background: "#22242d", border: "1px solid #33363f", borderRadius: 10, padding: "20px 24px", marginBottom: 20 },
sectionTitle: { fontSize: "0.95rem", fontWeight: 600, color: "#04E2DC", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 16, paddingLeft: 10, borderLeft: "3px solid #04E2DC" },
row: { display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" },
col: { display: "flex", flexDirection: "column", gap: 6 },
label: { fontSize: 12, color: "#8a9490", fontWeight: 500, textTransform: "uppercase", letterSpacing: 0.5 },
fileBtn: { display: "inline-block", padding: "8px 16px", background: "#2a2d38", border: "1px solid #3a3e48", borderRadius: 6, color: "#c8cec9", fontSize: 13, cursor: "pointer", transition: "all 0.15s" },
btn: { padding: "8px 20px", background: "#04E2DC", color: "#1a1c24", border: "none", borderRadius: 6, fontSize: 13, fontWeight: 600, cursor: "pointer", transition: "all 0.15s" },
btnSm: { padding: "6px 14px", background: "#2a2d38", border: "1px solid #3a3e48", borderRadius: 6, color: "#c8cec9", fontSize: 12, cursor: "pointer" },
btnDanger: { padding: "6px 14px", background: "#3a2020", border: "1px solid #5a3030", borderRadius: 6, color: "#e06b6b", fontSize: 12, cursor: "pointer" },
preview: { background: "repeating-conic-gradient(#2a2d38 0% 25%, #22242d 0% 50%) 50%/16px 16px", borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", padding: 16, minHeight: 150, border: "1px solid #33363f" },
input: { padding: "8px 12px", background: "#2a2d38", border: "1px solid #3a3e48", borderRadius: 6, color: "#c8cec9", fontSize: 13, outline: "none", width: 180 },
libGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))", gap: 8 },
libItem: { display: "flex", flexDirection: "column", alignItems: "center", padding: "8px 4px", background: "#2a2d38", border: "2px solid #3a3e48", borderRadius: 6, cursor: "pointer", transition: "all 0.15s", position: "relative" },
libItemSel: { borderColor: "#04E2DC", background: "#1a3a3a" },
libImg: { width: 48, height: 48, objectFit: "contain", imageRendering: "pixelated" },
libName: { fontSize: 10, color: "#8a9490", marginTop: 4, textAlign: "center", maxWidth: "100%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
libDel: { position: "absolute", top: 2, right: 4, fontSize: 12, color: "#666", cursor: "pointer", background: "none", border: "none", lineHeight: 1 },
craftWrap: { display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap", justifyContent: "center" },
craftGrid: { display: "grid", gridTemplateColumns: "repeat(3, 56px)", gap: 4 },
craftSlot: { width: 56, height: 56, background: "#555", border: "2px solid #444", borderTopColor: "#333", borderLeftColor: "#333", borderBottomColor: "#777", borderRightColor: "#777", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", imageRendering: "pixelated", position: "relative" },
craftSlotHover: { borderColor: "#04E2DC" },
craftImg: { width: 44, height: 44, objectFit: "contain", imageRendering: "pixelated", pointerEvents: "none" },
arrow: { fontSize: 32, color: "#555", userSelect: "none", margin: "0 8px" },
outputSlot: { width: 64, height: 64, background: "#555", border: "2px solid #444", borderTopColor: "#333", borderLeftColor: "#333", borderBottomColor: "#777", borderRightColor: "#777", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", imageRendering: "pixelated" },
outputImg: { width: 52, height: 52, objectFit: "contain", imageRendering: "pixelated", pointerEvents: "none" },
exportArea: { marginTop: 16, display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" },
resultWrap: { marginTop: 16, textAlign: "center" },
resultImg: { maxWidth: "100%", imageRendering: "pixelated", borderRadius: 6, border: "1px solid #33363f" },
hint: { fontSize: 11, color: "#5c6360", fontStyle: "italic" },
checkLabel: { display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: "#8a9490", cursor: "pointer" },
};
export default function RecipeBuilder() {
const [lib, setLib] = useState([]);
const [sel, setSel] = useState(null);
const [grid, setGrid] = useState(Array(9).fill(null));
const [out, setOut] = useState(null);
const [topSrc, setTopSrc] = useState(null);
const [sideSrc, setSideSrc] = useState(null);
const [frontSrc, setFrontSrc] = useState(null);
const [blockPrev, setBlockPrev] = useState(null);
const [blockName, setBlockName] = useState("");
const [recipeImg, setRecipeImg] = useState(null);
const [scale, setScale] = useState(2);
const [transBg, setTransBg] = useState(false);
const [hoverSlot, setHoverSlot] = useState(null);
const topRef = useRef(); const sideRef = useRef(); const frontRef = useRef(); const itemRef = useRef();
const onTex = async (e, set) => { if (e.target.files[0]) set(await fileToUrl(e.target.files[0])); };
const doRender = async () => {
if (!topSrc || !sideSrc) return;
setBlockPrev(await renderBlock(topSrc, sideSrc, frontSrc, 128));
};
const addBlock = () => {
if (!blockPrev) return;
setLib(p => [...p, { id: Date.now(), name: blockName || "Block " + (p.length + 1), dataUrl: blockPrev }]);
setBlockPrev(null); setTopSrc(null); setSideSrc(null); setFrontSrc(null); setBlockName("");
if (topRef.current) topRef.current.value = "";
if (sideRef.current) sideRef.current.value = "";
if (frontRef.current) frontRef.current.value = "";
};
const addItems = async (e) => {
for (const f of Array.from(e.target.files)) {
const url = await fileToUrl(f);
setLib(p => [...p, { id: Date.now() + Math.random(), name: f.name.replace(/\.\w+$/, ""), dataUrl: url }]);
}
e.target.value = "";
};
const removeLib = (id) => {
setLib(p => p.filter(i => i.id !== id));
if (sel?.id === id) setSel(null);
setGrid(p => p.map(s => s?.id === id ? null : s));
if (out?.id === id) setOut(null);
};
const placeSlot = (idx) => { if (sel) setGrid(p => { const n = [...p]; n[idx] = sel; return n; }); };
const clearSlot = (e, idx) => { e.preventDefault(); setGrid(p => { const n = [...p]; n[idx] = null; return n; }); };
const placeOut = () => { if (sel) setOut(sel); };
const clearOut = (e) => { e.preventDefault(); setOut(null); };
const clearAll = () => { setGrid(Array(9).fill(null)); setOut(null); setRecipeImg(null); };
const doExport = async () => { setRecipeImg(await exportRecipe(grid, out, scale, transBg)); };
const download = (url, name) => { const a = document.createElement("a"); a.href = url; a.download = name; a.click(); };
return (
<div style={S.wrap}>
<div style={S.header}>
<h1 style={S.title}>Recipe Image Builder</h1>
<p style={S.subtitle}>Render isometric blocks from textures, build crafting recipes, and export images for your wiki.</p>
</div>
<div style={S.body}>
{/* BLOCK RENDERER */}
<div style={S.section}>
<div style={S.sectionTitle}>Isometric Block Renderer</div>
<div style={S.row}>
<div style={S.col}>
<span style={S.label}>Top Texture *</span>
<label style={S.fileBtn}>
{topSrc ? "✓ Uploaded" : "Choose file"}
<input ref={topRef} type="file" accept="image/png" style={{ display: "none" }} onChange={e => onTex(e, setTopSrc)} />
</label>
{topSrc && <img src={topSrc} style={{ width: 32, height: 32, imageRendering: "pixelated", borderRadius: 4, border: "1px solid #3a3e48" }} />}
</div>
<div style={S.col}>
<span style={S.label}>Side Texture *</span>
<label style={S.fileBtn}>
{sideSrc ? "✓ Uploaded" : "Choose file"}
<input ref={sideRef} type="file" accept="image/png" style={{ display: "none" }} onChange={e => onTex(e, setSideSrc)} />
</label>
{sideSrc && <img src={sideSrc} style={{ width: 32, height: 32, imageRendering: "pixelated", borderRadius: 4, border: "1px solid #3a3e48" }} />}
</div>
<div style={S.col}>
<span style={S.label}>Front Texture</span>
<label style={S.fileBtn}>
{frontSrc ? "✓ Uploaded" : "Optional"}
<input ref={frontRef} type="file" accept="image/png" style={{ display: "none" }} onChange={e => onTex(e, setFrontSrc)} />
</label>
{frontSrc && <img src={frontSrc} style={{ width: 32, height: 32, imageRendering: "pixelated", borderRadius: 4, border: "1px solid #3a3e48" }} />}
</div>
<button style={{ ...S.btn, opacity: (!topSrc || !sideSrc) ? 0.4 : 1 }} onClick={doRender} disabled={!topSrc || !sideSrc}>
Render Block
</button>
</div>
{blockPrev && (
<div style={{ marginTop: 16 }}>
<div style={S.preview}>
<img src={blockPrev} style={{ width: 128, height: 128, imageRendering: "pixelated" }} />
</div>
<div style={{ ...S.row, marginTop: 12 }}>
<input style={S.input} placeholder="Block name..." value={blockName} onChange={e => setBlockName(e.target.value)} />
<button style={S.btn} onClick={addBlock}>Add to Library</button>
<button style={S.btnSm} onClick={() => download(blockPrev, (blockName || "block") + ".png")}>Download PNG</button>
</div>
</div>
)}
</div>
{/* ITEM LIBRARY */}
<div style={S.section}>
<div style={S.sectionTitle}>Item Library</div>
<div style={{ ...S.row, marginBottom: 12 }}>
<label style={S.fileBtn}>
Upload Item PNGs
<input ref={itemRef} type="file" accept="image/png" multiple style={{ display: "none" }} onChange={addItems} />
</label>
<span style={S.hint}>Upload flat item/block textures (swords, ingots, etc.) or use the renderer above for 3D blocks</span>
</div>
{lib.length === 0 ? (
<p style={{ ...S.hint, textAlign: "center", padding: "24px 0" }}>No items yet. Render a block or upload item PNGs above.</p>
) : (
<>
<p style={{ ...S.hint, marginBottom: 8 }}>Click an item to select it, then click a crafting slot to place it. Right-click a slot to clear it.</p>
<div style={S.libGrid}>
{lib.map(item => (
<div key={item.id}
style={{ ...S.libItem, ...(sel?.id === item.id ? S.libItemSel : {}) }}
onClick={() => setSel(sel?.id === item.id ? null : item)}>
<button style={S.libDel} onClick={e => { e.stopPropagation(); removeLib(item.id); }} title="Remove">×</button>
<img src={item.dataUrl} style={S.libImg} />
<span style={S.libName}>{item.name}</span>
</div>
))}
</div>
</>
)}
</div>
{/* CRAFTING GRID */}
<div style={S.section}>
<div style={S.sectionTitle}>Crafting Recipe</div>
<div style={S.craftWrap}>
<div style={S.craftGrid}>
{grid.map((item, i) => (
<div key={i} style={{ ...S.craftSlot, ...(hoverSlot === i && sel ? { borderColor: "#04E2DC" } : {}) }}
onClick={() => placeSlot(i)} onContextMenu={e => clearSlot(e, i)}
onMouseEnter={() => setHoverSlot(i)} onMouseLeave={() => setHoverSlot(null)}>
{item && <img src={item.dataUrl} style={S.craftImg} />}
</div>
))}
</div>
<span style={S.arrow}>→</span>
<div style={{ ...S.outputSlot, ...(hoverSlot === "out" && sel ? { borderColor: "#04E2DC" } : {}) }}
onClick={placeOut} onContextMenu={clearOut}
onMouseEnter={() => setHoverSlot("out")} onMouseLeave={() => setHoverSlot(null)}>
{out && <img src={out.dataUrl} style={S.outputImg} />}
</div>
</div>
<div style={S.exportArea}>
<div style={S.col}>
<span style={S.label}>Scale</span>
<select style={S.input} value={scale} onChange={e => setScale(Number(e.target.value))}>
<option value={1}>1x (small)</option>
<option value={2}>2x (medium)</option>
<option value={3}>3x (large)</option>
<option value={4}>4x (extra large)</option>
</select>
</div>
<label style={{ ...S.checkLabel, alignSelf: "flex-end", paddingBottom: 8 }}>
<input type="checkbox" checked={transBg} onChange={e => setTransBg(e.target.checked)} />
Transparent background
</label>
<button style={{ ...S.btn, alignSelf: "flex-end", marginBottom: 4 }} onClick={doExport}>Export Recipe Image</button>
<button style={{ ...S.btnDanger, alignSelf: "flex-end", marginBottom: 4 }} onClick={clearAll}>Clear All</button>
</div>
{recipeImg && (
<div style={S.resultWrap}>
<div style={S.preview}>
<img src={recipeImg} style={S.resultImg} />
</div>
<button style={{ ...S.btn, marginTop: 12 }} onClick={() => download(recipeImg, "recipe.png")}>
Download Recipe PNG
</button>
</div>
)}
</div>
</div>
</div>
);
}