Add i18n, save validation, and tolerant import handling.

Prepare the UI for English (default/fallback) and German with auto or manual locale selection, and report import issues with client-translated warnings instead of failing on minor save format changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-19 15:59:57 +02:00
parent 4b8b921e02
commit fbc2deec45
11 changed files with 1430 additions and 270 deletions
+377 -176
View File
@@ -4,7 +4,7 @@ let state = {
data: null,
snapshots: [],
timeline: [],
inventory: { search: "", categories: new Set(), sort: "category", highlightEquipped: false },
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 },
@@ -17,14 +17,67 @@ const CATEGORY_ORDER = [
"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);
async function init() {
await I18n.init();
applyStaticI18n();
setupLanguage();
setupNav();
setupUpload();
await loadData();
}
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", () => {
@@ -45,27 +98,102 @@ function setupUpload() {
fd.append("file", file);
const res = await fetch("/api/import", { method: "POST", body: fd });
const result = await res.json();
if (result.imported || result.snapshot_id) {
if (!res.ok || result.error) {
showImportFailure(result);
e.target.value = "";
return;
}
if (result.imported) {
await loadData();
alert(result.imported ? `Importiert: Snapshot #${result.snapshot_id}` : "Backup bereits vorhanden (Duplikat).");
} else {
alert(result.error || "Import fehlgeschlagen");
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 = `
<div class="import-report-header">
<strong>${esc(title)}</strong>
<span class="import-report-counts">
${errors.length ? t("import.countErrors", { count: errors.length }) : ""}
${warnings.length ? t("import.countWarnings", { count: warnings.length }) : ""}
${infos.length ? t("import.countInfos", { count: infos.length }) : ""}
</span>
<button type="button" class="import-report-dismiss" title="${esc(t("actions.dismiss"))}">×</button>
</div>
<ul class="import-report-list">
${visible.map((i) => `<li class="import-issue import-issue-${i.level}">${esc(I18n.translateIssue(i))}</li>`).join("")}
${infos.length ? `
<li class="import-issue import-issue-info-collapsed">
<details>
<summary>${esc(t("import.newFieldsSummary", { count: infos.length }))}</summary>
<ul>${infos.map((i) => `<li>${esc(I18n.translateIssue(i))}</li>`).join("")}</ul>
</details>
</li>` : ""}
</ul>`;
el.querySelector(".import-report-dismiss").addEventListener("click", () => {
el.hidden = true;
});
}
async function loadData() {
try {
const res = await fetch("/api/snapshot/latest");
if (!res.ok) {
showEmpty("Kein Save importiert. Starte mit: python app.py fantasyidler_save.json");
showEmpty(t("empty.noSave"));
return;
}
state.data = await res.json();
renderAll();
} catch (err) {
showEmpty(`Fehler beim Laden: ${err.message}`);
showEmpty(t("empty.loadError", { message: err.message }));
}
}
@@ -78,6 +206,7 @@ function renderAll() {
if (!d) return;
renderHeader(d);
renderImportReport(d.meta);
renderOverview(d);
renderSkills(d);
renderInventory(d);
@@ -90,16 +219,16 @@ function renderHeader(d) {
const c = d.character;
const m = d.meta;
document.getElementById("character-header").innerHTML = `
<h2>${esc(c.name || "Unbekannt")}</h2>
<h2>${esc(c.name || t("empty.unknown"))}</h2>
<div class="character-meta">
${esc(c.race || "")} · ${esc(c.gender || "")} · Export: ${formatTs(m.exported_at)}
${esc(c.race || "")} · ${esc(c.gender || "")} · ${t("meta.export")}: ${formatTs(m.exported_at)}
</div>`;
document.getElementById("kpi-row").innerHTML = `
<div class="kpi"><div class="kpi-label">Coins</div><div class="kpi-value">${fmt(m.coins)}</div></div>
<div class="kpi"><div class="kpi-label">Gesamt-Level</div><div class="kpi-value">${m.total_level}</div></div>
<div class="kpi"><div class="kpi-label">Items</div><div class="kpi-value">${m.item_count}</div></div>
<div class="kpi"><div class="kpi-label">Stückzahl</div><div class="kpi-value">${fmt(m.total_items)}</div></div>`;
<div class="kpi"><div class="kpi-label">${esc(t("kpi.coins"))}</div><div class="kpi-value">${fmt(m.coins)}</div></div>
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalLevel"))}</div><div class="kpi-value">${m.total_level}</div></div>
<div class="kpi"><div class="kpi-label">${esc(t("kpi.items"))}</div><div class="kpi-value">${m.item_count}</div></div>
<div class="kpi"><div class="kpi-label">${esc(t("kpi.totalQty"))}</div><div class="kpi-value">${fmt(m.total_items)}</div></div>`;
}
function renderOverview(d) {
@@ -110,47 +239,47 @@ function renderOverview(d) {
const slayer = d.combat.slayer_task;
const slayerHtml = slayer
? `<p><strong>${esc(slayer.display_name)}</strong>: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} Punkte)</p>`
: "<p class='empty-state'>Kein Slayer-Task aktiv</p>";
? `<p><strong>${esc(slayer.display_name)}</strong>: ${slayer.kills_completed}/${slayer.target_kills} (${d.combat.slayer_points} ${esc(t("meta.points"))})</p>`
: `<p class='empty-state'>${esc(t("overview.noSlayerTask"))}</p>`;
const pets = (d.pets || []).map((p) =>
`<li><span>${esc(p.id.replace(/_/g, " "))}</span><span>+${p.boost_percent}%</span></li>`
).join("");
const farming = (d.farming || []).map((p) =>
`<li><span>Feld ${p.patchNumber}</span><span>${esc(p.cropType || "—")}</span></li>`
`<li><span>${esc(t("overview.patch", { n: p.patchNumber }))}</span><span>${esc(p.cropType || "—")}</span></li>`
).join("");
document.getElementById("tab-overview").innerHTML = `
<div class="grid-2">
<div class="card">
<h3>Charakter</h3>
<h3>${esc(t("overview.character"))}</h3>
<ul class="list-compact">
<li><span>HP</span><span>${c.hp ?? "—"}</span></li>
<li><span>Aktiver Trank</span><span>${esc(c.active_potion || "—")}</span></li>
<li><span>Aktiver Zauber</span><span>${esc(c.active_spell || "—")}</span></li>
<li><span>Waffen-Slot</span><span>${esc(c.active_weapon_slot || "—")}</span></li>
<li><span>Segen</span><span>${esc(c.active_blessing || "—")}</span></li>
<li><span>${esc(t("overview.hp"))}</span><span>${c.hp ?? "—"}</span></li>
<li><span>${esc(t("overview.activePotion"))}</span><span>${esc(c.active_potion || "—")}</span></li>
<li><span>${esc(t("overview.activeSpell"))}</span><span>${esc(c.active_spell || "—")}</span></li>
<li><span>${esc(t("overview.weaponSlot"))}</span><span>${esc(c.active_weapon_slot || "—")}</span></li>
<li><span>${esc(t("overview.blessing"))}</span><span>${esc(c.active_blessing || "—")}</span></li>
</ul>
</div>
<div class="card">
<h3>Session-Queue</h3>
<ul class="list-compact">${queue || "<li><span>Leer</span></li>"}</ul>
<h3>${esc(t("overview.sessionQueue"))}</h3>
<ul class="list-compact">${queue || `<li><span>${esc(t("empty.empty"))}</span></li>`}</ul>
</div>
<div class="card">
<h3>Slayer</h3>
<h3>${esc(t("overview.slayer"))}</h3>
${slayerHtml}
</div>
<div class="card">
<h3>Pets</h3>
<ul class="list-compact">${pets || "<li><span>Keine</span></li>"}</ul>
<h3>${esc(t("overview.pets"))}</h3>
<ul class="list-compact">${pets || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
</div>
<div class="card">
<h3>Farming</h3>
<ul class="list-compact">${farming || "<li><span>Keine Felder</span></li>"}</ul>
<h3>${esc(t("overview.farming"))}</h3>
<ul class="list-compact">${farming || `<li><span>${esc(t("empty.none"))}</span></li>`}</ul>
</div>
<div class="card">
<h3>Gilden-Ruf</h3>
<h3>${esc(t("overview.guildRep"))}</h3>
<ul class="list-compact">${Object.entries(d.guild_reputation || {}).map(([k, v]) =>
`<li><span>${esc(k)}</span><span>${fmt(v)}</span></li>`).join("")}</ul>
</div>
@@ -160,6 +289,62 @@ function renderOverview(d) {
function renderSkills(d) {
const panel = document.getElementById("tab-skills");
const s = state.skills;
if (!panel.querySelector("#skill-search")) {
panel.innerHTML = `
<div class="toolbar">
<input class="search-input" id="skill-search" placeholder="" value="">
<select class="select-input" id="skill-sort">
<option value="level"></option>
<option value="xp"></option>
<option value="name"></option>
</select>
</div>
<div class="card">
<table>
<thead><tr>
<th data-sort="name"></th>
<th data-sort="level"></th>
<th data-sort="xp">XP</th>
<th></th>
</tr></thead>
<tbody id="skill-tbody"></tbody>
</table>
</div>`;
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();
@@ -173,62 +358,19 @@ function renderSkills(d) {
return s.sortAsc ? cmp : -cmp;
});
panel.innerHTML = `
<div class="toolbar">
<input class="search-input" id="skill-search" placeholder="Skill suchen…" value="${esc(s.search)}">
<select class="select-input" id="skill-sort">
<option value="level" ${s.sort === "level" ? "selected" : ""}>Nach Level</option>
<option value="xp" ${s.sort === "xp" ? "selected" : ""}>Nach XP</option>
<option value="name" ${s.sort === "name" ? "selected" : ""}>Nach Name</option>
</select>
</div>
<div class="card">
<table>
<thead><tr>
<th data-sort="name">Skill</th>
<th data-sort="level">Level</th>
<th data-sort="xp">XP</th>
<th>Fortschritt</th>
</tr></thead>
<tbody>${items.map((sk) => `
<tr>
<td>${esc(sk.name)}</td>
<td>${sk.level}</td>
<td>${fmt(sk.xp)}</td>
<td style="min-width:140px">
${sk.progress_pct}%
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
</td>
</tr>`).join("")}
</tbody>
</table>
</div>`;
document.getElementById("skill-search").addEventListener("input", (e) => {
state.skills.search = e.target.value;
renderSkills(state.data);
});
document.getElementById("skill-sort").addEventListener("change", (e) => {
state.skills.sort = e.target.value;
renderSkills(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; }
renderSkills(state.data);
});
});
document.getElementById("skill-tbody").innerHTML = items.map((sk) => `
<tr>
<td>${esc(sk.name)}</td>
<td>${sk.level}</td>
<td>${fmt(sk.xp)}</td>
<td style="min-width:140px">
${sk.progress_pct}%
<div class="progress-bar"><div class="progress-fill" style="width:${sk.progress_pct}%"></div></div>
</td>
</tr>`).join("");
}
function renderInventory(d) {
const panel = document.getElementById("tab-inventory");
const inv = state.inventory;
const categories = [...new Set(d.inventory.map((i) => i.category))].sort(
(a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b)
);
function getFilteredInventoryItems(d, inv) {
let items = [...d.inventory];
if (inv.search) {
const q = inv.search.toLowerCase();
@@ -244,7 +386,11 @@ function renderInventory(d) {
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] = [];
@@ -253,25 +399,33 @@ function renderInventory(d) {
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 = `
<tr class="inv-group-row" data-group="${esc(cat)}">
<td colspan="3">
<button type="button" class="inv-group-toggle" data-group="${esc(cat)}" aria-expanded="true">
<span class="inv-group-title">${esc(cat)}</span>
<span class="inv-group-meta">${catItems.length} Items · ${fmt(totalQty)} Stück</span>
<button type="button" class="inv-group-toggle" data-group="${esc(cat)}" aria-expanded="${expanded}">
<span class="inv-group-title">${esc(catLabel)}</span>
<span class="inv-group-meta">${esc(t("inventory.groupMeta", { count: catItems.length, qty: fmt(totalQty) }))}</span>
</button>
</td>
</tr>`;
const rows = catItems.map((i) => `
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""}" data-group="${esc(cat)}">
<td class="col-name">${esc(i.name)}${i.equipped ? '<span class="equipped-mark" title="Ausgerüstet">⚡</span>' : ""}</td>
<tr class="inv-item-row ${i.equipped && inv.highlightEquipped ? "item-equipped" : ""} ${expanded ? "" : "collapsed"}" data-group="${esc(cat)}">
<td class="col-name">${esc(i.name)}${i.equipped ? `<span class="equipped-mark" title="${esc(t("inventory.equipped"))}">⚡</span>` : ""}</td>
<td class="col-qty">${fmt(i.qty)}</td>
<td class="col-key"><code>${esc(i.key)}</code></td>
</tr>`).join("");
return header + rows;
}).join("");
const tableHtml = groupRows ? `
const results = document.getElementById("inv-results");
if (!groupRows) {
results.innerHTML = `<p class='empty-state'>${esc(t("empty.noItems"))}</p>`;
return;
}
results.innerHTML = `
<div class="inv-table-wrap">
<table class="inv-table">
<colgroup>
@@ -281,72 +435,99 @@ function renderInventory(d) {
</colgroup>
<thead>
<tr>
<th class="col-name">Item</th>
<th class="col-qty">Menge</th>
<th class="col-key">ID</th>
<th class="col-name">${esc(t("inventory.item"))}</th>
<th class="col-qty">${esc(t("inventory.qty"))}</th>
<th class="col-key">${esc(t("inventory.id"))}</th>
</tr>
</thead>
<tbody>${groupRows}</tbody>
</table>
</div>` : "<p class='empty-state'>Keine Items gefunden</p>";
panel.innerHTML = `
<div class="toolbar">
<input class="search-input" id="inv-search" placeholder="Item suchen…" value="${esc(inv.search)}">
<select class="select-input" id="inv-sort">
<option value="category" ${inv.sort === "category" ? "selected" : ""}>Nach Kategorie</option>
<option value="name" ${inv.sort === "name" ? "selected" : ""}>Nach Name</option>
<option value="qty" ${inv.sort === "qty" ? "selected" : ""}>Nach Menge</option>
</select>
<label class="toggle-label">
<input type="checkbox" id="inv-equipped" ${inv.highlightEquipped ? "checked" : ""}>
Ausgerüstet hervorheben
</label>
</div>
<div class="chip-row" id="inv-chips">
${categories.map((c) => `
<span class="chip ${inv.categories.has(c) ? "active" : ""}" data-cat="${esc(c)}">${esc(c)}</span>`).join("")}
</div>
<div class="card inv-card">
${tableHtml}
</div>`;
document.getElementById("inv-search").addEventListener("input", (e) => {
state.inventory.search = e.target.value;
renderInventory(state.data);
});
document.getElementById("inv-sort").addEventListener("change", (e) => {
state.inventory.sort = e.target.value;
renderInventory(state.data);
});
document.getElementById("inv-equipped").addEventListener("change", (e) => {
state.inventory.highlightEquipped = e.target.checked;
renderInventory(state.data);
});
panel.querySelectorAll(".chip").forEach((chip) => {
chip.addEventListener("click", () => {
const cat = chip.dataset.cat;
if (state.inventory.categories.has(cat)) state.inventory.categories.delete(cat);
else state.inventory.categories.add(cat);
renderInventory(state.data);
});
});
panel.querySelectorAll(".inv-group-toggle").forEach((btn) => {
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");
panel.querySelectorAll(`.inv-item-row[data-group="${group}"]`).forEach((row) => {
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) => `
<span class="chip ${inv.categories.has(c) ? "active" : ""}" data-cat="${esc(c)}">${esc(categoryLabel(c))}</span>`).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 = `
<div class="toolbar">
<input class="search-input" id="inv-search" placeholder="" value="">
<select class="select-input" id="inv-sort">
<option value="category"></option>
<option value="name"></option>
<option value="qty"></option>
</select>
<label class="toggle-label">
<input type="checkbox" id="inv-equipped">
<span id="inv-equipped-label"></span>
</label>
</div>
<div class="chip-row" id="inv-chips"></div>
<div class="card inv-card" id="inv-results"></div>`;
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 = `
<div class="card">
<h3>Ausrüstung</h3>
<h3>${esc(t("equipment.title"))}</h3>
<div class="equip-grid">
${d.equipment.map((eq) => `
<div class="equip-slot ${eq.key ? "" : "empty"}">
@@ -361,10 +542,10 @@ function renderQuests(d) {
const panel = document.getElementById("tab-quests");
const q = state.quests;
const tabs = [
{ key: "story", label: "Story" },
{ key: "daily", label: "Daily" },
{ key: "weekly", label: "Weekly" },
{ key: "guild", label: "Gilde" },
{ 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] || [];
@@ -379,28 +560,28 @@ function renderQuests(d) {
const isStory = q.tab === "story";
panel.innerHTML = `
<div class="quest-tabs">
${tabs.map((t) => `<button class="quest-tab ${q.tab === t.key ? "active" : ""}" data-tab="${t.key}">${t.label}</button>`).join("")}
${tabs.map((tab) => `<button class="quest-tab ${q.tab === tab.key ? "active" : ""}" data-tab="${tab.key}">${esc(tab.label)}</button>`).join("")}
</div>
<div class="toolbar">
<select class="select-input" id="quest-filter">
<option value="all" ${q.filter === "all" ? "selected" : ""}>Alle</option>
<option value="open" ${q.filter === "open" ? "selected" : ""}>Offen</option>
<option value="done" ${q.filter === "done" ? "selected" : ""}>Abgeschlossen</option>
<option value="all" ${q.filter === "all" ? "selected" : ""}>${esc(t("quests.filterAll"))}</option>
<option value="open" ${q.filter === "open" ? "selected" : ""}>${esc(t("quests.filterOpen"))}</option>
<option value="done" ${q.filter === "done" ? "selected" : ""}>${esc(t("quests.filterDone"))}</option>
</select>
</div>
<div class="card">
<table>
<thead><tr>
<th>Quest</th>
<th>Fortschritt</th>
<th>Status</th>
<th>${esc(t("quests.quest"))}</th>
<th>${esc(t("quests.progress"))}</th>
<th>${esc(t("quests.status"))}</th>
</tr></thead>
<tbody>${items.map((quest) => {
const done = isStory ? quest.completed : quest.claimed;
return `<tr>
<td>${esc(quest.name)}</td>
<td>${fmt(quest.progress)}</td>
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${done ? "Erledigt" : "Offen"}</span></td>
<td><span class="badge ${done ? "badge-success" : "badge-warning"}">${esc(done ? t("quests.done") : t("quests.open"))}</span></td>
</tr>`;
}).join("")}</tbody>
</table>
@@ -425,26 +606,27 @@ function renderCombat(d) {
const dungeons = Object.entries(d.combat.dungeon_runs || {})
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${fmt(v)} Runs</span></li>`).join("");
.map(([k, v]) => `<li><span>${esc(k.replace(/_/g, " "))}</span><span>${esc(t("combat.runs", { count: fmt(v) }))}</span></li>`).join("");
const recent = (d.recent_sessions || [])
.map((s) => `<li><span>${esc(s.activity_display_name || s.activity_key)}</span><span>${esc(s.skill_name)}</span></li>`).join("");
const active = (d.sessions || [])
.map((s) => `<li><span>${esc(s.activity)}</span><span>${esc(s.skill)} · ${s.completed ? "fertig" : "läuft"}</span></li>`).join("");
.map((s) => `<li><span>${esc(s.activity)}</span><span>${esc(s.skill)} · ${esc(s.completed ? t("combat.sessionDone") : t("combat.sessionRunning"))}</span></li>`).join("");
const none = `<li>${esc(t("empty.none"))}</li>`;
document.getElementById("tab-combat").innerHTML = `
<div class="grid-2">
<div class="card"><h3>Feind-Kills</h3><ul class="list-compact">${kills || "<li>Keine</li>"}</ul></div>
<div class="card"><h3>Dungeon-Runs</h3><ul class="list-compact">${dungeons || "<li>Keine</li>"}</ul></div>
<div class="card"><h3>Letzte Aktivitäten</h3><ul class="list-compact">${recent || "<li>Keine</li>"}</ul></div>
<div class="card"><h3>Aktive Sessions</h3><ul class="list-compact">${active || "<li>Keine</li>"}</ul></div>
<div class="card"><h3>${esc(t("combat.enemyKills"))}</h3><ul class="list-compact">${kills || none}</ul></div>
<div class="card"><h3>${esc(t("combat.dungeonRuns"))}</h3><ul class="list-compact">${dungeons || none}</ul></div>
<div class="card"><h3>${esc(t("combat.recentActivity"))}</h3><ul class="list-compact">${recent || none}</ul></div>
<div class="card"><h3>${esc(t("combat.activeSessions"))}</h3><ul class="list-compact">${active || none}</ul></div>
</div>`;
}
async function loadHistoryTab() {
const panel = document.getElementById("tab-history");
panel.innerHTML = "<p class='loading'>Lade Verlauf…</p>";
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
const [snapRes, tlRes] = await Promise.all([
fetch("/api/snapshots"),
@@ -454,7 +636,7 @@ async function loadHistoryTab() {
state.timeline = await tlRes.json();
if (state.snapshots.length === 0) {
panel.innerHTML = "<p class='empty-state'>Noch keine Snapshots. Importiere ein Backup.</p>";
panel.innerHTML = `<p class='empty-state'>${esc(t("empty.noSnapshots"))}</p>`;
return;
}
@@ -465,16 +647,16 @@ async function loadHistoryTab() {
panel.innerHTML = `
<div class="grid-2">
<div class="card">
<h3>Coins-Verlauf</h3>
<h3>${esc(t("history.coinsChart"))}</h3>
<div class="chart-wrap"><canvas id="chart-coins"></canvas></div>
</div>
<div class="card">
<h3>Gesamt-Level-Verlauf</h3>
<h3>${esc(t("history.levelChart"))}</h3>
<div class="chart-wrap"><canvas id="chart-level"></canvas></div>
</div>
</div>
<div class="card">
<h3>Snapshot-Vergleich</h3>
<h3>${esc(t("history.snapshotCompare"))}</h3>
<div class="toolbar">
<select class="select-input" id="diff-older">
${state.snapshots.map((s) => option(s, h.olderId)).join("")}
@@ -483,14 +665,21 @@ async function loadHistoryTab() {
<select class="select-input" id="diff-newer">
${state.snapshots.map((s) => option(s, h.newerId)).join("")}
</select>
<button class="select-input" id="diff-run" style="background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600">Vergleichen</button>
<button class="select-input" id="diff-run" style="background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600">${esc(t("actions.compare"))}</button>
</div>
<div id="diff-result"></div>
</div>
<div class="card">
<h3>Alle Snapshots</h3>
<h3>${esc(t("history.allSnapshots"))}</h3>
<table>
<thead><tr><th>ID</th><th>Charakter</th><th>Coins</th><th>Level</th><th>Export</th><th>Datei</th></tr></thead>
<thead><tr>
<th>ID</th>
<th>${esc(t("history.character"))}</th>
<th>${esc(t("kpi.coins"))}</th>
<th>${esc(t("kpi.totalLevel"))}</th>
<th>${esc(t("meta.export"))}</th>
<th>${esc(t("history.file"))}</th>
</tr></thead>
<tbody>${state.snapshots.map((s) => `
<tr>
<td>${s.id}</td>
@@ -529,12 +718,12 @@ function renderTimelineCharts() {
state.charts.coins = new Chart(document.getElementById("chart-coins"), {
type: "line",
data: { labels, datasets: [{ label: "Coins", data: coins, borderColor: "#6c8cff", tension: 0.3, fill: false }] },
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: "Gesamt-Level", data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
data: { labels, datasets: [{ label: t("kpi.totalLevel"), data: levels, borderColor: "#4ade80", tension: 0.3, fill: false }] },
options: chartOpts(),
});
}
@@ -562,7 +751,7 @@ async function runDiff() {
const h = state.history;
const el = document.getElementById("diff-result");
if (!h.olderId || !h.newerId || h.olderId === h.newerId) {
el.innerHTML = "<p class='empty-state'>Wähle zwei verschiedene Snapshots.</p>";
el.innerHTML = `<p class='empty-state'>${esc(t("empty.pickTwoSnapshots"))}</p>`;
return;
}
const older = Math.min(h.olderId, h.newerId);
@@ -576,6 +765,7 @@ async function runDiff() {
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) => `
<tr>
@@ -594,26 +784,37 @@ async function runDiff() {
<td class="${s.xp_delta >= 0 ? "delta-pos" : "delta-neg"}">${s.xp_delta >= 0 ? "+" : ""}${fmt(s.xp_delta)} XP</td>
</tr>`).join("");
const noChanges = `<tr><td colspan='3'>${esc(t("empty.noChanges"))}</td></tr>`;
el.innerHTML = `
<p>Coins: <span class="${coinCls}">${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}</span>
· Gesamt-Level: ${diff.summary.total_level_delta >= 0 ? "+" : ""}${diff.summary.total_level_delta}</p>
<h4 style="margin-top:16px">Inventar-Änderungen (${diff.inventory_changes.length})</h4>
<table><thead><tr><th>Item</th><th>Menge</th><th>Delta</th></tr></thead>
<tbody>${invRows || "<tr><td colspan='3'>Keine Änderungen</td></tr>"}</tbody></table>
<h4 style="margin-top:16px">Skill-Änderungen (${diff.skill_changes.length})</h4>
<table><thead><tr><th>Skill</th><th>Level</th><th>XP-Delta</th></tr></thead>
<tbody>${skRows || "<tr><td colspan='3'>Keine Änderungen</td></tr>"}</tbody></table>`;
<p>${esc(t("history.coinsSummary", {
delta: `${coinDelta >= 0 ? "+" : ""}${fmt(coinDelta)}`,
levelDelta: `${levelDelta >= 0 ? "+" : ""}${levelDelta}`,
}))}</p>
<h4 style="margin-top:16px">${esc(t("history.inventoryChanges", { count: diff.inventory_changes.length }))}</h4>
<table><thead><tr>
<th>${esc(t("inventory.item"))}</th>
<th>${esc(t("inventory.qty"))}</th>
<th>${esc(t("history.delta"))}</th>
</tr></thead>
<tbody>${invRows || noChanges}</tbody></table>
<h4 style="margin-top:16px">${esc(t("history.skillChanges", { count: diff.skill_changes.length }))}</h4>
<table><thead><tr>
<th>${esc(t("skills.skill"))}</th>
<th>${esc(t("skills.level"))}</th>
<th>${esc(t("history.xpDelta"))}</th>
</tr></thead>
<tbody>${skRows || noChanges}</tbody></table>`;
}
function fmt(n) {
if (n == null) return "—";
return Number(n).toLocaleString("de-DE");
return Number(n).toLocaleString(I18n.localeTag());
}
function formatTs(ts) {
if (!ts) return "—";
const d = new Date(Number(ts));
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
return d.toLocaleString(I18n.localeTag(), { dateStyle: "short", timeStyle: "short" });
}
function esc(s) {
+69
View File
@@ -0,0 +1,69 @@
/* Idle Fantasy Viewer i18n (English default / fallback) */
const I18n = (() => {
const STORAGE_KEY = "locale";
const SUPPORTED = ["en", "de"];
let locale = "en";
let preference = "auto";
let messages = {};
let fallback = {};
function getNested(obj, path) {
return path.split(".").reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
}
async function loadMessages(code) {
const res = await fetch(`/static/locales/${code}.json`);
if (!res.ok) throw new Error(`Locale not found: ${code}`);
return res.json();
}
function resolveLocale(pref) {
if (pref === "en" || pref === "de") return pref;
const browser = (navigator.language || "en").split("-")[0].toLowerCase();
return SUPPORTED.includes(browser) ? browser : "en";
}
async function init() {
preference = localStorage.getItem(STORAGE_KEY) || "auto";
locale = resolveLocale(preference);
fallback = await loadMessages("en");
messages = locale === "en" ? fallback : await loadMessages(locale);
document.documentElement.lang = locale;
return locale;
}
async function setPreference(pref) {
preference = pref;
localStorage.setItem(STORAGE_KEY, pref);
locale = resolveLocale(pref);
messages = locale === "en" ? fallback : await loadMessages(locale);
document.documentElement.lang = locale;
return locale;
}
function t(key, params = {}) {
let str = getNested(messages, key) ?? getNested(fallback, key) ?? key;
if (typeof str !== "string") return key;
return str.replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? String(params[k]) : `{${k}}`));
}
function translateIssue(item) {
const params = { ...(item.params || {}), field: item.field || "" };
const translated = t(`import.${item.code}`, params);
if (translated !== `import.${item.code}`) return translated;
return item.message || translated;
}
function localeTag() {
return locale === "de" ? "de-DE" : "en-US";
}
function getLocale() { return locale; }
function getPreference() { return preference; }
return { init, setPreference, t, translateIssue, localeTag, getLocale, getPreference, SUPPORTED };
})();
window.I18n = I18n;
window.t = (...args) => I18n.t(...args);
+178
View File
@@ -0,0 +1,178 @@
{
"app": {
"title": "Idle Fantasy",
"subtitle": "Save Viewer",
"loading": "Lade Save…"
},
"nav": {
"overview": "Übersicht",
"skills": "Skills",
"inventory": "Inventar",
"equipment": "Ausrüstung",
"quests": "Quests",
"combat": "Kampf",
"history": "Verlauf"
},
"settings": {
"language": "Sprache",
"langAuto": "Automatisch (Browser)",
"langEn": "English",
"langDe": "Deutsch"
},
"actions": {
"importBackup": "Backup importieren",
"compare": "Vergleichen",
"dismiss": "Schließen"
},
"empty": {
"noSave": "Kein Save importiert. Starte mit: python app.py fantasyidler_save.json",
"loadError": "Fehler beim Laden: {message}",
"unknown": "Unbekannt",
"none": "Keine",
"empty": "Leer",
"noItems": "Keine Items gefunden",
"noSnapshots": "Noch keine Snapshots. Importiere ein Backup.",
"noChanges": "Keine Änderungen",
"pickTwoSnapshots": "Wähle zwei verschiedene Snapshots."
},
"import": {
"failed": "Import fehlgeschlagen",
"duplicate": "Backup bereits vorhanden (Duplikat).",
"success": "Importiert: Snapshot #{id}",
"successWithNotes": "Importiert: Snapshot #{id}\n\n{warnings} Warnung(en), {infos} Hinweis(e) Details im Dashboard-Banner.",
"titleError": "Import-Fehler",
"titleWarning": "Import-Warnungen",
"titleInfo": "Import-Hinweise",
"countErrors": "{count} Fehler",
"countWarnings": "{count} Warnung(en)",
"countInfos": "{count} Hinweis(e)",
"newFieldsSummary": "{count} neue/unbekannte Feld(er) aus dem Spiel",
"invalid_root": "Die Datei ist kein JSON-Objekt kein gültiges Idle-Fantasy-Backup.",
"empty_save": "Die Save-Datei ist leer.",
"unknown_top_level": "Unbekanntes Feld im Backup: „{field}“ (Spiel-Update?).",
"missing_field": "Erwartetes Feld fehlt: „{field}“ zugehörige Daten werden leer angezeigt.",
"nested_json_invalid": "Feld „{field}“ konnte nicht als JSON gelesen werden Rohwert ignoriert.",
"invalid_coins": "Feld „coins“ ist nicht numerisch.",
"invalid_exported_at": "Feld „exported_at“ ist kein gültiger Zeitstempel.",
"missing_exported_at": "Kein Export-Zeitstempel Verlaufsvergleiche können ungenau sein.",
"skill_xp_mismatch": "{count} Skill(s) ohne XP-Eintrag (z. B. {examples}).",
"skill_level_mismatch": "{count} XP-Einträge ohne Skill-Level.",
"unparsed_nested_json": "Feld „{field}“ ist noch Text JSON-Inhalt konnte nicht gelesen werden.",
"invalid_type": "Feld „{field}“ hat unerwarteten Typ ({type}).",
"coerced_empty_dict": "Feld „{field}“ ist kein Objekt wird als leer behandelt.",
"coerced_empty_list": "Feld „{field}“ ist keine Liste wird übersprungen.",
"invalid_number": "Ungültiger Zahlenwert in „{field}“{detail}.",
"invalid_quest_entry": "Quest-Eintrag #{index} ist kein Objekt und wurde übersprungen.",
"invalid_session_entry": "Session-Eintrag #{index} ist kein Objekt und wurde übersprungen.",
"unparsed_session_frames": "Session #{index}: Aktivitäts-Frames konnten nicht gelesen werden.",
"invalid_pets": "Feld „pets“ ist keine Liste.",
"invalid_farming_patch": "Farming-Patch #{index} wurde übersprungen.",
"missing_character_name": "Kein Charaktername im Save gefunden.",
"invalid_quest_ids": "Quest-IDs ({label}) sind keine Liste.",
"invalid_quest_progress": "Quest-Fortschritt ({label}) ist kein Objekt."
},
"meta": {
"export": "Export",
"points": "Punkte"
},
"kpi": {
"coins": "Münzen",
"totalLevel": "Gesamtlevel",
"items": "Items",
"totalQty": "Gesamtmenge"
},
"overview": {
"character": "Charakter",
"hp": "HP",
"activePotion": "Aktiver Trank",
"activeSpell": "Aktiver Zauber",
"weaponSlot": "Waffenslot",
"blessing": "Segen",
"sessionQueue": "Session-Warteschlange",
"slayer": "Slayer",
"noSlayerTask": "Keine aktive Slayer-Aufgabe",
"pets": "Haustiere",
"farming": "Farming",
"patch": "Patch {n}",
"guildRep": "Gilden-Ruf"
},
"skills": {
"search": "Skill suchen…",
"sortLevel": "Nach Level",
"sortXp": "Nach XP",
"sortName": "Nach Name",
"skill": "Skill",
"level": "Level",
"progress": "Fortschritt"
},
"inventory": {
"search": "Item suchen…",
"sortCategory": "Nach Kategorie",
"sortName": "Nach Name",
"sortQty": "Nach Menge",
"highlightEquipped": "Ausgerüstete hervorheben",
"item": "Item",
"qty": "Menge",
"id": "ID",
"equipped": "Ausgerüstet",
"groupMeta": "{count} Items · {qty} Stk."
},
"equipment": {
"title": "Ausrüstung"
},
"quests": {
"story": "Story",
"daily": "Daily",
"weekly": "Weekly",
"guild": "Gilde",
"filterAll": "Alle",
"filterOpen": "Offen",
"filterDone": "Erledigt",
"quest": "Quest",
"progress": "Fortschritt",
"status": "Status",
"done": "Erledigt",
"open": "Offen"
},
"combat": {
"enemyKills": "Gegner-Kills",
"dungeonRuns": "Dungeon-Läufe",
"runs": "{count} Läufe",
"recentActivity": "Letzte Aktivität",
"activeSessions": "Aktive Sessions",
"sessionDone": "fertig",
"sessionRunning": "läuft"
},
"history": {
"loading": "Lade Verlauf…",
"coinsChart": "Münzen über Zeit",
"levelChart": "Gesamtlevel über Zeit",
"snapshotCompare": "Snapshot-Vergleich",
"allSnapshots": "Alle Snapshots",
"character": "Charakter",
"file": "Datei",
"inventoryChanges": "Inventar-Änderungen ({count})",
"skillChanges": "Skill-Änderungen ({count})",
"delta": "Delta",
"xpDelta": "XP-Delta",
"coinsSummary": "Münzen: {delta} · Gesamtlevel: {levelDelta}"
},
"category": {
"currency": "Währung",
"ores_mining": "Erze & Mining",
"bars_smithing": "Barren & Schmieden",
"wood_planks": "Holz & Bretter",
"runes": "Runen",
"raw_food": "Rohkost",
"cooked_food": "Gekochtes",
"seeds_farming": "Samen & Farming",
"melee_weapons": "Nahkampfwaffen",
"ranged": "Fernkampf",
"magic": "Magie",
"armor": "Rüstung",
"bones_hides": "Knochen & Felle",
"gems_jewelry": "Edelsteine & Schmuck",
"potions_brews": "Tränke & Brauerei",
"misc": "Sonstiges"
}
}
+178
View File
@@ -0,0 +1,178 @@
{
"app": {
"title": "Idle Fantasy",
"subtitle": "Save Viewer",
"loading": "Loading save…"
},
"nav": {
"overview": "Overview",
"skills": "Skills",
"inventory": "Inventory",
"equipment": "Equipment",
"quests": "Quests",
"combat": "Combat",
"history": "History"
},
"settings": {
"language": "Language",
"langAuto": "Auto (browser)",
"langEn": "English",
"langDe": "Deutsch"
},
"actions": {
"importBackup": "Import backup",
"compare": "Compare",
"dismiss": "Dismiss"
},
"empty": {
"noSave": "No save imported. Start with: python app.py fantasyidler_save.json",
"loadError": "Failed to load: {message}",
"unknown": "Unknown",
"none": "None",
"empty": "Empty",
"noItems": "No items found",
"noSnapshots": "No snapshots yet. Import a backup.",
"noChanges": "No changes",
"pickTwoSnapshots": "Select two different snapshots."
},
"import": {
"failed": "Import failed",
"duplicate": "Backup already exists (duplicate).",
"success": "Imported: Snapshot #{id}",
"successWithNotes": "Imported: Snapshot #{id}\n\n{warnings} warning(s), {infos} note(s) see dashboard banner for details.",
"titleError": "Import errors",
"titleWarning": "Import warnings",
"titleInfo": "Import notes",
"countErrors": "{count} error(s)",
"countWarnings": "{count} warning(s)",
"countInfos": "{count} info(s)",
"newFieldsSummary": "{count} new/unknown field(s) from the game",
"invalid_root": "The file is not a JSON object not a valid Idle Fantasy backup.",
"empty_save": "The save file is empty.",
"unknown_top_level": "Unknown field in backup: \"{field}\" (added by a game update?).",
"missing_field": "Expected field missing: \"{field}\" related data will be shown empty.",
"nested_json_invalid": "Field \"{field}\" could not be read as JSON raw value ignored.",
"invalid_coins": "Field \"coins\" is not numeric.",
"invalid_exported_at": "Field \"exported_at\" is not a valid timestamp.",
"missing_exported_at": "No export timestamp history comparisons may be inaccurate.",
"skill_xp_mismatch": "{count} skill(s) without XP entry (e.g. {examples}).",
"skill_level_mismatch": "{count} XP entries without skill level.",
"unparsed_nested_json": "Field \"{field}\" is still a text string JSON content could not be read.",
"invalid_type": "Field \"{field}\" has unexpected type ({type}).",
"coerced_empty_dict": "Field \"{field}\" is not an object treated as empty.",
"coerced_empty_list": "Field \"{field}\" is not a list skipped.",
"invalid_number": "Invalid number in \"{field}\"{detail}.",
"invalid_quest_entry": "Quest entry #{index} is not an object and was skipped.",
"invalid_session_entry": "Session entry #{index} is not an object and was skipped.",
"unparsed_session_frames": "Session #{index}: activity frames could not be read.",
"invalid_pets": "Field \"pets\" is not a list.",
"invalid_farming_patch": "Farming patch #{index} was skipped.",
"missing_character_name": "No character name found in save.",
"invalid_quest_ids": "Quest IDs ({label}) are not a list.",
"invalid_quest_progress": "Quest progress ({label}) is not an object."
},
"meta": {
"export": "Export",
"points": "points"
},
"kpi": {
"coins": "Coins",
"totalLevel": "Total level",
"items": "Items",
"totalQty": "Total quantity"
},
"overview": {
"character": "Character",
"hp": "HP",
"activePotion": "Active potion",
"activeSpell": "Active spell",
"weaponSlot": "Weapon slot",
"blessing": "Blessing",
"sessionQueue": "Session queue",
"slayer": "Slayer",
"noSlayerTask": "No active slayer task",
"pets": "Pets",
"farming": "Farming",
"patch": "Patch {n}",
"guildRep": "Guild reputation"
},
"skills": {
"search": "Search skills…",
"sortLevel": "By level",
"sortXp": "By XP",
"sortName": "By name",
"skill": "Skill",
"level": "Level",
"progress": "Progress"
},
"inventory": {
"search": "Search items…",
"sortCategory": "By category",
"sortName": "By name",
"sortQty": "By quantity",
"highlightEquipped": "Highlight equipped",
"item": "Item",
"qty": "Qty",
"id": "ID",
"equipped": "Equipped",
"groupMeta": "{count} items · {qty} pcs"
},
"equipment": {
"title": "Equipment"
},
"quests": {
"story": "Story",
"daily": "Daily",
"weekly": "Weekly",
"guild": "Guild",
"filterAll": "All",
"filterOpen": "Open",
"filterDone": "Completed",
"quest": "Quest",
"progress": "Progress",
"status": "Status",
"done": "Done",
"open": "Open"
},
"combat": {
"enemyKills": "Enemy kills",
"dungeonRuns": "Dungeon runs",
"runs": "{count} runs",
"recentActivity": "Recent activity",
"activeSessions": "Active sessions",
"sessionDone": "done",
"sessionRunning": "running"
},
"history": {
"loading": "Loading history…",
"coinsChart": "Coins over time",
"levelChart": "Total level over time",
"snapshotCompare": "Snapshot comparison",
"allSnapshots": "All snapshots",
"character": "Character",
"file": "File",
"inventoryChanges": "Inventory changes ({count})",
"skillChanges": "Skill changes ({count})",
"delta": "Delta",
"xpDelta": "XP delta",
"coinsSummary": "Coins: {delta} · Total level: {levelDelta}"
},
"category": {
"currency": "Currency",
"ores_mining": "Ores & Mining",
"bars_smithing": "Bars & Smithing",
"wood_planks": "Wood & Planks",
"runes": "Runes",
"raw_food": "Raw Food",
"cooked_food": "Cooked Food",
"seeds_farming": "Seeds & Farming",
"melee_weapons": "Melee Weapons",
"ranged": "Ranged",
"magic": "Magic",
"armor": "Armor",
"bones_hides": "Bones & Hides",
"gems_jewelry": "Gems & Jewelry",
"potions_brews": "Potions & Brews",
"misc": "Misc"
}
}
+82
View File
@@ -94,6 +94,22 @@ body {
.sidebar-footer {
padding: 12px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 10px;
}
.lang-label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.75rem;
color: var(--text-muted);
}
.lang-select {
width: 100%;
font-size: 0.85rem;
}
.upload-btn {
@@ -121,6 +137,72 @@ body {
.topbar { margin-bottom: 24px; }
.import-report {
margin-bottom: 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
padding: 12px 16px;
background: var(--bg-card);
}
.import-report-error {
border-color: var(--danger);
background: rgba(248, 113, 113, 0.08);
}
.import-report-warning {
border-color: var(--warning);
background: rgba(251, 191, 36, 0.08);
}
.import-report-info {
border-color: var(--accent-dim);
background: rgba(108, 140, 255, 0.08);
}
.import-report-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.import-report-counts {
color: var(--text-muted);
font-size: 0.82rem;
flex: 1;
}
.import-report-dismiss {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.import-report-list {
margin: 0;
padding-left: 1.2rem;
font-size: 0.88rem;
}
.import-issue-warning { color: var(--warning); }
.import-issue-error { color: var(--danger); }
.import-issue-info-collapsed details {
color: var(--text-muted);
cursor: pointer;
}
.import-issue-info-collapsed ul {
margin: 8px 0 0;
padding-left: 1.2rem;
color: var(--text-muted);
}
.character-header h2 {
margin: 0 0 4px;
font-size: 1.5rem;