const WIKI_IMG = 'https://terraria.wiki.gg/wiki/Special:FilePath/'; const FAVS_STORAGE_KEY = 'terraria-favourite-recipes'; const DEFAULT_META = { title: 'Terraria Companion', description: 'Search Terraria items, explore interactive crafting trees, track favourite recipes, and find enemy drop sources. Data from terraria.wiki.gg.', url: '/', image: '/images/logo.png', type: 'website', }; function setMetaContent(id, value) { const el = document.getElementById(id); if (el) el.setAttribute('content', value); } function updatePageMeta({ title, description, url, image, type = 'website' }) { document.title = title; const origin = location.origin; const fullUrl = url.startsWith('http') ? url : origin + url; const fullImage = image.startsWith('http') ? image : origin + image; setMetaContent('meta-description', description); setMetaContent('og-title', title); setMetaContent('og-description', description); setMetaContent('og-url', fullUrl); setMetaContent('og-image', fullImage); setMetaContent('og-type', type); setMetaContent('twitter-title', title); setMetaContent('twitter-description', description); setMetaContent('twitter-image', fullImage); const canonical = document.getElementById('canonical'); if (canonical) canonical.setAttribute('href', fullUrl); } function itemDescription(item) { const tooltip = (item.tooltip || '').replace(/<[^>]*>/g, '').trim(); if (tooltip) { return tooltip.length > 160 ? tooltip.slice(0, 157) + '...' : tooltip; } return `Crafting recipes, ingredients, stats, and drop sources for ${item.name} in Terraria.`; } function itemIconPath(imagefile) { return imagefile ? '/icons/' + encodeURIComponent(imagefile) : '/images/logo.png'; } // In-memory caches const itemCache = new Map(); const recipeCache = new Map(); const dropCache = new Map(); // ── Favourites localStorage helpers ─────────────────────────────── function loadFavourites() { try { let raw = localStorage.getItem(FAVS_STORAGE_KEY); if (!raw) { // Migrate from old pins storage raw = localStorage.getItem('terraria-pinned-recipes'); if (raw) { const old = JSON.parse(raw); if (old.version === 1 && old.pins) { const migrated = { version: 1, favourites: {} }; for (const [k, v] of Object.entries(old.pins)) { migrated.favourites[k] = { favouritedAt: v.pinnedAt || Date.now(), ownedItems: v.ownedItems || {} }; } saveFavourites(migrated); localStorage.removeItem('terraria-pinned-recipes'); return migrated; } } return { version: 1, favourites: {} }; } const data = JSON.parse(raw); if (data.version === 1) return data; return { version: 1, favourites: {} }; } catch { return { version: 1, favourites: {} }; } } function saveFavourites(data) { localStorage.setItem(FAVS_STORAGE_KEY, JSON.stringify(data)); } function isFavourite(name) { return !!loadFavourites().favourites[name]; } function addFavourite(name) { const data = loadFavourites(); if (!data.favourites[name]) { data.favourites[name] = { favouritedAt: Date.now(), ownedItems: {} }; saveFavourites(data); } } function removeFavourite(name) { const data = loadFavourites(); delete data.favourites[name]; saveFavourites(data); } function setItemOwned(favName, itemName, owned) { const data = loadFavourites(); const fav = data.favourites[favName]; if (!fav) return; if (owned) { fav.ownedItems[itemName] = true; } else { delete fav.ownedItems[itemName]; } saveFavourites(data); } function setNodesOwned(favName, paths, owned) { const data = loadFavourites(); const fav = data.favourites[favName]; if (!fav) return; for (const path of paths) { if (owned) { fav.ownedItems[path] = true; } else { delete fav.ownedItems[path]; } } saveFavourites(data); } function updateFavouritesCount() { const countEl = document.getElementById('favs-count'); if (!countEl) return; const count = Object.keys(loadFavourites().favourites).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; } const usedInCache = new Map(); async function getRecipesUsing(itemName) { if (usedInCache.has(itemName)) return usedInCache.get(itemName); const res = await fetch(`/api/used-in?item=${encodeURIComponent(itemName)}`); const recipes = await res.json(); usedInCache.set(itemName, recipes); return recipes; } 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 favsView = document.getElementById('favs-view'); let lastSearchQuery = ''; let lastSearchResults = null; let kbSelectedIndex = -1; let kbItemViewIndex = -1; let kbFavsIndex = -1; function navigateTo(path, pushState = true) { hideGameTooltip(); if (pushState) { history.pushState(null, '', path); } handleRoute(location.pathname); } // ── Keyboard navigation helpers ──────────────────────────────────── function clearKbSelection() { document.querySelectorAll('.kb-selected').forEach(el => el.classList.remove('kb-selected')); } function highlightSearchResult(index) { clearKbSelection(); const items = itemList.querySelectorAll('li'); if (index < 0 || index >= items.length) return; items[index].classList.add('kb-selected'); items[index].scrollIntoView({ block: 'nearest' }); } function getItemViewFocusables() { const els = []; treeContainer.querySelectorAll('.tree-item .item-name').forEach(span => { // Only include visible items (not inside collapsed children) const closestCollapsed = span.closest('.tree-children.collapsed'); if (!closestCollapsed) els.push(span); }); treeContainer.querySelectorAll('.recipe-card').forEach(card => { els.push(card); }); return els; } function highlightItemViewElement(index) { clearKbSelection(); const els = getItemViewFocusables(); if (els.length === 0) return; // Wrap around if (index < 0) index = els.length - 1; if (index >= els.length) index = 0; kbItemViewIndex = index; const el = els[index]; if (el.classList.contains('recipe-card')) { el.classList.add('kb-selected'); } else { el.closest('.tree-item').classList.add('kb-selected'); } el.scrollIntoView({ block: 'nearest' }); } function getFavsCards() { return document.querySelectorAll('#favs-grid .fav-grid-card'); } function getFavsColumns() { const grid = document.getElementById('favs-grid'); if (!grid) return 1; const style = getComputedStyle(grid); const cols = style.getPropertyValue('grid-template-columns').split(' ').length; return cols || 1; } function highlightFavsCard(index) { clearKbSelection(); const cards = getFavsCards(); if (cards.length === 0) return; if (index < 0) index = cards.length - 1; if (index >= cards.length) index = cards.length - 1; kbFavsIndex = index; cards[index].classList.add('kb-selected'); cards[index].scrollIntoView({ block: 'nearest' }); } function activateFavsCard(index) { const cards = getFavsCards(); if (index < 0 || index >= cards.length) return; cards[index].click(); } function activateItemViewElement(index) { const els = getItemViewFocusables(); if (index < 0 || index >= els.length) return; els[index].click(); } function handleRoute(path) { if (path === '/favourites') { showFavsView(); } 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() { kbSelectedIndex = -1; kbItemViewIndex = -1; clearKbSelection(); updateKeybindsHelp(); searchView.style.display = ''; itemView.style.display = 'none'; favsView.style.display = 'none'; updatePageMeta(DEFAULT_META); updateFavouritesCount(); // 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) { kbSelectedIndex = -1; kbItemViewIndex = -1; clearKbSelection(); updateKeybindsHelp(); // Save search state lastSearchQuery = searchInput.value.trim(); searchView.style.display = 'none'; itemView.style.display = ''; favsView.style.display = 'none'; document.body.classList.remove('has-results'); updatePageMeta({ title: `${itemName} - Terraria Companion`, description: `Crafting recipes, ingredients, stats, and drop sources for ${itemName} in Terraria.`, url: '/item/' + encodeURIComponent(itemName), image: '/images/logo.png', type: 'article', }); loadItemPage(itemName); window.scrollTo(0, 0); } function showFavsView() { kbSelectedIndex = -1; kbItemViewIndex = -1; kbFavsIndex = -1; clearKbSelection(); updateKeybindsHelp(); searchView.style.display = 'none'; itemView.style.display = 'none'; favsView.style.display = ''; document.body.classList.remove('has-results'); updatePageMeta({ title: 'Favourites - Terraria Companion', description: 'Your saved Terraria crafting recipes and progress tracker.', url: '/favourites', image: '/images/logo.png', type: 'website', }); renderFavsList(); 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; kbSelectedIndex = -1; 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) { kbSelectedIndex = -1; clearKbSelection(); 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); }); // ── Recipe card builder ─────────────────────────────────────────── async function makeRecipeCard(recipe) { const card = document.createElement('div'); card.className = 'recipe-card'; card.tabIndex = 0; card.onclick = () => navigateTo('/item/' + encodeURIComponent(recipe.result)); const resultItem = await getItemByName(recipe.result); const imagefile = resultItem ? resultItem.imagefile : null; if (imagefile) { card.appendChild(makeIcon(imagefile, 'recipe-card-icon')); } const nameEl = document.createElement('span'); nameEl.className = 'recipe-card-name'; nameEl.textContent = recipe.result; if (resultItem) nameEl.style.color = getRarityColor(resultItem.rare); card.appendChild(nameEl); const stationEl = document.createElement('span'); stationEl.className = 'recipe-card-station'; stationEl.textContent = recipe.station || 'By Hand'; card.appendChild(stationEl); if (recipe.amount && parseInt(recipe.amount) > 1) { const amountEl = document.createElement('span'); amountEl.className = 'recipe-card-amount'; amountEl.textContent = recipe.amount + 'x'; card.appendChild(amountEl); } if (resultItem) attachItemTooltip(card, resultItem); return card; } // ── 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); updatePageMeta({ title: `${item.name} - Terraria Companion`, description: itemDescription(item), url: '/item/' + encodeURIComponent(item.name), image: itemIconPath(item.imagefile), type: 'article', }); const favBtn = document.getElementById('fav-btn'); if (favBtn) { favBtn.addEventListener('click', () => { toggleFavourite(favBtn); }); } 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.
'; } } else { treeContainer.innerHTML = ''; const favData = loadFavourites().favourites[item.name]; const ownedItems = favData ? favData.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); } } // "Used In" section — show recipes that use this item as an ingredient const usedInRecipes = await getRecipesUsing(item.name); if (usedInRecipes && usedInRecipes.length > 0) { const section = document.createElement('div'); section.className = 'recipe-section'; const sectionTitle = document.createElement('div'); sectionTitle.className = 'recipe-section-title'; sectionTitle.textContent = 'Used In'; section.appendChild(sectionTitle); const grid = document.createElement('div'); grid.className = 'recipe-card-grid'; const cards = await Promise.all(usedInRecipes.map(r => makeRecipeCard(r))); cards.forEach(c => grid.appendChild(c)); section.appendChild(grid); treeContainer.appendChild(section); } } catch (err) { treeContainer.innerHTML = `
Failed to load crafting tree: ${err.message}
`; console.error(err); } } function toggleFavourite(favBtn) { const name = favBtn.dataset.item; if (isFavourite(name)) { removeFavourite(name); favBtn.textContent = 'Fav'; favBtn.classList.remove('favourited'); // Reset all owned items in the tree treeContainer.querySelectorAll('.tree-node.owned > .tree-children.collapsed').forEach(children => { children.classList.remove('collapsed'); const toggle = children.parentElement.querySelector(':scope > .tree-item > .toggle'); if (toggle) toggle.textContent = '▼'; }); treeContainer.querySelectorAll('.owned-checkbox').forEach(cb => { cb.checked = false; cb.closest('.tree-node').classList.remove('owned'); }); } else { addFavourite(name); favBtn.textContent = 'Unfav'; favBtn.classList.add('favourited'); } updateFavouritesCount(); } 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 favourited = isFavourite(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; } // ── Favourites view rendering ───────────────────────────────────── async function renderFavsList() { const favsList = document.getElementById('favs-list'); const favsEmpty = document.getElementById('favs-empty'); const data = loadFavourites(); const entries = Object.entries(data.favourites) .sort((a, b) => a[0].localeCompare(b[0])); favsList.innerHTML = ''; if (entries.length === 0) { favsEmpty.style.display = ''; return; } favsEmpty.style.display = 'none'; // Create grid container const grid = document.createElement('div'); grid.id = 'favs-grid'; grid.className = 'favs-grid'; for (const [name] of entries) { const card = document.createElement('div'); card.className = 'fav-grid-card'; card.tabIndex = 0; card.dataset.itemName = name; card.onclick = () => navigateTo('/item/' + encodeURIComponent(name)); const item = await getItemByName(name); const imagefile = item ? item.imagefile : null; if (imagefile) { card.appendChild(makeIcon(imagefile, 'fav-grid-icon')); } const nameEl = document.createElement('span'); nameEl.className = 'fav-grid-name'; nameEl.textContent = name; if (item) nameEl.style.color = getRarityColor(item.rare); card.appendChild(nameEl); if (item) attachItemTooltip(card, item); grid.appendChild(card); } favsList.appendChild(grid); } 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, favName, 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, favName, 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-favourite if checking and not already favourited if (isOwned && !isFavourite(favName)) { addFavourite(favName); const favBtn = document.getElementById('fav-btn'); if (favBtn) { favBtn.textContent = 'Unfav'; favBtn.classList.add('favourited'); } updateFavouritesCount(); } // 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(favName, paths, isOwned); // Update progress (favourites view) const card = container.closest('.fav-card'); if (card) { const progressDiv = card.querySelector('.fav-progress'); if (progressDiv) { const treeRoot = card.querySelector('.tree-root'); const currentOwnedItems = loadFavourites().favourites[favName]?.ownedItems || {}; const leafPaths = collectLeafPaths(treeRoot); updateProgressText(progressDiv, leafPaths, currentOwnedItems); } } }); itemDiv.appendChild(checkbox); return container; } // ── Keybinds help overlay ───────────────────────────────────────── const keybindsHelpEl = document.getElementById('keybinds-help'); const keybindsHintEl = document.getElementById('keybinds-hint'); let keybindsHelpVisible = false; function getKeybindsForView() { const inSearchView = searchView.style.display !== 'none'; const inItemView = itemView.style.display !== 'none'; const inFavsView = favsView.style.display !== 'none'; const binds = []; if (inSearchView) { binds.push(['/', 'Focus search']); binds.push(['F', 'Favourites']); binds.push(['\u2191\u2193', 'Navigate results']); binds.push(['Enter', 'Select result']); binds.push(['Esc', 'Clear selection']); } else if (inItemView) { binds.push(['\u2191\u2193', 'Navigate items']); binds.push(['Enter', 'Open item']); binds.push(['Space', 'Toggle owned']); binds.push(['F', 'Toggle favourite']); binds.push(['\u2190', 'Go back']); binds.push(['/', 'Search']); binds.push(['Esc', 'Go back']); } else if (inFavsView) { binds.push(['\u2190\u2191\u2193\u2192', 'Navigate']); binds.push(['Enter', 'Open item']); binds.push(['Esc', 'Go back']); binds.push(['/', 'Search']); } binds.push(['?', 'Toggle help']); return binds; } function renderKeybindsHelp() { const binds = getKeybindsForView(); let html = '
Keyboard shortcuts
'; for (const [key, desc] of binds) { html += `
${key}${desc}
`; } keybindsHelpEl.innerHTML = html; } function toggleKeybindsHelp() { keybindsHelpVisible = !keybindsHelpVisible; if (keybindsHelpVisible) renderKeybindsHelp(); keybindsHelpEl.classList.toggle('visible', keybindsHelpVisible); keybindsHintEl.classList.toggle('hidden', keybindsHelpVisible); } keybindsHintEl.addEventListener('click', () => { toggleKeybindsHelp(); }); function updateKeybindsHelp() { if (!keybindsHelpVisible) return; renderKeybindsHelp(); } // ── Back link & keyboard ────────────────────────────────────────── backLink.addEventListener('click', (e) => { e.preventDefault(); if (history.length > 1) { history.back(); } else { navigateTo('/'); } }); document.getElementById('favs-link').addEventListener('click', (e) => { e.preventDefault(); navigateTo('/favourites'); }); document.getElementById('favs-back-link').addEventListener('click', (e) => { e.preventDefault(); if (history.length > 1) { history.back(); } else { navigateTo('/'); } }); document.addEventListener('keydown', (e) => { const inSearchInput = document.activeElement === searchInput; const inSearchView = searchView.style.display !== 'none'; const inItemView = itemView.style.display !== 'none'; const inFavsView = favsView.style.display !== 'none'; // `?` — toggle keybinds help if (e.key === '?' && !inSearchInput) { e.preventDefault(); toggleKeybindsHelp(); return; } // Global `/` — focus search input if (e.key === '/' && !inSearchInput) { e.preventDefault(); if (!inSearchView) navigateTo('/'); searchInput.focus(); return; } // `F` — toggle favourite (item view) or navigate to favourites if ((e.key === 'f' || e.key === 'F') && !inSearchInput) { e.preventDefault(); if (inItemView) { const favBtn = document.getElementById('fav-btn'); if (favBtn) { toggleFavourite(favBtn); } } else { navigateTo('/favourites'); } return; } // Escape if (e.key === 'Escape') { if (inSearchInput) { e.preventDefault(); searchInput.blur(); return; } if (inSearchView && kbSelectedIndex >= 0) { e.preventDefault(); kbSelectedIndex = -1; clearKbSelection(); searchInput.focus(); return; } if (inFavsView && kbFavsIndex >= 0) { e.preventDefault(); kbFavsIndex = -1; clearKbSelection(); return; } if (inItemView || inFavsView) { e.preventDefault(); if (history.length > 1) { history.back(); } else { navigateTo('/'); } return; } } // Search view arrow navigation if (inSearchView) { const items = itemList.querySelectorAll('li'); if (e.key === 'ArrowDown' && items.length > 0) { e.preventDefault(); if (kbSelectedIndex < items.length - 1) { kbSelectedIndex++; highlightSearchResult(kbSelectedIndex); searchInput.blur(); } return; } if (e.key === 'ArrowUp' && items.length > 0) { e.preventDefault(); if (kbSelectedIndex > 0) { kbSelectedIndex--; highlightSearchResult(kbSelectedIndex); } else if (kbSelectedIndex === 0) { kbSelectedIndex = -1; clearKbSelection(); searchInput.focus(); } return; } if (e.key === 'Enter' && kbSelectedIndex >= 0 && kbSelectedIndex < items.length) { e.preventDefault(); items[kbSelectedIndex].click(); return; } } // Item view arrow navigation if (inItemView) { if (e.key === 'ArrowDown') { e.preventDefault(); highlightItemViewElement(kbItemViewIndex + 1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); highlightItemViewElement(kbItemViewIndex - 1); return; } if (e.key === 'Enter' && kbItemViewIndex >= 0) { e.preventDefault(); activateItemViewElement(kbItemViewIndex); return; } if (e.key === ' ' && kbItemViewIndex >= 0) { e.preventDefault(); const els = getItemViewFocusables(); const el = els[kbItemViewIndex]; if (el && el.classList.contains('item-name')) { const treeNode = el.closest('.tree-node'); if (treeNode) { const checkbox = treeNode.querySelector(':scope > .tree-item > .owned-checkbox'); if (checkbox) { checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change', { bubbles: true })); // Collapse/uncollapse children based on owned state const children = treeNode.querySelector(':scope > .tree-children'); if (children) { const toggle = treeNode.querySelector(':scope > .tree-item > .toggle'); if (checkbox.checked && !children.classList.contains('collapsed')) { children.classList.add('collapsed'); if (toggle) toggle.textContent = '▶'; } else if (!checkbox.checked && children.classList.contains('collapsed')) { children.classList.remove('collapsed'); if (toggle) toggle.textContent = '▼'; } } } } } return; } if (e.key === 'ArrowLeft') { e.preventDefault(); if (history.length > 1) { history.back(); } else { navigateTo('/'); } return; } } // Favourites view keyboard navigation if (inFavsView) { const cards = getFavsCards(); const cols = getFavsColumns(); if (e.key === 'ArrowDown' && cards.length > 0) { e.preventDefault(); highlightFavsCard(kbFavsIndex < 0 ? 0 : Math.min(kbFavsIndex + cols, cards.length - 1)); return; } if (e.key === 'ArrowUp' && cards.length > 0) { e.preventDefault(); if (kbFavsIndex <= 0) { kbFavsIndex = -1; clearKbSelection(); } else { highlightFavsCard(Math.max(kbFavsIndex - cols, 0)); } return; } if (e.key === 'ArrowRight' && cards.length > 0) { e.preventDefault(); highlightFavsCard(kbFavsIndex < 0 ? 0 : Math.min(kbFavsIndex + 1, cards.length - 1)); return; } if (e.key === 'ArrowLeft' && cards.length > 0) { e.preventDefault(); if (kbFavsIndex <= 0) { kbFavsIndex = -1; clearKbSelection(); } else { highlightFavsCard(kbFavsIndex - 1); } return; } if (e.key === 'Enter' && kbFavsIndex >= 0) { e.preventDefault(); activateFavsCard(kbFavsIndex); return; } } }); document.addEventListener('mousedown', () => { kbSelectedIndex = -1; kbItemViewIndex = -1; kbFavsIndex = -1; clearKbSelection(); }); // ── 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(() => {}); fetch('/api/version') .then(r => r.json()) .then(data => { const el = document.getElementById('version-info'); if (el && data.version) { el.textContent = data.version; } }) .catch(() => {}); updateFavouritesCount(); handleRoute(location.pathname); const initialQuery = new URLSearchParams(location.search).get('q'); if (initialQuery && initialQuery.length >= 2) { searchInput.value = initialQuery; updateLayout(); searchItems(initialQuery).then(results => { lastSearchQuery = initialQuery; lastSearchResults = results; renderList(results); searchStatus.textContent = results.length ? `${results.length} result${results.length > 1 ? 's' : ''}` : 'No items found'; }); }