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>
1491 lines
47 KiB
JavaScript
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';
|
|
});
|
|
}
|