const WIKI_IMG = 'https://terraria.wiki.gg/wiki/Special:FilePath/';
const PINS_STORAGE_KEY = 'terraria-pinned-recipes';
// In-memory caches
const itemCache = new Map();
const recipeCache = new Map();
const dropCache = new Map();
// ── Pins localStorage helpers ─────────────────────────────────────
function loadPins() {
try {
const raw = localStorage.getItem(PINS_STORAGE_KEY);
if (!raw) return { version: 1, pins: {} };
const data = JSON.parse(raw);
if (data.version === 1) return data;
return { version: 1, pins: {} };
} catch {
return { version: 1, pins: {} };
}
}
function savePins(data) {
localStorage.setItem(PINS_STORAGE_KEY, JSON.stringify(data));
}
function isPinned(name) {
return !!loadPins().pins[name];
}
function addPin(name) {
const data = loadPins();
if (!data.pins[name]) {
data.pins[name] = { pinnedAt: Date.now(), ownedItems: {} };
savePins(data);
}
}
function removePin(name) {
const data = loadPins();
delete data.pins[name];
savePins(data);
}
function setItemOwned(pinName, itemName, owned) {
const data = loadPins();
const pin = data.pins[pinName];
if (!pin) return;
if (owned) {
pin.ownedItems[itemName] = true;
} else {
delete pin.ownedItems[itemName];
}
savePins(data);
}
function setNodesOwned(pinName, paths, owned) {
const data = loadPins();
const pin = data.pins[pinName];
if (!pin) return;
for (const path of paths) {
if (owned) {
pin.ownedItems[path] = true;
} else {
delete pin.ownedItems[path];
}
}
savePins(data);
}
function updatePinsCount() {
const countEl = document.getElementById('pins-count');
if (!countEl) return;
const count = Object.keys(loadPins().pins).length;
countEl.textContent = count;
countEl.style.display = count > 0 ? '' : 'none';
}
// ── Local API helpers ─────────────────────────────────────────────
async function searchItems(query) {
if (query.length < 2) return [];
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return res.json();
}
async function getItemByName(name) {
if (itemCache.has(name)) return itemCache.get(name);
const res = await fetch(`/api/item?name=${encodeURIComponent(name)}`);
const item = await res.json();
if (item) itemCache.set(name, item);
return item;
}
async function getRecipesFor(itemName) {
if (recipeCache.has(itemName)) return recipeCache.get(itemName);
const res = await fetch(`/api/recipes?item=${encodeURIComponent(itemName)}`);
const recipes = await res.json();
recipeCache.set(itemName, recipes);
return recipes;
}
async function getDropsFor(itemName) {
if (dropCache.has(itemName)) return dropCache.get(itemName);
const res = await fetch(`/api/drops?item=${encodeURIComponent(itemName)}`);
const drops = await res.json();
dropCache.set(itemName, drops);
return drops;
}
function parseIngredients(ingsStr) {
// Format: "¦Item Name¦quantity^¦Item Name 2¦quantity2"
if (!ingsStr) return [];
return ingsStr.split('^').map(part => {
const match = part.match(/¦(.+?)¦(\d+)/);
if (!match) return null;
return { name: match[1], quantity: parseInt(match[2]) };
}).filter(Boolean);
}
// ── Icons: local first, wiki fallback ─────────────────────────────
function getIconUrl(imagefile) {
if (!imagefile) return null;
return `/icons/${encodeURIComponent(imagefile)}`;
}
function getWikiIconUrl(imagefile) {
if (!imagefile) return null;
return WIKI_IMG + encodeURIComponent(imagefile);
}
function onIconError(img, imagefile) {
if (img.dataset.triedWiki) {
img.style.display = 'none';
return;
}
img.dataset.triedWiki = '1';
img.src = getWikiIconUrl(imagefile);
}
function getWikiUrl(name) {
return `https://terraria.wiki.gg/wiki/${encodeURIComponent(name.replace(/ /g, '_'))}`;
}
// ── Rarity ────────────────────────────────────────────────────────
const RARITY_COLORS_DARK = {
'-11': '#FF00FF', '-1': '#828282', '0': '#FFFFFF', '1': '#9696FF',
'2': '#96FF96', '3': '#FFC896', '4': '#FF9696', '5': '#FF96FF',
'6': '#D2A0FF', '7': '#96FF0A', '8': '#FFFF00', '9': '#00BFFF',
'10': '#FF2864', '11': '#B428FF', '12': '#FF9614',
};
const RARITY_COLORS_LIGHT = {
'-11': '#9C27B0', '-1': '#616161', '0': '#424242', '1': '#1565C0',
'2': '#2E7D32', '3': '#E65100', '4': '#C62828', '5': '#AD1457',
'6': '#6A1B9A', '7': '#33691E', '8': '#F57F17', '9': '#0277BD',
'10': '#B71C1C', '11': '#6A1B9A', '12': '#E65100',
};
const RARITY_NAMES = {
'-11': 'Quest', '-1': 'Gray', '0': 'White', '1': 'Blue',
'2': 'Green', '3': 'Orange', '4': 'Light Red', '5': 'Pink',
'6': 'Light Purple', '7': 'Lime', '8': 'Yellow', '9': 'Cyan',
'10': 'Red', '11': 'Expert', '12': 'Master',
};
function isLightMode() {
return window.matchMedia('(prefers-color-scheme: light)').matches;
}
function getRarityColor(rarity) {
const colors = isLightMode() ? RARITY_COLORS_LIGHT : RARITY_COLORS_DARK;
const fallback = isLightMode() ? '#424242' : '#FFFFFF';
return colors[rarity] || fallback;
}
function getRarityName(rarity) {
return RARITY_NAMES[rarity] || `Tier ${rarity}`;
}
// ── Utilities ─────────────────────────────────────────────────────
function cleanHtml(html) {
if (!html) return '';
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
}
function makeIcon(imagefile, cls) {
const img = document.createElement('img');
img.src = getIconUrl(imagefile);
img.alt = '';
img.className = cls;
img.onerror = () => onIconError(img, imagefile);
return img;
}
// ── Game tooltip system ───────────────────────────────────────────
const gameTooltipEl = document.getElementById('game-tooltip');
let tooltipShowTimeout = null;
let isTouchDevice = false;
window.addEventListener('touchstart', () => { isTouchDevice = true; }, { once: true });
function showGameTooltip(item, x, y) {
let html = '';
// Name in rarity color (always use dark palette — tooltip bg is always dark)
const rarityColor = RARITY_COLORS_DARK[item.rare] || '#FFFFFF';
html += `
${item.name}
`;
// Stats
const stats = [];
if (item.damage) stats.push(`${item.damage} ${item.damagetype || ''} damage`);
if (item.defense) stats.push(`${item.defense} defense`);
if (item.knockback) stats.push(`${item.knockback} knockback`);
if (item.critical) stats.push(`${item.critical}% critical strike chance`);
if (item.usetime) stats.push(`${item.usetime} use time`);
if (item.velocity) stats.push(`${item.velocity} velocity`);
if (item.pick) stats.push(`${item.pick}% pickaxe power`);
if (item.axe) stats.push(`${item.axe}% axe power`);
if (item.hammer) stats.push(`${item.hammer}% hammer power`);
if (item.mana) stats.push(`Uses ${item.mana} mana`);
if (item.bait) stats.push(`${item.bait}% bait power`);
if (item.fishingpower) stats.push(`${item.fishingpower}% fishing power`);
for (const s of stats) {
html += `${s}
`;
}
// Flavor text
const tooltip = cleanHtml(item.tooltip);
if (tooltip) {
html += `${tooltip}
`;
}
// Sell price
const sell = cleanHtml(item.sell);
if (sell) {
html += `Sell: ${sell}
`;
}
gameTooltipEl.innerHTML = html;
gameTooltipEl.style.display = 'block';
positionTooltip(x, y);
requestAnimationFrame(() => { gameTooltipEl.classList.add('visible'); });
}
function hideGameTooltip() {
clearTimeout(tooltipShowTimeout);
gameTooltipEl.classList.remove('visible');
// Wait for fade-out transition then hide
setTimeout(() => {
if (!gameTooltipEl.classList.contains('visible')) {
gameTooltipEl.style.display = 'none';
}
}, 120);
}
function positionTooltip(x, y) {
const offset = 14;
const rect = gameTooltipEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = x + offset;
let top = y + offset;
if (left + rect.width > vw - 8) {
left = x - rect.width - offset;
}
if (top + rect.height > vh - 8) {
top = y - rect.height - offset;
}
if (left < 8) left = 8;
if (top < 8) top = 8;
gameTooltipEl.style.left = left + 'px';
gameTooltipEl.style.top = top + 'px';
}
function attachItemTooltip(element, item) {
element.addEventListener('mouseenter', (e) => {
if (isTouchDevice) return;
clearTimeout(tooltipShowTimeout);
tooltipShowTimeout = setTimeout(() => {
showGameTooltip(item, e.clientX, e.clientY);
}, 150);
});
element.addEventListener('mousemove', (e) => {
if (isTouchDevice) return;
if (gameTooltipEl.classList.contains('visible')) {
positionTooltip(e.clientX, e.clientY);
}
});
element.addEventListener('mouseleave', () => {
hideGameTooltip();
});
}
window.addEventListener('scroll', hideGameTooltip, true);
// ── Router infrastructure ─────────────────────────────────────────
const searchView = document.getElementById('search-view');
const itemView = document.getElementById('item-view');
const itemHeader = document.getElementById('item-header');
const treeContainer = document.getElementById('tree-container');
const backLink = document.getElementById('back-link');
const pinsView = document.getElementById('pins-view');
let lastSearchQuery = '';
let lastSearchResults = null;
function navigateTo(path, pushState = true) {
hideGameTooltip();
if (pushState) {
history.pushState(null, '', path);
}
handleRoute(location.pathname);
}
function handleRoute(path) {
if (path === '/pins') {
showPinsView();
} else {
const match = path.match(/^\/item\/(.+)$/);
if (match) {
showItemView(decodeURIComponent(match[1]));
} else {
showSearchView();
}
}
}
window.addEventListener('popstate', () => {
hideGameTooltip();
handleRoute(location.pathname);
});
// ── View toggle functions ─────────────────────────────────────────
function showSearchView() {
searchView.style.display = '';
itemView.style.display = 'none';
pinsView.style.display = 'none';
document.title = 'Terraria Companion';
updatePinsCount();
// Restore search state
if (lastSearchQuery) {
searchInput.value = lastSearchQuery;
updateLayout();
if (lastSearchResults) {
renderList(lastSearchResults);
searchStatus.textContent = lastSearchResults.length
? `${lastSearchResults.length} result${lastSearchResults.length > 1 ? 's' : ''}`
: 'No items found';
}
} else {
document.body.classList.remove('has-results');
}
searchInput.focus();
}
function showItemView(itemName) {
// Save search state
lastSearchQuery = searchInput.value.trim();
searchView.style.display = 'none';
itemView.style.display = '';
pinsView.style.display = 'none';
document.body.classList.remove('has-results');
document.title = `${itemName} - Terraria Companion`;
loadItemPage(itemName);
window.scrollTo(0, 0);
}
function showPinsView() {
searchView.style.display = 'none';
itemView.style.display = 'none';
pinsView.style.display = '';
document.body.classList.remove('has-results');
document.title = 'Pinned Recipes - Terraria Companion';
renderPinsList();
window.scrollTo(0, 0);
}
// ── Layout toggle ─────────────────────────────────────────────────
function updateLayout() {
const hasQuery = searchInput.value.trim().length >= 2;
document.body.classList.toggle('has-results', hasQuery);
}
// ── Search ────────────────────────────────────────────────────────
let searchTimeout = null;
const searchInput = document.getElementById('search');
const searchStatus = document.getElementById('search-status');
const itemList = document.getElementById('item-list');
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
const query = searchInput.value.trim();
updateLayout();
if (query.length < 2) {
itemList.innerHTML = '';
searchStatus.textContent = '';
lastSearchResults = null;
return;
}
searchStatus.textContent = 'Searching…';
searchTimeout = setTimeout(() => doSearch(query), 200);
});
async function doSearch(query) {
try {
const items = await searchItems(query);
lastSearchResults = items;
searchStatus.textContent = items.length
? `${items.length} result${items.length > 1 ? 's' : ''}`
: 'No items found';
renderList(items);
} catch (err) {
searchStatus.textContent = 'Search failed';
console.error(err);
}
}
function renderList(items) {
itemList.innerHTML = '';
for (const item of items) {
const li = document.createElement('li');
li.onclick = () => navigateTo('/item/' + encodeURIComponent(item.name));
if (item.imagefile) {
li.appendChild(makeIcon(item.imagefile, 'item-icon-small'));
}
const nameSpan = document.createElement('span');
nameSpan.className = 'item-name';
nameSpan.textContent = item.name;
nameSpan.style.color = getRarityColor(item.rare);
const typeSpan = document.createElement('span');
typeSpan.className = 'item-type-badge';
typeSpan.textContent = (item.type || '').split('^')[0];
li.appendChild(nameSpan);
li.appendChild(typeSpan);
attachItemTooltip(li, item);
itemList.appendChild(li);
}
}
// Re-render results when system color scheme changes
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
if (lastSearchResults) renderList(lastSearchResults);
});
// ── Item page ─────────────────────────────────────────────────────
async function loadItemPage(itemName) {
itemHeader.innerHTML = 'Loading…
';
treeContainer.innerHTML = '';
const item = await getItemByName(itemName);
if (!item) {
itemHeader.innerHTML = `Item not found: "${itemName}"
`;
treeContainer.innerHTML = '';
return;
}
itemHeader.innerHTML = renderItemHeader(item);
const pinBtn = document.getElementById('pin-btn');
if (pinBtn) {
pinBtn.addEventListener('click', () => {
const name = pinBtn.dataset.item;
if (isPinned(name)) {
removePin(name);
pinBtn.textContent = 'Pin';
pinBtn.classList.remove('pinned');
} else {
addPin(name);
pinBtn.textContent = 'Unpin';
pinBtn.classList.add('pinned');
}
updatePinsCount();
});
}
treeContainer.innerHTML = 'Loading crafting tree…
';
try {
const recipes = await getRecipesFor(item.name);
if (!recipes.length) {
const drops = await getDropsFor(item.name);
if (drops.length) {
treeContainer.innerHTML = '';
const section = document.createElement('div');
section.className = 'drops-section';
const title = document.createElement('div');
title.className = 'drops-title';
title.textContent = '💀 Dropped by';
section.appendChild(title);
for (const drop of drops) {
const entry = document.createElement('div');
entry.className = 'drop-entry';
const name = document.createElement('span');
name.className = 'drop-source-name';
name.textContent = drop.nameraw || 'Unknown';
entry.appendChild(name);
const qty = cleanHtml(drop.quantity);
const rate = cleanHtml(drop.rate);
if (qty || rate) {
const details = document.createElement('span');
details.className = 'drop-details';
const parts = [];
if (qty) parts.push(`Qty: ${qty}`);
if (rate) parts.push(rate);
details.textContent = parts.join(' · ');
entry.appendChild(details);
}
if (drop.expert === '1') {
const badge = document.createElement('span');
badge.className = 'drop-badge expert';
badge.textContent = 'Expert';
entry.appendChild(badge);
}
if (drop.master === '1') {
const badge = document.createElement('span');
badge.className = 'drop-badge master';
badge.textContent = 'Master';
entry.appendChild(badge);
}
section.appendChild(entry);
}
treeContainer.appendChild(section);
} else {
treeContainer.innerHTML = 'This item has no crafting recipe or known drop source.
';
}
return;
}
treeContainer.innerHTML = '';
const pinData = loadPins().pins[item.name];
const ownedItems = pinData ? pinData.ownedItems : {};
for (const recipe of recipes) {
const recipeDiv = document.createElement('div');
recipeDiv.className = 'recipe-block';
const stationDiv = document.createElement('div');
stationDiv.className = 'crafting-station';
stationDiv.textContent = `⚒️ ${recipe.station || 'By Hand'}`;
recipeDiv.appendChild(stationDiv);
const ingredients = parseIngredients(recipe.ings);
const tree = document.createElement('div');
tree.className = 'tree-root';
const branches = await Promise.all(
ingredients.map(ing => buildTreeNodeWithOwnership(ing.name, ing.quantity, new Set([item.name.toLowerCase()]), item.name, ownedItems, ing.name))
);
branches.forEach(b => tree.appendChild(b));
recipeDiv.appendChild(tree);
treeContainer.appendChild(recipeDiv);
}
} catch (err) {
treeContainer.innerHTML = `Failed to load crafting tree: ${err.message}
`;
console.error(err);
}
}
function renderItemHeader(item) {
const rarityColor = getRarityColor(item.rare);
const tooltip = cleanHtml(item.tooltip);
const sell = cleanHtml(item.sell);
const iconUrl = getIconUrl(item.imagefile);
const wikiIconUrl = getWikiIconUrl(item.imagefile);
const pinned = isPinned(item.name);
let statsHtml = '';
if (item.damage) statsHtml += `⚔️ ${item.damage} ${item.damagetype || ''} damage`;
if (item.knockback) statsHtml += `💥 ${item.knockback} knockback`;
if (item.critical) statsHtml += `🎯 ${item.critical}% crit`;
if (item.usetime) statsHtml += `⏱️ ${item.usetime} use time`;
if (item.velocity) statsHtml += `🚀 ${item.velocity} velocity`;
if (item.defense) statsHtml += `🛡️ ${item.defense} defense`;
if (sell) statsHtml += `💰 ${sell}`;
return `
`;
}
async function buildTreeNode(itemName, quantity, ancestors = new Set()) {
const container = document.createElement('div');
container.className = 'tree-node';
const item = await getItemByName(itemName);
const imagefile = item ? item.imagefile : null;
const rarityColor = item ? getRarityColor(item.rare) : (isLightMode() ? '#424242' : '#FFFFFF');
const itemDiv = document.createElement('div');
itemDiv.className = 'tree-item';
if (imagefile) {
itemDiv.appendChild(makeIcon(imagefile, 'tree-icon'));
}
const qtySpan = document.createElement('span');
qtySpan.className = 'quantity';
qtySpan.textContent = `${quantity}x`;
const nameSpan = document.createElement('span');
nameSpan.className = 'item-name';
nameSpan.textContent = itemName;
nameSpan.style.color = rarityColor;
nameSpan.style.cursor = 'pointer';
nameSpan.onclick = (e) => {
e.stopPropagation();
navigateTo('/item/' + encodeURIComponent(itemName));
};
itemDiv.appendChild(qtySpan);
itemDiv.appendChild(nameSpan);
container.appendChild(itemDiv);
if (item) attachItemTooltip(itemDiv, item);
// Sub-recipes (skip if this item is already an ancestor to prevent infinite loops)
const nameLower = itemName.toLowerCase();
const recipes = (!ancestors.has(nameLower)) ? await getRecipesFor(itemName) : [];
if (recipes.length > 0) {
const recipe = recipes[0];
const ingredients = parseIngredients(recipe.ings);
const stationTag = document.createElement('span');
stationTag.className = 'tree-station';
stationTag.textContent = `⚒️ ${recipe.station || 'By Hand'}`;
itemDiv.appendChild(stationTag);
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children';
const nextAncestors = new Set(ancestors);
nextAncestors.add(nameLower);
const branches = await Promise.all(
ingredients.map(ing => buildTreeNode(ing.name, ing.quantity * quantity, nextAncestors))
);
branches.forEach(b => childrenDiv.appendChild(b));
container.appendChild(childrenDiv);
const toggle = document.createElement('span');
toggle.className = 'toggle';
toggle.textContent = '▼';
itemDiv.prepend(toggle);
toggle.onclick = (e) => {
e.stopPropagation();
const collapsed = childrenDiv.classList.toggle('collapsed');
toggle.textContent = collapsed ? '▶' : '▼';
};
} else {
const leafIcon = document.createElement('span');
leafIcon.className = 'leaf-icon';
leafIcon.textContent = '🔸';
itemDiv.prepend(leafIcon);
}
return container;
}
// ── Pins view rendering ───────────────────────────────────────────
async function renderPinsList() {
const pinsList = document.getElementById('pins-list');
const pinsEmpty = document.getElementById('pins-empty');
const data = loadPins();
const entries = Object.entries(data.pins)
.sort((a, b) => b[1].pinnedAt - a[1].pinnedAt);
pinsList.innerHTML = '';
if (entries.length === 0) {
pinsEmpty.style.display = '';
return;
}
pinsEmpty.style.display = 'none';
for (const [name, pin] of entries) {
const card = document.createElement('div');
card.className = 'pin-card';
const item = await getItemByName(name);
const imagefile = item ? item.imagefile : null;
// Header
const header = document.createElement('div');
header.className = 'pin-card-header';
if (imagefile) {
header.appendChild(makeIcon(imagefile, 'item-icon-small'));
}
const nameEl = document.createElement('span');
nameEl.className = 'pin-card-name';
nameEl.textContent = name;
if (item) nameEl.style.color = getRarityColor(item.rare);
nameEl.onclick = () => navigateTo('/item/' + encodeURIComponent(name));
header.appendChild(nameEl);
const unpinBtn = document.createElement('button');
unpinBtn.className = 'unpin-btn';
unpinBtn.textContent = 'Unpin';
unpinBtn.onclick = () => {
removePin(name);
card.remove();
updatePinsCount();
const remaining = document.querySelectorAll('.pin-card');
if (remaining.length === 0) pinsEmpty.style.display = '';
};
header.appendChild(unpinBtn);
if (item) attachItemTooltip(header, item);
card.appendChild(header);
// Body: crafting tree with checkboxes
const body = document.createElement('div');
body.className = 'pin-card-body';
const recipes = await getRecipesFor(name);
if (recipes.length > 0) {
const recipe = recipes[0];
const stationDiv = document.createElement('div');
stationDiv.className = 'crafting-station';
stationDiv.textContent = `⚒️ ${recipe.station || 'By Hand'}`;
body.appendChild(stationDiv);
const ingredients = parseIngredients(recipe.ings);
const tree = document.createElement('div');
tree.className = 'tree-root';
const branches = await Promise.all(
ingredients.map(ing =>
buildTreeNodeWithOwnership(ing.name, ing.quantity, new Set([name.toLowerCase()]), name, pin.ownedItems, ing.name)
)
);
branches.forEach(b => tree.appendChild(b));
body.appendChild(tree);
// Progress bar
const leafPaths = collectLeafPaths(tree);
const progressDiv = document.createElement('div');
progressDiv.className = 'pin-progress';
progressDiv.dataset.pinName = name;
updateProgressText(progressDiv, leafPaths, pin.ownedItems);
body.appendChild(progressDiv);
} else {
const noRecipe = document.createElement('div');
noRecipe.className = 'no-recipe';
noRecipe.textContent = 'No crafting recipe found.';
body.appendChild(noRecipe);
}
card.appendChild(body);
pinsList.appendChild(card);
}
}
function collectLeafPaths(treeRoot) {
const leafPaths = new Set();
treeRoot.querySelectorAll('.owned-checkbox').forEach(cb => {
const treeNode = cb.closest('.tree-node');
if (!treeNode.querySelector('.tree-children')) {
leafPaths.add(cb.dataset.nodePath);
}
});
return leafPaths;
}
function updateProgressText(progressDiv, leafPaths, ownedItems) {
const total = leafPaths.size;
let owned = 0;
leafPaths.forEach(path => { if (ownedItems[path]) owned++; });
progressDiv.textContent = `${owned} / ${total} base materials owned`;
}
async function buildTreeNodeWithOwnership(itemName, quantity, ancestors, pinName, ownedItems, nodePath) {
const container = document.createElement('div');
container.className = 'tree-node';
const item = await getItemByName(itemName);
const imagefile = item ? item.imagefile : null;
const rarityColor = item ? getRarityColor(item.rare) : (isLightMode() ? '#424242' : '#FFFFFF');
const itemDiv = document.createElement('div');
itemDiv.className = 'tree-item';
if (imagefile) {
itemDiv.appendChild(makeIcon(imagefile, 'tree-icon'));
}
const qtySpan = document.createElement('span');
qtySpan.className = 'quantity';
qtySpan.textContent = `${quantity}x`;
const nameSpan = document.createElement('span');
nameSpan.className = 'item-name';
nameSpan.textContent = itemName;
nameSpan.style.color = rarityColor;
nameSpan.style.cursor = 'pointer';
nameSpan.onclick = (e) => {
e.stopPropagation();
navigateTo('/item/' + encodeURIComponent(itemName));
};
itemDiv.appendChild(qtySpan);
itemDiv.appendChild(nameSpan);
container.appendChild(itemDiv);
if (item) attachItemTooltip(itemDiv, item);
const nameLower = itemName.toLowerCase();
const recipes = (!ancestors.has(nameLower)) ? await getRecipesFor(itemName) : [];
if (recipes.length > 0) {
const recipe = recipes[0];
const ingredients = parseIngredients(recipe.ings);
const stationTag = document.createElement('span');
stationTag.className = 'tree-station';
stationTag.textContent = `⚒️ ${recipe.station || 'By Hand'}`;
itemDiv.appendChild(stationTag);
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children';
const nextAncestors = new Set(ancestors);
nextAncestors.add(nameLower);
const branches = await Promise.all(
ingredients.map(ing =>
buildTreeNodeWithOwnership(ing.name, ing.quantity * quantity, nextAncestors, pinName, ownedItems, nodePath + '>' + ing.name)
)
);
branches.forEach(b => childrenDiv.appendChild(b));
container.appendChild(childrenDiv);
const toggle = document.createElement('span');
toggle.className = 'toggle';
toggle.textContent = '▼';
itemDiv.prepend(toggle);
toggle.onclick = (e) => {
e.stopPropagation();
const collapsed = childrenDiv.classList.toggle('collapsed');
toggle.textContent = collapsed ? '▶' : '▼';
};
} else {
const leafIcon = document.createElement('span');
leafIcon.className = 'leaf-icon';
leafIcon.textContent = '🔸';
itemDiv.prepend(leafIcon);
}
// Checkbox on every node, at the far right
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'owned-checkbox';
checkbox.dataset.nodePath = nodePath;
checkbox.checked = !!ownedItems[nodePath];
if (checkbox.checked) {
container.classList.add('owned');
// Auto-collapse owned nodes that have children
const ownedChildren = container.querySelector(':scope > .tree-children');
if (ownedChildren) {
ownedChildren.classList.add('collapsed');
const ownedToggle = container.querySelector(':scope > .tree-item > .toggle');
if (ownedToggle) ownedToggle.textContent = '▶';
}
}
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
const isOwned = checkbox.checked;
// Auto-pin if checking and not already pinned
if (isOwned && !isPinned(pinName)) {
addPin(pinName);
const pinBtn = document.getElementById('pin-btn');
if (pinBtn) {
pinBtn.textContent = 'Unpin';
pinBtn.classList.add('pinned');
}
updatePinsCount();
}
// Collect this node's path + all descendant paths
const paths = [nodePath];
container.classList.toggle('owned', isOwned);
container.querySelectorAll('.owned-checkbox').forEach(cb => {
if (cb === checkbox) return;
paths.push(cb.dataset.nodePath);
cb.checked = isOwned;
cb.closest('.tree-node').classList.toggle('owned', isOwned);
});
setNodesOwned(pinName, paths, isOwned);
// Update progress (pins view)
const card = container.closest('.pin-card');
if (card) {
const progressDiv = card.querySelector('.pin-progress');
if (progressDiv) {
const treeRoot = card.querySelector('.tree-root');
const currentOwnedItems = loadPins().pins[pinName]?.ownedItems || {};
const leafPaths = collectLeafPaths(treeRoot);
updateProgressText(progressDiv, leafPaths, currentOwnedItems);
}
}
});
itemDiv.appendChild(checkbox);
return container;
}
// ── Back link & keyboard ──────────────────────────────────────────
backLink.addEventListener('click', (e) => {
e.preventDefault();
if (history.length > 1) {
history.back();
} else {
navigateTo('/');
}
});
document.getElementById('pins-link').addEventListener('click', (e) => {
e.preventDefault();
navigateTo('/pins');
});
document.getElementById('pins-back-link').addEventListener('click', (e) => {
e.preventDefault();
if (history.length > 1) {
history.back();
} else {
navigateTo('/');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (itemView.style.display !== 'none' || pinsView.style.display !== 'none') {
if (history.length > 1) {
history.back();
} else {
navigateTo('/');
}
}
}
});
// ── Initialization ────────────────────────────────────────────────
fetch('/api/splashes')
.then(r => r.json())
.then(splashes => {
if (splashes.length) {
document.querySelector('.subtitle').textContent = splashes[Math.floor(Math.random() * splashes.length)];
}
})
.catch(() => {});
updatePinsCount();
handleRoute(location.pathname);