recipe

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>
  );
}
Scroll to Top