/* Idle Fantasy Save Viewer – client UI */ let state = { data: null, snapshots: [], timeline: [], inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false, collapsedGroups: new Set() }, skills: { search: "", sort: "level", sortAsc: false }, quests: { tab: "story", filter: "all" }, history: { olderId: null, newerId: null, diff: null }, charts: {}, }; const CATEGORY_ORDER = [ "Currency", "Ores & Mining", "Bars & Smithing", "Wood & Planks", "Runes", "Raw Food", "Cooked Food", "Seeds & Farming", "Melee Weapons", "Ranged", "Magic", "Armor", "Bones & Hides", "Gems & Jewelry", "Potions & Brews", "Misc", ]; const CATEGORY_I18N_KEYS = { "Currency": "category.currency", "Ores & Mining": "category.ores_mining", "Bars & Smithing": "category.bars_smithing", "Wood & Planks": "category.wood_planks", "Runes": "category.runes", "Raw Food": "category.raw_food", "Cooked Food": "category.cooked_food", "Seeds & Farming": "category.seeds_farming", "Melee Weapons": "category.melee_weapons", "Ranged": "category.ranged", "Magic": "category.magic", "Armor": "category.armor", "Bones & Hides": "category.bones_hides", "Gems & Jewelry": "category.gems_jewelry", "Potions & Brews": "category.potions_brews", "Misc": "category.misc", }; document.addEventListener("DOMContentLoaded", init); function apiBase() { const vid = window.VIEWER_ID; return vid ? `/v/${vid}/api` : "/api"; } function viewerPageUrl() { const vid = window.VIEWER_ID; if (!vid) return window.location.href; return `${window.location.origin}/v/${vid}/`; } async function init() { await I18n.init(); applyStaticI18n(); setupLanguage(); setupViewerBanner(); setupNav(); setupUpload(); await loadData(); } function setupViewerBanner() { const vid = window.VIEWER_ID; if (!vid || vid === "local") return; const banner = document.getElementById("viewer-link-banner"); const urlEl = document.getElementById("viewer-link-url"); const copyBtn = document.getElementById("viewer-copy-link"); const url = viewerPageUrl(); banner.hidden = false; urlEl.textContent = url; copyBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(url); const prev = copyBtn.textContent; copyBtn.textContent = t("viewer.copied"); setTimeout(() => { copyBtn.textContent = prev; }, 2000); } catch { window.prompt(t("viewer.copyPrompt"), url); } }); } function categoryLabel(cat) { const key = CATEGORY_I18N_KEYS[cat]; return key ? t(key) : cat; } function applyStaticI18n() { document.querySelectorAll("[data-i18n]").forEach((el) => { el.textContent = t(el.dataset.i18n); }); } function setupLanguage() { const sel = document.getElementById("locale-select"); sel.value = I18n.getPreference(); sel.addEventListener("change", async (e) => { await I18n.setPreference(e.target.value); applyStaticI18n(); resetLocaleDependentPanels(); if (state.data) renderAll(); if (document.getElementById("tab-history").classList.contains("active")) { loadHistoryTab(); } }); } function resetLocaleDependentPanels() { ["tab-skills", "tab-inventory"].forEach((id) => { document.getElementById(id).innerHTML = ""; }); } function setupNav() { document.querySelectorAll(".nav-btn").forEach((btn) => { btn.addEventListener("click", () => { document.querySelectorAll(".nav-btn").forEach((b) => b.classList.remove("active")); document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active")); btn.classList.add("active"); document.getElementById(`tab-${btn.dataset.tab}`).classList.add("active"); if (btn.dataset.tab === "history") loadHistoryTab(); }); }); } function setupUpload() { document.getElementById("file-upload").addEventListener("change", async (e) => { const file = e.target.files[0]; if (!file) return; const fd = new FormData(); fd.append("file", file); const res = await fetch(`${apiBase()}/import`, { method: "POST", body: fd }); const result = await res.json(); if (!res.ok || result.error) { showImportFailure(result); e.target.value = ""; return; } if (result.imported) { await loadData(); notifyImportSuccess(result); } else if (result.reason === "duplicate") { alert(t("import.duplicate")); } e.target.value = ""; }); } function showImportFailure(result) { const lines = [result.error || t("import.failed")]; for (const item of result.import_report || []) { lines.push(`• ${I18n.translateIssue(item)}`); } alert(lines.join("\n")); } function notifyImportSuccess(result) { const summary = result.import_summary || {}; const warnings = summary.warnings || 0; const infos = summary.infos || 0; if (warnings || infos) { alert(t("import.successWithNotes", { id: result.snapshot_id, warnings, infos, })); } } function renderImportReport(meta) { const el = document.getElementById("import-report"); const report = meta?.import_report || []; const visible = report.filter((i) => i.level === "error" || i.level === "warning"); const infos = report.filter((i) => i.level === "info"); if (!report.length) { el.hidden = true; el.innerHTML = ""; return; } const errors = report.filter((i) => i.level === "error"); const warnings = report.filter((i) => i.level === "warning"); const level = errors.length ? "error" : warnings.length ? "warning" : "info"; const title = errors.length ? t("import.titleError") : warnings.length ? t("import.titleWarning") : t("import.titleInfo"); el.hidden = false; el.className = `import-report import-report-${level}`; el.innerHTML = `
${esc(title)} ${errors.length ? t("import.countErrors", { count: errors.length }) : ""} ${warnings.length ? t("import.countWarnings", { count: warnings.length }) : ""} ${infos.length ? t("import.countInfos", { count: infos.length }) : ""}
`; el.querySelector(".import-report-dismiss").addEventListener("click", () => { el.hidden = true; }); } async function loadData() { try { const res = await fetch(`${apiBase()}/snapshot/latest`); if (!res.ok) { showEmpty(window.VIEWER_ID ? t("empty.noSaveWeb") : t("empty.noSave")); return; } state.data = await res.json(); renderAll(); } catch (err) { showEmpty(t("empty.loadError", { message: err.message })); } } function showEmpty(msg) { document.getElementById("character-header").innerHTML = `${esc(msg)}`; } function renderAll() { const d = state.data; if (!d) return; renderHeader(d); renderImportReport(d.meta); renderOverview(d); renderSkills(d); renderInventory(d); renderEquipment(d); renderQuests(d); renderCombat(d); } function renderHeader(d) { const c = d.character; const m = d.meta; document.getElementById("character-header").innerHTML = `

${esc(c.name || t("empty.unknown"))}

${esc(c.race || "")} · ${esc(c.gender || "")} · ${t("meta.export")}: ${formatTs(m.exported_at)}
`; document.getElementById("kpi-row").innerHTML = `
${esc(t("kpi.coins"))}
${fmt(m.coins)}
${esc(t("kpi.totalLevel"))}
${m.total_level}
${esc(t("kpi.items"))}
${m.item_count}
${esc(t("kpi.totalQty"))}
${fmt(m.total_items)}
`; } function renderOverview(d) { const c = d.character; const queue = (d.session_queue || []).map((q) => `
  • ${esc(q.skill_display_name || q.skill_name)}${esc(q.activity_key || "—")} · ${q.qty || 0}
  • ` ).join(""); const slayer = d.combat.slayer_task; const slayerHtml = slayer ? `

    ${esc(slayer.display_name)}: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} ${esc(t("meta.points"))})

    ` : `

    ${esc(t("overview.noSlayerTask"))}

    `; const pets = (d.pets || []).map((p) => `
  • ${esc(p.id.replace(/_/g, " "))}+${p.boost_percent}%
  • ` ).join(""); const farming = (d.farming || []).map((p) => `
  • ${esc(t("overview.patch", { n: p.patchNumber }))}${esc(p.cropType || "—")}
  • ` ).join(""); document.getElementById("tab-overview").innerHTML = `

    ${esc(t("overview.character"))}

    ${esc(t("overview.sessionQueue"))}

    ${esc(t("overview.slayer"))}

    ${slayerHtml}

    ${esc(t("overview.pets"))}

    ${esc(t("overview.farming"))}

    ${esc(t("overview.guildRep"))}

    `; } function renderSkills(d) { const panel = document.getElementById("tab-skills"); const s = state.skills; if (!panel.querySelector("#skill-search")) { panel.innerHTML = `
    XP
    `; document.getElementById("skill-search").addEventListener("input", (e) => { state.skills.search = e.target.value; renderSkillsBody(state.data); }); document.getElementById("skill-sort").addEventListener("change", (e) => { state.skills.sort = e.target.value; renderSkillsBody(state.data); }); panel.querySelectorAll("th[data-sort]").forEach((th) => { th.addEventListener("click", () => { const key = th.dataset.sort; if (state.skills.sort === key) state.skills.sortAsc = !state.skills.sortAsc; else { state.skills.sort = key; state.skills.sortAsc = false; } renderSkillsBody(state.data); }); }); } document.getElementById("skill-search").placeholder = t("skills.search"); document.getElementById("skill-sort").options[0].textContent = t("skills.sortLevel"); document.getElementById("skill-sort").options[1].textContent = t("skills.sortXp"); document.getElementById("skill-sort").options[2].textContent = t("skills.sortName"); panel.querySelector('th[data-sort="name"]').textContent = t("skills.skill"); panel.querySelector('th[data-sort="level"]').textContent = t("skills.level"); panel.querySelector("thead tr th:last-child").textContent = t("skills.progress"); document.getElementById("skill-search").value = s.search; document.getElementById("skill-sort").value = s.sort; renderSkillsBody(d); } function renderSkillsBody(d) { const s = state.skills; let items = [...d.skills]; if (s.search) { const q = s.search.toLowerCase(); items = items.filter((sk) => sk.name.toLowerCase().includes(q) || sk.key.includes(q)); } items.sort((a, b) => { let cmp = 0; if (s.sort === "name") cmp = a.name.localeCompare(b.name); else if (s.sort === "level") cmp = a.level - b.level; else cmp = a.xp - b.xp; return s.sortAsc ? cmp : -cmp; }); document.getElementById("skill-tbody").innerHTML = items.map((sk) => ` ${esc(sk.name)} ${sk.level} ${fmt(sk.xp)} ${sk.progress_pct}%
    `).join(""); } function getFilteredInventoryItems(d, inv) { let items = [...d.inventory]; if (inv.search) { const q = inv.search.toLowerCase(); items = items.filter((i) => i.name.toLowerCase().includes(q) || i.key.includes(q)); } if (inv.categories.size > 0) { items = items.filter((i) => inv.categories.has(i.category)); } items.sort((a, b) => { if (inv.sort === "qty") return b.qty - a.qty; if (inv.sort === "name") return a.name.localeCompare(b.name); const ca = CATEGORY_ORDER.indexOf(a.category); const cb = CATEGORY_ORDER.indexOf(b.category); return ca - cb || a.name.localeCompare(b.name); }); return items; } function renderInventoryTable(d, inv) { const items = getFilteredInventoryItems(d, inv); const grouped = {}; for (const item of items) { if (!grouped[item.category]) grouped[item.category] = []; grouped[item.category].push(item); } const groupRows = Object.entries(grouped).map(([cat, catItems]) => { const totalQty = catItems.reduce((s, i) => s + i.qty, 0); const expanded = !inv.collapsedGroups.has(cat); const catLabel = categoryLabel(cat); const header = ` `; const rows = catItems.map((i) => ` ${esc(i.name)}${i.equipped ? `` : ""} ${fmt(i.qty)} ${esc(i.key)} `).join(""); return header + rows; }).join(""); const results = document.getElementById("inv-results"); if (!groupRows) { results.innerHTML = `

    ${esc(t("empty.noItems"))}

    `; return; } results.innerHTML = `
    ${groupRows}
    ${esc(t("inventory.item"))} ${esc(t("inventory.qty"))} ${esc(t("inventory.id"))}
    `; results.querySelectorAll(".inv-group-toggle").forEach((btn) => { btn.addEventListener("click", () => { const group = btn.dataset.group; const expanded = btn.getAttribute("aria-expanded") === "true"; btn.setAttribute("aria-expanded", expanded ? "false" : "true"); if (expanded) inv.collapsedGroups.add(group); else inv.collapsedGroups.delete(group); results.querySelectorAll(`.inv-item-row[data-group="${group}"]`).forEach((row) => { row.classList.toggle("collapsed", expanded); }); }); }); } function renderInventoryChips(d, inv) { const categories = [...new Set(d.inventory.map((i) => i.category))].sort( (a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b) ); const chips = document.getElementById("inv-chips"); chips.innerHTML = categories.map((c) => ` ${esc(categoryLabel(c))}`).join(""); chips.querySelectorAll(".chip").forEach((chip) => { chip.addEventListener("click", () => { const cat = chip.dataset.cat; if (inv.categories.has(cat)) inv.categories.delete(cat); else inv.categories.add(cat); renderInventoryChips(d, inv); renderInventoryTable(d, inv); }); }); } function renderInventory(d) { const panel = document.getElementById("tab-inventory"); const inv = state.inventory; if (!panel.querySelector("#inv-search")) { panel.innerHTML = `
    `; document.getElementById("inv-search").addEventListener("input", (e) => { state.inventory.search = e.target.value; renderInventoryTable(state.data, state.inventory); }); document.getElementById("inv-sort").addEventListener("change", (e) => { state.inventory.sort = e.target.value; renderInventoryTable(state.data, state.inventory); }); document.getElementById("inv-equipped").addEventListener("change", (e) => { state.inventory.highlightEquipped = e.target.checked; renderInventoryTable(state.data, state.inventory); }); } document.getElementById("inv-search").placeholder = t("inventory.search"); document.getElementById("inv-sort").options[0].textContent = t("inventory.sortCategory"); document.getElementById("inv-sort").options[1].textContent = t("inventory.sortName"); document.getElementById("inv-sort").options[2].textContent = t("inventory.sortQty"); document.getElementById("inv-equipped-label").textContent = t("inventory.highlightEquipped"); document.getElementById("inv-search").value = inv.search; document.getElementById("inv-sort").value = inv.sort; document.getElementById("inv-equipped").checked = inv.highlightEquipped; renderInventoryChips(d, inv); renderInventoryTable(d, inv); } function renderEquipment(d) { document.getElementById("tab-equipment").innerHTML = `

    ${esc(t("equipment.title"))}

    ${d.equipment.map((eq) => `
    ${esc(eq.slot_name)}
    ${eq.name ? esc(eq.name) : "—"}
    `).join("")}
    `; } function renderQuests(d) { const panel = document.getElementById("tab-quests"); const q = state.quests; const tabs = [ { key: "story", label: t("quests.story") }, { key: "daily", label: t("quests.daily") }, { key: "weekly", label: t("quests.weekly") }, { key: "guild", label: t("quests.guild") }, ]; let items = d.quests[q.tab] || []; if (q.tab === "story") { if (q.filter === "open") items = items.filter((x) => !x.completed); if (q.filter === "done") items = items.filter((x) => x.completed); } else { if (q.filter === "open") items = items.filter((x) => !x.claimed); if (q.filter === "done") items = items.filter((x) => x.claimed); } const isStory = q.tab === "story"; panel.innerHTML = `
    ${tabs.map((tab) => ``).join("")}
    ${items.map((quest) => { const done = isStory ? quest.completed : quest.claimed; return ``; }).join("")}
    ${esc(t("quests.quest"))} ${esc(t("quests.progress"))} ${esc(t("quests.status"))}
    ${esc(quest.name)} ${fmt(quest.progress)} ${esc(done ? t("quests.done") : t("quests.open"))}
    `; panel.querySelectorAll(".quest-tab").forEach((btn) => { btn.addEventListener("click", () => { state.quests.tab = btn.dataset.tab; renderQuests(state.data); }); }); document.getElementById("quest-filter").addEventListener("change", (e) => { state.quests.filter = e.target.value; renderQuests(state.data); }); } function renderCombat(d) { const kills = Object.entries(d.combat.enemy_kills || {}) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `
  • ${esc(k.replace(/_/g, " "))}${fmt(v)}
  • `).join(""); const dungeons = Object.entries(d.combat.dungeon_runs || {}) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `
  • ${esc(k.replace(/_/g, " "))}${esc(t("combat.runs", { count: fmt(v) }))}
  • `).join(""); const recent = (d.recent_sessions || []) .map((s) => `
  • ${esc(s.activity_display_name || s.activity_key)}${esc(s.skill_name)}
  • `).join(""); const active = (d.sessions || []) .map((s) => `
  • ${esc(s.activity)}${esc(s.skill)} · ${esc(s.completed ? t("combat.sessionDone") : t("combat.sessionRunning"))}
  • `).join(""); const none = `
  • ${esc(t("empty.none"))}
  • `; document.getElementById("tab-combat").innerHTML = `

    ${esc(t("combat.enemyKills"))}

    ${esc(t("combat.dungeonRuns"))}

    ${esc(t("combat.recentActivity"))}

    ${esc(t("combat.activeSessions"))}

    `; } async function loadHistoryTab() { const panel = document.getElementById("tab-history"); panel.innerHTML = `

    ${esc(t("history.loading"))}

    `; const [snapRes, tlRes] = await Promise.all([ fetch(`${apiBase()}/snapshots`), fetch(`${apiBase()}/timeline`), ]); state.snapshots = await snapRes.json(); state.timeline = await tlRes.json(); if (state.snapshots.length === 0) { panel.innerHTML = `

    ${esc(t("empty.noSnapshots"))}

    `; return; } const h = state.history; if (!h.newerId) h.newerId = state.snapshots[0].id; if (!h.olderId && state.snapshots.length > 1) h.olderId = state.snapshots[1].id; panel.innerHTML = `

    ${esc(t("history.coinsChart"))}

    ${esc(t("history.levelChart"))}

    ${esc(t("history.snapshotCompare"))}

    ${esc(t("history.allSnapshots"))}

    ${state.snapshots.map((s) => ` `).join("")}
    ID ${esc(t("history.character"))} ${esc(t("kpi.coins"))} ${esc(t("kpi.totalLevel"))} ${esc(t("meta.export"))} ${esc(t("history.file"))}
    ${s.id} ${esc(s.character_name || "—")} ${fmt(s.coins)} ${s.total_level} ${formatTs(s.exported_at)} ${esc(s.source_file)}
    `; renderTimelineCharts(); document.getElementById("diff-older").addEventListener("change", (e) => { h.olderId = +e.target.value; }); document.getElementById("diff-newer").addEventListener("change", (e) => { h.newerId = +e.target.value; }); document.getElementById("diff-run").addEventListener("click", runDiff); if (h.olderId && h.newerId && h.olderId !== h.newerId) runDiff(); } function option(s, selected) { const label = `#${s.id} · ${s.character_name || "?"} · ${formatTs(s.exported_at)}`; return ``; } function renderTimelineCharts() { const tl = state.timeline; if (!tl.length) return; const labels = tl.map((s) => formatTs(s.exported_at)); const coins = tl.map((s) => s.coins); const levels = tl.map((s) => s.total_level); destroyChart("coins"); destroyChart("level"); state.charts.coins = new Chart(document.getElementById("chart-coins"), { type: "line", data: { labels, datasets: [{ label: t("kpi.coins"), data: coins, borderColor: "#6c8cff", tension: 0.3, fill: false }] }, options: chartOpts(), }); state.charts.level = new Chart(document.getElementById("chart-level"), { type: "line", data: { labels, datasets: [{ label: t("kpi.totalLevel"), data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] }, options: chartOpts(), }); } function chartOpts() { return { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: "#8b92a8" } } }, scales: { x: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } }, y: { ticks: { color: "#8b92a8" }, grid: { color: "#2d3348" } }, }, }; } function destroyChart(key) { if (state.charts[key]) { state.charts[key].destroy(); delete state.charts[key]; } } async function runDiff() { const h = state.history; const el = document.getElementById("diff-result"); if (!h.olderId || !h.newerId || h.olderId === h.newerId) { el.innerHTML = `

    ${esc(t("empty.pickTwoSnapshots"))}

    `; return; } const older = Math.min(h.olderId, h.newerId); const newer = Math.max(h.olderId, h.newerId); const res = await fetch(`${apiBase()}/snapshots/${older}/diff/${newer}`); const diff = await res.json(); if (diff.error) { el.innerHTML = `

    ${esc(diff.error)}

    `; return; } const coinDelta = diff.summary.coins_delta; const coinCls = coinDelta >= 0 ? "delta-pos" : "delta-neg"; const levelDelta = diff.summary.total_level_delta; const invRows = diff.inventory_changes.slice(0, 50).map((i) => ` ${esc(i.name)} ${fmt(i.old_qty)} → ${fmt(i.new_qty)} ${i.delta >= 0 ? "+" : ""}${fmt(i.delta)} `).join(""); const skRows = diff.skill_changes .sort((a, b) => b.xp_delta - a.xp_delta) .slice(0, 20) .map((s) => ` ${esc(s.name)} ${s.old_level} → ${s.new_level} ${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP `).join(""); const noChanges = `${esc(t("empty.noChanges"))}`; el.innerHTML = `

    ${esc(t("history.coinsSummary", { delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`, levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`, }))}

    ${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}

    ${invRows || noChanges}
    ${esc(t("inventory.item"))} ${esc(t("inventory.qty"))} ${esc(t("history.delta"))}

    ${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}

    ${skRows || noChanges}
    ${esc(t("skills.skill"))} ${esc(t("skills.level"))} ${esc(t("history.xpDelta"))}
    `; } function fmt(n) { if (n == null) return "—"; return Number(n).toLocaleString(I18n.localeTag()); } function formatTs(ts) { if (!ts) return "—"; const d = new Date(Number(ts)); return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" }); } function esc(s) { if (s == null) return ""; return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }