terraria-companion/public/script.js
Felipe M. 87b27235d0
Some checks failed
CI / format (push) Successful in 1m59s
CI / goreleaser-lint (push) Successful in 10s
CI / lint (push) Failing after 1m27s
CI / build (push) Successful in 2m14s
Release / release (push) Successful in 2m43s
fix: add SEO metadata, accessibility tweaks, and OCI version labels
Serve robots.txt and sitemap.xml with server-side meta injection for item pages, improve frontend semantics and dynamic meta tags, and stamp release container images with org.opencontainers.image.version.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:05:52 +02:00

1491 lines
47 KiB
JavaScript

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 += `<div class="game-tooltip-name" style="color:${rarityColor}">${item.name}</div>`;
// 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 += `<div class="game-tooltip-stat">${s}</div>`;
}
// Flavor text
const tooltip = cleanHtml(item.tooltip);
if (tooltip) {
html += `<div class="game-tooltip-flavor">${tooltip}</div>`;
}
// Sell price
const sell = cleanHtml(item.sell);
if (sell) {
html += `<div class="game-tooltip-sell">Sell: ${sell}</div>`;
}
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 = '<div class="loading">Loading…</div>';
treeContainer.innerHTML = '';
const item = await getItemByName(itemName);
if (!item) {
itemHeader.innerHTML = `<div class="error">Item not found: "${itemName}"</div>`;
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 = '<div class="loading">Loading crafting tree…</div>';
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 = '<div class="no-recipe">This item has no crafting recipe or known drop source.</div>';
}
} 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 = `<div class="error">Failed to load crafting tree: ${err.message}</div>`;
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 += `<span class="stat">⚔️ ${item.damage} ${item.damagetype || ''} damage</span>`;
if (item.knockback) statsHtml += `<span class="stat">💥 ${item.knockback} knockback</span>`;
if (item.critical) statsHtml += `<span class="stat">🎯 ${item.critical}% crit</span>`;
if (item.usetime) statsHtml += `<span class="stat">⏱️ ${item.usetime} use time</span>`;
if (item.velocity) statsHtml += `<span class="stat">🚀 ${item.velocity} velocity</span>`;
if (item.defense) statsHtml += `<span class="stat">🛡️ ${item.defense} defense</span>`;
if (sell) statsHtml += `<span class="stat">💰 ${sell}</span>`;
return `
<div class="item-header">
<img src="${iconUrl}" alt="${item.name}" class="item-icon-large"
onerror="if(!this.dataset.triedWiki){this.dataset.triedWiki='1';this.src='${wikiIconUrl}'}else{this.style.display='none'}">
<div class="item-header-info">
<h2 style="color:${rarityColor}">
${item.name}
<a href="${getWikiUrl(item.name)}" target="_blank" class="wiki-link" title="View on Terraria Wiki">🔗</a>
<button class="fav-btn${favourited ? ' favourited' : ''}" id="fav-btn" data-item="${item.name}">${favourited ? 'Unfav' : 'Fav'}</button>
</h2>
<div class="item-meta">
<span class="rarity" style="color:${rarityColor}">★ ${getRarityName(item.rare)} Rarity</span>
<span class="type-label">${(item.type || '').replace(/\^/g, ', ')}</span>
${item.hardmode === '1' ? '<span class="hardmode-badge">Hardmode</span>' : ''}
</div>
${tooltip ? `<div class="tooltip-text">${tooltip}</div>` : ''}
<div class="stats">${statsHtml}</div>
</div>
</div>
`;
}
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 = '<div class="keybinds-help-title">Keyboard shortcuts</div>';
for (const [key, desc] of binds) {
html += `<div class="keybinds-help-row"><kbd>${key}</kbd><span>${desc}</span></div>`;
}
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';
});
}