399 lines
13 KiB
JavaScript
Executable File
399 lines
13 KiB
JavaScript
Executable File
|
||
(() => {
|
||
'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 ? `<strong>${escapeHTML(obj.type)}</strong>` : '<strong>Objective</strong>';
|
||
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 => `<div>${l}</div>`).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 = `<strong>${escapeHTML(label)}:</strong> ${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 = `<strong>Items:</strong>`;
|
||
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 = `<strong>Offer unlocks:</strong>`;
|
||
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 = `<strong>Skill rewards:</strong>`;
|
||
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 = `<strong>Trader unlocks:</strong>`;
|
||
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 = `<strong>Craft unlocks:</strong>`;
|
||
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…');
|
||
})();
|