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 `
${item.name}

${item.name} 🔗

★ ${getRarityName(item.rare)} Rarity ${(item.type || '').replace(/\^/g, ', ')} ${item.hardmode === '1' ? 'Hardmode' : ''}
${tooltip ? `
${tooltip}
` : ''}
${statsHtml}
`; } 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);