(() => {
'use strict';
const els = {
input: document.getElementById('questInput'),
button: document.getElementById('questBtn'),
status: document.getElementById('questStatus'),
results: document.getElementById('questResults'),
toolbar: document.querySelector('.quest-toolbar')
};
function showStatus(msg, ok = false) {
if (!els.status) return;
els.status.textContent = msg;
els.status.style.color = ok ? 'var(--fg)' : 'var(--fg-soft)';
}
function escapeHTML(s = '') {
return s.toString()
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
async function fetchJSON(url) {
try {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
let allTasks = null;
async function loadLocalData() {
if (Array.isArray(allTasks)) return allTasks;
els.results?.setAttribute('aria-busy', 'true');
try {
const json = await fetchJSON('/quests.json');
const tasks =
(Array.isArray(json?.tasks) && json.tasks) ||
(Array.isArray(json?.data?.tasks) && json.data.tasks) ||
[];
// ✅ FIX: assign the cache
allTasks = tasks;
return allTasks;
} finally {
els.results?.removeAttribute('aria-busy');
}
}
function renderQuest(task) {
const wrap = document.createElement('article');
wrap.className = 'quest-card trader-card';
const h2 = document.createElement('h2');
h2.className = 'quest-name trader-name';
h2.textContent = task.name || 'Unknown Quest';
wrap.appendChild(h2);
if (task.trader || task.wikiLink) {
const meta = document.createElement('div');
meta.className = 'quest-meta';
if (task.trader) {
const box = document.createElement('div');
box.className = 'quest-trader';
box.style.display = 'flex';
box.style.alignItems = 'center';
if (task.trader.imageLink) {
const img = document.createElement('img');
img.src = task.trader.imageLink;
img.alt = task.trader.name || 'Trader';
img.loading = 'lazy';
img.width = 36;
img.height = 36;
img.style.borderRadius = '6px';
img.style.marginRight = '8px';
img.style.border = '1px solid rgba(0,255,102,0.2)';
box.appendChild(img);
}
const nm = document.createElement('span');
nm.textContent = task.trader.name || 'Trader';
box.appendChild(nm);
meta.appendChild(box);
}
if (task.wikiLink) {
const wiki = document.createElement('a');
wiki.href = task.wikiLink;
wiki.target = '_blank';
wiki.rel = 'noopener noreferrer';
wiki.className = 'btn';
wiki.style.marginLeft = 'auto';
wiki.textContent = 'Open Wiki';
meta.appendChild(wiki);
}
wrap.appendChild(meta);
}
const objectives = Array.isArray(task.objectives) ? task.objectives : [];
const ol = document.createElement('ol');
ol.className = 'quest-objectives';
if (!objectives.length) {
const li = document.createElement('li');
li.className = 'quest-objective';
li.textContent = 'No objectives listed.';
ol.appendChild(li);
} else {
for (const obj of objectives) {
const li = document.createElement('li');
li.className = 'quest-objective';
const lines = [];
const type = obj.type ? `${escapeHTML(obj.type)}` : 'Objective';
const desc = escapeHTML(obj.description || '—');
const maps = (obj.maps && obj.maps.length)
? ` — Map: ${escapeHTML(obj.maps.map(m => m.normalizedName).join(', '))}`
: '';
lines.push(`${type}: ${desc}${maps}`);
if (typeof obj.count === 'number' && (obj.item || (obj.items && obj.items.length))) {
const baseName = obj.item?.name || obj.items?.[0]?.name || 'item';
const fir = obj.foundInRaid ? ' (FIR)' : '';
lines.push(`Hand in: ${escapeHTML(baseName)} × ${obj.count}${escapeHTML(fir)}`);
}
if (obj.targetNames && obj.targetNames.length && typeof obj.count === 'number') {
lines.push(`Eliminate: ${escapeHTML(obj.targetNames.join(', '))} × ${obj.count}`);
}
li.innerHTML = lines.map(l => `
${l}
`).join('');
ol.appendChild(li);
}
}
wrap.appendChild(ol);
const fr = task.finishRewards;
if (fr) {
const box = document.createElement('div');
box.className = 'quest-rewards';
function addRow(label, valueHTML) {
const row = document.createElement('div');
row.innerHTML = `${escapeHTML(label)}: ${valueHTML}`;
box.appendChild(row);
}
if (Array.isArray(fr.items) && fr.items.length) {
const grid = document.createElement('div');
grid.style.display = 'grid';
grid.style.gridTemplateColumns = 'repeat(auto-fit, minmax(180px, 1fr))';
grid.style.gap = '8px';
for (const g of fr.items) {
const c = document.createElement('div');
c.style.display = 'flex';
c.style.alignItems = 'center';
c.style.gap = '8px';
if (g.item?.iconLink) {
const img = document.createElement('img');
img.src = g.item.iconLink;
img.alt = g.item.name || '';
img.width = 24;
img.height = 24;
img.style.border = '1px solid rgba(0,255,102,0.2)';
img.style.borderRadius = '4px';
c.appendChild(img);
}
const span = document.createElement('span');
const nm = g.item?.name || 'Item';
const ct = (typeof g.count === 'number') ? ` × ${g.count}` : '';
span.textContent = `${nm}${ct}`;
c.appendChild(span);
grid.appendChild(c);
}
const row = document.createElement('div');
row.innerHTML = `Items:`;
row.appendChild(grid);
box.appendChild(row);
}
if (Array.isArray(fr.traderStanding) && fr.traderStanding.length) {
const parts = fr.traderStanding.map(s => {
const t = s.trader?.name || 'Trader';
const v = typeof s.standing === 'number'
? `${s.standing > 0 ? '+' : ''}${s.standing}`
: (s.standing ?? '');
return `${t}: ${v}`;
});
addRow('Standing', escapeHTML(parts.join(', ')));
}
if (fr.offerUnlock) {
const list = Array.isArray(fr.offerUnlock) ? fr.offerUnlock : [fr.offerUnlock];
const ul = document.createElement('ul');
for (const u of list) {
const li = document.createElement('li');
const itemName = u?.item?.name || 'Item';
const traderName = u?.trader?.name || 'Trader';
li.textContent = `${itemName} @ ${traderName}`;
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `Offer unlocks:`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.skillLevelReward) {
const list = Array.isArray(fr.skillLevelReward) ? fr.skillLevelReward : [fr.skillLevelReward];
const ul = document.createElement('ul');
for (const s of list) {
const li = document.createElement('li');
const skillObj = s?.skill;
const skillName = skillObj?.name || skillObj?.id || 'Skill';
const lvl = typeof s?.level === 'number' ? ` +${s.level}` : '';
li.textContent = `${skillName}${lvl}`;
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `Skill rewards:`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.traderUnlock) {
const list = Array.isArray(fr.traderUnlock) ? fr.traderUnlock : [fr.traderUnlock];
const ul = document.createElement('ul');
for (const t of list) {
const li = document.createElement('li');
li.textContent = t?.name || t?.id || 'Trader unlock';
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `Trader unlocks:`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.craftUnlock) {
const list = Array.isArray(fr.craftUnlock) ? fr.craftUnlock : [fr.craftUnlock];
const ul = document.createElement('ul');
for (const p of list) {
const li = document.createElement('li');
li.textContent = p?.name || p?.id || 'Craft unlock';
ul.appendChild(li);
}
const row = document.createElement('div');
row.innerHTML = `Craft unlocks:`;
row.appendChild(ul);
box.appendChild(row);
}
if (fr.achievement) {
const list = Array.isArray(fr.achievement) ? fr.achievement : [fr.achievement];
const line = list.map(a => a?.name || a?.id || 'Achievement').join(', ');
addRow('Achievement', escapeHTML(line));
}
if (fr.customization) {
const list = Array.isArray(fr.customization) ? fr.customization : [fr.customization];
const line = list.map(c => c?.name || c?.id || 'Customization').join(', ');
addRow('Customization', escapeHTML(line));
}
if (box.children.length) wrap.appendChild(box);
}
return wrap;
}
function getOrCreateAltMatchesContainer() {
const id = 'questAltMatches';
let box = document.getElementById(id);
if (box) {
box.hidden = false;
return box;
}
const results = document.getElementById('questResults');
if (!results || !results.parentNode) return null;
box = document.createElement('div');
box.id = id;
box.className = 'quest-alt-matches';
results.parentNode.insertBefore(box, results);
return box;
}
function renderMatchList(matches) {
const altBox = getOrCreateAltMatchesContainer();
if (!altBox) return;
altBox.innerHTML = '';
if (!matches.length) {
altBox.hidden = true;
return;
}
const details = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = `Show ${matches.length} match(es)`;
details.appendChild(summary);
const ul = document.createElement('ul');
ul.style.listStyle = 'disc';
ul.style.paddingLeft = '1.25rem';
for (const m of matches) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'linklike';
btn.textContent = m.trader?.name ? `${m.name} — ${m.trader.name}` : m.name;
btn.addEventListener('click', () => openTaskDetails(m.id));
li.appendChild(btn);
ul.appendChild(li);
}
details.appendChild(ul);
altBox.appendChild(details);
}
async function openTaskDetails(id) {
try {
showStatus('Loading task…');
els.results.innerHTML = '';
const list = await loadLocalData();
const task = list.find(t => t?.id === id);
if (!task) {
showStatus('Task not found', false);
return;
}
const card = renderQuest(task);
els.results.appendChild(card);
showStatus('Ready', true);
} catch (err) {
console.error('[QuestSearch] open details failed:', err);
showStatus('Failed to load task details.', false);
}
}
async function search() {
const q = (els.input?.value || '').trim();
const alt = getOrCreateAltMatchesContainer();
els.results.innerHTML = '';
if (alt) alt.innerHTML = '';
if (!q) {
if (alt) alt.hidden = true;
showStatus('Type a quest name to search.');
return;
}
try {
showStatus('Searching…');
const list = await loadLocalData();
const safe = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(safe, 'i');
const matches = (Array.isArray(list) ? list : []).filter(t => re.test(t.name || ''));
renderMatchList(matches);
showStatus(`Found ${matches.length} match(es)`, true);
if (matches.length === 1) await openTaskDetails(matches[0].id);
} catch (err) {
console.error('[QuestSearch] search failed:', err);
showStatus('Search failed. Check console.', false);
}
}
let lastEnterTs = 0;
const ENTER_DEBOUNCE_MS = 250;
els.button?.addEventListener('click', search);
els.input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const now = Date.now();
if (now - lastEnterTs < ENTER_DEBOUNCE_MS) return;
lastEnterTs = now;
e.preventDefault();
search();
}
});
showStatus('Waiting to search…');
})();