Split embed.go with build tags so builds work without data files (embed_nodata.go provides empty fallbacks, embed_data.go embeds real data when built with -tags embed_data). Add Woodpecker CI pipeline (format/lint/build), release pipeline (goreleaser + Docker), Containerfile, docker-compose.yml, and goreleaser config. Update Makefile with format/lint targets and embed_data tag on build targets. Rename project from terraria-item-tree to terraria-companion throughout. Fix all errcheck lint warnings in fetcher.go and server.go. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1004 lines
32 KiB
JavaScript
1004 lines
32 KiB
JavaScript
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 += `<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 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 = '<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);
|
|
|
|
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 = '<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>';
|
|
}
|
|
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 = `<div class="error">Failed to load crafting tree: ${err.message}</div>`;
|
|
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 += `<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="pin-btn${pinned ? ' pinned' : ''}" id="pin-btn" data-item="${item.name}">${pinned ? 'Unpin' : 'Pin'}</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;
|
|
}
|
|
|
|
// ── 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);
|