const DATA_URL = window.ZabaCrafting?.dataUrl || "./generated/crafting-recipes-release.json";
const ICON_BASE = window.ZabaCrafting?.iconBase || "./assets/icons/";
const ICON_DATA = window.ZabaCrafting?.iconData || {};
const QUICK_FILTERS = [
{ id: "all", label: "ทั้งหมด", test: () => true },
{ id: "popular", label: "ยอดนิยม", test: (recipe) => recipe.searchText.match(/crafting_table|stick|torch|chest|furnace|bed|boat|sword|pickaxe|axe|shovel|shield|bow|arrow|bucket|door|ladder|rail|piston|hopper|observer/) },
{ id: "wood", label: "ไม้", test: (recipe) => recipe.searchText.match(/wood|log|plank|slab|stairs|fence|door|trapdoor|sign|boat|shelf|ไม้|แผ่นไม้|ซุง/) },
{ id: "stone", label: "หิน", test: (recipe) => recipe.searchText.match(/stone|cobble|deepslate|granite|diorite|andesite|tuff|blackstone|หิน/) },
{ id: "copper", label: "ทองแดง", test: (recipe) => recipe.searchText.match(/copper|ทองแดง/) },
{ id: "color", label: "สี/ย้อม", test: (recipe) => recipe.searchText.match(/dye|banner|wool|carpet|bed|stained|terracotta|candle|shulker|bundle|harness|สี|ย้อม/) },
{ id: "redstone", label: "เรดสโตน", test: (recipe) => recipe.category === "redstone" || recipe.searchText.match(/redstone|piston|hopper|observer|rail|comparator|repeater|เรดสโตน/) },
{ id: "gear", label: "อุปกรณ์", test: (recipe) => ["tools", "weapons", "armor"].includes(recipe.category) },
{ id: "nether", label: "เนเธอร์/เอนด์", test: (recipe) => recipe.category === "nether_end" || recipe.searchText.match(/nether|end_|ender|quartz|blaze|obsidian|เนเธอร์|เอนด์/) }
];
const TYPE_LABELS = {
all: "ทุกประเภทสูตร",
"minecraft:crafting_shaped": "ต้องวางตำแหน่ง",
"minecraft:crafting_shapeless": "ไม่บังคับตำแหน่ง",
"minecraft:crafting_transmute": "สูตรแปลงไอเทม"
};
let categories = [];
let items = {};
let recipes = [];
let activeCategory = "all";
let activeQuickFilter = "all";
let activeType = "all";
let activeSort = "name_th";
let selectedRecipe = null;
let visibleLimit = 180;
const el = {
recipeCount: document.getElementById("recipe-count"),
selectedIcon: document.getElementById("selected-icon"),
selectedTitle: document.getElementById("selected-title"),
selectedSubtitle: document.getElementById("selected-subtitle"),
selectedDescription: document.getElementById("selected-description"),
selectedEditions: document.getElementById("selected-editions"),
ingredientList: document.getElementById("ingredient-list"),
craftingGrid: document.getElementById("crafting-grid"),
resultSlot: document.getElementById("result-slot"),
tabs: document.getElementById("category-tabs"),
grid: document.getElementById("recipe-card-grid"),
search: document.getElementById("recipe-search"),
empty: document.getElementById("empty-state")
};
function ensureFinderControls() {
const finderPanel = document.querySelector(".finder-panel");
const searchBox = document.querySelector(".search-box");
if (searchBox && !document.getElementById("recipe-search")) {
const searchInput = document.createElement("input");
searchInput.type = "search";
searchInput.id = "recipe-search";
searchInput.placeholder = "ค้นหา เช่น ดาบ โต๊ะคราฟ redstone";
searchInput.autocomplete = "off";
searchBox.appendChild(searchInput);
}
el.search = document.getElementById("recipe-search");
if (!finderPanel || document.getElementById("quick-filter-tabs")) return;
const controls = document.createElement("div");
controls.className = "craft-filter-panel";
controls.innerHTML = `
`;
const categoryTabs = document.getElementById("category-tabs");
finderPanel.insertBefore(controls, categoryTabs);
const footer = document.createElement("div");
footer.className = "recipe-results-footer";
footer.innerHTML = `
`;
el.grid.insertAdjacentElement("afterend", footer);
el.quickTabs = document.getElementById("quick-filter-tabs");
el.typeFilter = document.getElementById("type-filter");
el.sortFilter = document.getElementById("sort-filter");
el.clearFilter = document.getElementById("clear-filter-button");
el.visibleSummary = document.getElementById("visible-summary");
el.loadMore = document.getElementById("load-more-recipes");
}
function normalizeRecipe(recipe) {
const ingredients = (recipe.ingredients || []).map((ingredient) => ({
id: ingredient.id,
count: Number(ingredient.count || 1),
alternatives: ingredient.alternatives || (ingredient.id ? [ingredient.id] : [])
}));
const tags = [
recipe.id,
recipe.result,
recipe.nameTh,
recipe.nameEn,
recipe.thaiBucket,
recipe.category,
recipe.type,
...ingredients.flatMap((ingredient) => [ingredient.id, ...ingredient.alternatives])
].filter(Boolean);
return {
...recipe,
outputQty: Number(recipe.outputQty || 1),
grid: Array.from({ length: 9 }, (_, index) => recipe.grid?.[index] || null),
ingredients,
tags,
searchText: tags.join(" ").toLowerCase()
};
}
function itemMeta(id) {
if (!id) return { nameTh: "-", nameEn: "-", label: "-", color: "#cfd5cc" };
const meta = items[id] || {};
const nameTh = meta.nameTh || id.replace(/^#/, "").replace(/_/g, " ");
const nameEn = meta.nameEn || nameTh;
return {
nameTh,
nameEn,
label: shortLabel(id),
color: colorFromId(id)
};
}
function shortLabel(id) {
return String(id)
.replace(/^#/, "")
.split("_")
.filter(Boolean)
.slice(0, 2)
.map((part) => part.slice(0, 3))
.join(" ")
.toUpperCase();
}
function colorFromId(id) {
let hash = 0;
for (const ch of String(id)) hash = ((hash << 5) - hash + ch.charCodeAt(0)) | 0;
const hue = Math.abs(hash) % 360;
return `hsl(${hue} 34% 58%)`;
}
function icon(id, large = false) {
const meta = itemMeta(id);
const node = document.createElement("span");
node.className = `item-icon${large ? " item-icon-lg" : ""}`;
node.style.setProperty("--item-color", meta.color);
node.title = `${meta.nameTh} (${meta.nameEn})`;
const mappedIcon = ICON_DATA[id];
if (!id || (id.startsWith("#") && !mappedIcon)) {
node.textContent = meta.label;
return node;
}
const img = document.createElement("img");
img.src = mappedIcon || `${ICON_BASE}${id}.png`;
img.alt = "";
img.loading = "lazy";
img.decoding = "async";
img.addEventListener("error", () => {
node.textContent = meta.label;
}, { once: true });
node.appendChild(img);
return node;
}
function recipeDescription(recipe) {
if (recipe.notes) return recipe.notes;
if (recipe.type === "minecraft:crafting_shapeless") {
return "สูตรแบบไม่บังคับตำแหน่ง วางวัตถุดิบในช่องคราฟช่องใดก็ได้ตามจำนวนที่ระบุ";
}
if (recipe.type === "minecraft:crafting_transmute") {
return "สูตรแปลงไอเทม ใช้วัตถุดิบหลักร่วมกับวัสดุแปลงตามที่ระบุในช่องคราฟ";
}
return "วางวัตถุดิบตามตำแหน่ง 3x3 ที่แสดง แล้วจะได้ผลลัพธ์ตามจำนวนที่ระบุ";
}
function setSelected(recipeToShow, updateHash = true) {
selectedRecipe = recipeToShow;
if (updateHash) history.replaceState(null, "", `#${recipeToShow.id}`);
el.selectedIcon.replaceChildren(icon(recipeToShow.result || recipeToShow.id, true));
el.selectedTitle.textContent = `${recipeToShow.nameTh} (${recipeToShow.nameEn})`;
el.selectedSubtitle.textContent = `ผลลัพธ์: ${recipeToShow.nameTh} x${recipeToShow.outputQty}`;
el.selectedDescription.textContent = recipeDescription(recipeToShow);
el.selectedEditions.textContent = "Java และ Bedrock ส่วนใหญ่ใช้สูตรเดียวกัน; ตรวจเวอร์ชันเกมหากเป็นไอเทมใหม่";
renderCraftingGrid(recipeToShow);
renderIngredients(recipeToShow);
renderCards();
}
function renderCraftingGrid(recipeToShow) {
el.craftingGrid.replaceChildren();
recipeToShow.grid.forEach((itemId) => {
const slot = document.createElement("div");
slot.className = "crafting-slot";
if (itemId) {
slot.appendChild(icon(itemId));
} else {
slot.textContent = "-";
slot.classList.add("empty-slot");
}
el.craftingGrid.appendChild(slot);
});
el.resultSlot.replaceChildren(icon(recipeToShow.result || recipeToShow.id));
}
function renderIngredients(recipeToShow) {
el.ingredientList.replaceChildren();
recipeToShow.ingredients.forEach((ingredient) => {
const li = document.createElement("li");
const primary = ingredient.id || ingredient.alternatives[0];
li.appendChild(icon(primary));
const text = document.createElement("span");
const meta = itemMeta(primary);
const alternatives = ingredient.alternatives.filter((id) => id && id !== primary);
const altText = alternatives.length ? ` หรือ ${alternatives.map((id) => itemMeta(id).nameTh).join(", ")}` : "";
text.innerHTML = `${meta.nameTh} x${ingredient.count}${meta.nameEn}${altText}`;
li.appendChild(text);
el.ingredientList.appendChild(li);
});
}
function renderTabs() {
el.tabs.replaceChildren();
categories.forEach((category) => {
const button = document.createElement("button");
button.type = "button";
button.className = category.id === activeCategory ? "active" : "";
const count = category.id === "all"
? recipes.length
: recipes.filter((recipeToCheck) => recipeToCheck.category === category.id).length;
button.textContent = `${category.label} (${count})`;
button.addEventListener("click", () => {
activeCategory = category.id;
visibleLimit = 180;
renderTabs();
renderCards();
});
el.tabs.appendChild(button);
});
}
function renderQuickFilters() {
el.quickTabs.replaceChildren();
QUICK_FILTERS.forEach((filter) => {
const count = recipes.filter(filter.test).length;
const button = document.createElement("button");
button.type = "button";
button.className = filter.id === activeQuickFilter ? "active" : "";
button.textContent = `${filter.label} (${count})`;
button.addEventListener("click", () => {
activeQuickFilter = filter.id;
visibleLimit = 180;
renderQuickFilters();
renderCards();
});
el.quickTabs.appendChild(button);
});
}
function renderTypeFilter() {
const types = ["all", ...new Set(recipes.map((recipe) => recipe.type).filter(Boolean))];
el.typeFilter.innerHTML = types
.map((type) => ``)
.join("");
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
}[ch]));
}
function matches(recipeToCheck) {
const query = (el.search?.value || "").trim().toLowerCase();
const quickFilter = QUICK_FILTERS.find((filter) => filter.id === activeQuickFilter) || QUICK_FILTERS[0];
if (activeCategory !== "all" && recipeToCheck.category !== activeCategory) return false;
if (activeType !== "all" && recipeToCheck.type !== activeType) return false;
if (!quickFilter.test(recipeToCheck)) return false;
if (!query) return true;
return recipeToCheck.searchText.includes(query);
}
function compareRecipeName(a, b) {
if (activeSort === "name_en") return a.nameEn.localeCompare(b.nameEn, "en");
if (activeSort === "category") return `${a.category} ${a.nameTh}`.localeCompare(`${b.category} ${b.nameTh}`, "th");
return a.nameTh.localeCompare(b.nameTh, "th");
}
function sortRecipes(list) {
const categoryOrder = new Map(categories.map((category, index) => [category.id, index]));
return [...list].sort((a, b) => {
if (activeCategory === "all") {
const orderA = categoryOrder.get(a.category) ?? 999;
const orderB = categoryOrder.get(b.category) ?? 999;
if (orderA !== orderB) return orderA - orderB;
}
return compareRecipeName(a, b);
});
}
function getCategoryLabel(categoryId) {
const category = categories.find((categoryItem) => categoryItem.id === categoryId);
return category?.label || categoryId || "อื่นๆ";
}
function createCategoryDivider(categoryId, count) {
const divider = document.createElement("div");
divider.className = "recipe-category-divider";
const label = document.createElement("span");
label.textContent = getCategoryLabel(categoryId);
const amount = document.createElement("small");
amount.textContent = `${count.toLocaleString("th-TH")} สูตร`;
divider.append(label, amount);
return divider;
}
function renderCards() {
const visibleRecipes = sortRecipes(recipes.filter(matches));
const recipesToRender = visibleRecipes;
const fragment = document.createDocumentFragment();
const showCategoryDividers = activeCategory === "all" && visibleRecipes.length > 0;
const categoryCounts = visibleRecipes.reduce((counts, recipeToCount) => {
counts.set(recipeToCount.category, (counts.get(recipeToCount.category) || 0) + 1);
return counts;
}, new Map());
let currentCategory = null;
el.grid.replaceChildren();
recipesToRender.forEach((recipeToShow) => {
if (showCategoryDividers && recipeToShow.category !== currentCategory) {
currentCategory = recipeToShow.category;
fragment.appendChild(createCategoryDivider(currentCategory, categoryCounts.get(currentCategory) || 0));
}
const button = document.createElement("button");
button.type = "button";
button.className = recipeToShow.id === selectedRecipe?.id ? "recipe-card active" : "recipe-card";
const label = `${recipeToShow.nameTh} (${recipeToShow.nameEn})`;
button.setAttribute("aria-label", label);
button.title = label;
button.dataset.tooltip = label;
button.appendChild(icon(recipeToShow.result || recipeToShow.id));
button.addEventListener("click", () => setSelected(recipeToShow));
fragment.appendChild(button);
});
el.grid.appendChild(fragment);
el.empty.hidden = visibleRecipes.length > 0;
el.visibleSummary.textContent = visibleRecipes.length
? `แสดงทั้งหมด ${visibleRecipes.length.toLocaleString("th-TH")} สูตรที่ตรงเงื่อนไข`
: "ไม่พบสูตรที่ตรงกับตัวกรอง";
el.loadMore.hidden = true;
}
function clearFilters() {
activeCategory = "all";
activeQuickFilter = "all";
activeType = "all";
activeSort = "name_th";
visibleLimit = 180;
el.search.value = "";
el.typeFilter.value = activeType;
el.sortFilter.value = activeSort;
renderTabs();
renderQuickFilters();
renderCards();
}
async function loadDatabase() {
ensureFinderControls();
if (window.ZabaCrafting?.iconMapUrl) {
try {
const iconResponse = await fetch(window.ZabaCrafting.iconMapUrl, { cache: "no-store" });
if (iconResponse.ok) Object.assign(ICON_DATA, await iconResponse.json());
} catch (error) {
console.warn("Cannot load crafting icon map", error);
}
}
const response = await fetch(DATA_URL, { cache: "no-store" });
if (!response.ok) throw new Error(`Cannot load crafting data: ${response.status}`);
const data = await response.json();
categories = data.categories || [];
items = data.items || {};
recipes = (data.recipes || []).map(normalizeRecipe);
el.recipeCount.textContent = `${recipes.length.toLocaleString("th-TH")} สูตร`;
const hashId = decodeURIComponent(location.hash.replace(/^#/, ""));
const initialRecipe = recipes.find((recipe) => recipe.id === hashId || recipe.result === hashId) || recipes[0];
renderTabs();
renderQuickFilters();
renderTypeFilter();
setSelected(initialRecipe, Boolean(hashId));
el.search.addEventListener("input", () => {
visibleLimit = 180;
renderCards();
});
el.typeFilter.addEventListener("change", () => {
activeType = el.typeFilter.value;
visibleLimit = 180;
renderCards();
});
el.sortFilter.addEventListener("change", () => {
activeSort = el.sortFilter.value;
renderCards();
});
el.clearFilter.addEventListener("click", clearFilters);
el.loadMore.addEventListener("click", () => {
visibleLimit += 180;
renderCards();
});
}
loadDatabase().catch((error) => {
console.error(error);
el.selectedTitle.textContent = "โหลดข้อมูลสูตรคราฟไม่สำเร็จ";
el.selectedSubtitle.textContent = "ตรวจไฟล์ JSON หรือ path ของฐานข้อมูล";
});