Initial SOTF inventory editor for GitHub Pages
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# sotf-inventory
|
||||||
|
|
||||||
|
A browser based inventory editor for Sons of the Forest. Runs entirely on your computer, nothing is uploaded.
|
||||||
|
|
||||||
|
Live page: https://justinoros.github.io/sotf-inventory/
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
1. Close Sons of the Forest.
|
||||||
|
2. Open your save folder: `%USERPROFILE%\AppData\LocalLow\Endnight\SonsOfTheForest\Saves\<SteamID>\SinglePlayer\<SaveID>\`
|
||||||
|
3. Copy `SaveData.zip` somewhere safe as a backup.
|
||||||
|
4. Drag `SaveData.zip` onto the page, or click Browse.
|
||||||
|
5. Search for items and set their counts. Counts are limited from 0 to the in game max for each item.
|
||||||
|
6. Click Save to download the new `SaveData.zip`.
|
||||||
|
7. Replace the old `SaveData.zip` in your save folder with the new one and start the game.
|
||||||
|
|
||||||
|
A loose `PlayerInventorySaveData.json` also works if you prefer to edit the extracted file.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
* Unequip items in game before you save and quit. Equipped items are shown but locked.
|
||||||
|
* Items the editor doesn't recognize are marked unknown and can only be lowered, not raised.
|
||||||
|
* Blueprints also need entries in `PlayerStateSaveData.json` to show as unlocked. This tool only edits inventory.
|
||||||
|
* Browsers can't open a file dialog at a specific folder. Use Copy path and paste it into the dialog's address bar. Chrome and Edge remember the last folder you picked.
|
||||||
|
|
||||||
|
## Hosting on GitHub Pages
|
||||||
|
|
||||||
|
Push the repo, then go to Settings, Pages, and set the source to the `main` branch root.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
* `index.html` page layout
|
||||||
|
* `styles.css` theme
|
||||||
|
* `app.js` load, edit, and save logic
|
||||||
|
* `items.js` item IDs, names, categories, and max counts
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Item IDs, names, and inventory limits come from [SOTFEdit](https://github.com/codengine/SOTFEdit) by codengine, MIT License.
|
||||||
|
|
||||||
|
Not affiliated with Endnight Games.
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
(() => {
|
||||||
|
const DISCLAIMER_KEY = "sotf-inventory-disclaimer-v1";
|
||||||
|
const SAVES_PATH = "%USERPROFILE%\\AppData\\LocalLow\\Endnight\\SonsOfTheForest\\Saves";
|
||||||
|
const INVENTORY_FILE = "PlayerInventorySaveData.json";
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const els = {
|
||||||
|
dropZone: $("dropZone"),
|
||||||
|
loaderTitle: $("loaderTitle"),
|
||||||
|
loaderStatus: $("loaderStatus"),
|
||||||
|
browseBtn: $("browseBtn"),
|
||||||
|
fileInput: $("fileInput"),
|
||||||
|
savesPath: $("savesPath"),
|
||||||
|
copyPathBtn: $("copyPathBtn"),
|
||||||
|
search: $("search"),
|
||||||
|
category: $("category"),
|
||||||
|
ownedOnly: $("ownedOnly"),
|
||||||
|
resultCount: $("resultCount"),
|
||||||
|
rows: $("rows"),
|
||||||
|
table: $("inventory"),
|
||||||
|
emptyState: $("emptyState"),
|
||||||
|
changeSummary: $("changeSummary"),
|
||||||
|
resetBtn: $("resetBtn"),
|
||||||
|
saveBtn: $("saveBtn"),
|
||||||
|
disclaimer: $("disclaimer"),
|
||||||
|
disclaimerOk: $("disclaimerOk"),
|
||||||
|
saveDialog: $("saveDialog"),
|
||||||
|
saveFileName: $("saveFileName"),
|
||||||
|
saveCancel: $("saveCancel"),
|
||||||
|
saveConfirm: $("saveConfirm"),
|
||||||
|
toast: $("toast"),
|
||||||
|
toolbar: document.querySelector(".toolbar")
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
items: new Map(),
|
||||||
|
sortKey: "count",
|
||||||
|
sortDir: -1,
|
||||||
|
query: "",
|
||||||
|
category: "",
|
||||||
|
ownedOnly: false,
|
||||||
|
source: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildCatalog() {
|
||||||
|
state.items.clear();
|
||||||
|
for (const [id, name, category, max] of SOTF_ITEMS) {
|
||||||
|
state.items.set(id, { id, name, category, max, count: 0, original: 0, equipped: false, known: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCategories() {
|
||||||
|
const cats = [...new Set(SOTF_ITEMS.map((i) => i[2]))].sort();
|
||||||
|
for (const c of cats) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = c;
|
||||||
|
opt.textContent = c;
|
||||||
|
els.category.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUnknownCategory() {
|
||||||
|
const hasUnknown = [...state.items.values()].some((i) => !i.known);
|
||||||
|
const existing = els.category.querySelector('option[value="Unknown"]');
|
||||||
|
if (hasUnknown && !existing) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "Unknown";
|
||||||
|
opt.textContent = "Unknown";
|
||||||
|
els.category.appendChild(opt);
|
||||||
|
} else if (!hasUnknown && existing) {
|
||||||
|
existing.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, max) {
|
||||||
|
const n = Math.floor(Number(value));
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0;
|
||||||
|
return Math.min(n, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleItems() {
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
const qNum = /^\d+$/.test(q) ? Number(q) : null;
|
||||||
|
const list = [...state.items.values()].filter((i) => {
|
||||||
|
if (state.category && i.category !== state.category) return false;
|
||||||
|
if (state.ownedOnly && i.count === 0 && !i.equipped) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return i.name.toLowerCase().includes(q) || (qNum !== null && i.id === qNum);
|
||||||
|
});
|
||||||
|
const dir = state.sortDir;
|
||||||
|
const key = state.sortKey;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
let r;
|
||||||
|
if (key === "name" || key === "category") r = a[key].localeCompare(b[key]);
|
||||||
|
else r = a[key] - b[key];
|
||||||
|
if (r === 0) r = key === "name" ? a.id - b.id : a.name.localeCompare(b.name);
|
||||||
|
else r *= dir;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHtml(i) {
|
||||||
|
const locked = i.equipped;
|
||||||
|
const pills = [];
|
||||||
|
if (i.equipped) pills.push('<span class="pill">equipped</span>');
|
||||||
|
if (!i.known) pills.push('<span class="pill pill-warn" title="Not in the item list. Keep it as is unless you know what it is.">unknown</span>');
|
||||||
|
return `<tr data-id="${i.id}" class="${rowClass(i)}">
|
||||||
|
<td class="cell-name">${escapeHtml(i.name)}${pills.join("")}</td>
|
||||||
|
<td class="cell-cat">${escapeHtml(i.category)}</td>
|
||||||
|
<td class="cell-id">${i.id}</td>
|
||||||
|
<td class="cell-count">
|
||||||
|
<div class="stepper">
|
||||||
|
<button type="button" data-step="-1" aria-label="Remove one ${escapeHtml(i.name)}" ${locked || i.count <= 0 ? "disabled" : ""}>-</button>
|
||||||
|
<input type="number" inputmode="numeric" min="0" max="${i.max}" value="${i.count}" aria-label="${escapeHtml(i.name)} count" ${locked ? "disabled" : ""}>
|
||||||
|
<button type="button" data-step="1" aria-label="Add one ${escapeHtml(i.name)}" ${locked || i.count >= i.max ? "disabled" : ""}>+</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="cell-max"><button type="button" class="btn-link" data-fill ${locked || i.count >= i.max ? "disabled" : ""}>Fill</button>${i.max.toLocaleString()}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClass(i) {
|
||||||
|
const c = [];
|
||||||
|
if (i.count !== i.original) c.push("changed");
|
||||||
|
if (i.count === 0 && !i.equipped) c.push("zero");
|
||||||
|
return c.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const list = visibleItems();
|
||||||
|
els.rows.innerHTML = list.map(rowHtml).join("");
|
||||||
|
els.emptyState.hidden = list.length > 0;
|
||||||
|
els.resultCount.textContent = `${list.length} of ${state.items.size} items`;
|
||||||
|
for (const th of els.table.querySelectorAll("th[data-sort]")) {
|
||||||
|
if (th.dataset.sort === state.sortKey) th.setAttribute("aria-sort", state.sortDir === 1 ? "ascending" : "descending");
|
||||||
|
else th.removeAttribute("aria-sort");
|
||||||
|
}
|
||||||
|
updateSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRow(i) {
|
||||||
|
const tr = els.rows.querySelector(`tr[data-id="${i.id}"]`);
|
||||||
|
if (!tr) return;
|
||||||
|
tr.className = rowClass(i);
|
||||||
|
const input = tr.querySelector("input");
|
||||||
|
if (document.activeElement !== input || Number(input.value) !== i.count) input.value = i.count;
|
||||||
|
tr.querySelector('[data-step="-1"]').disabled = i.equipped || i.count <= 0;
|
||||||
|
tr.querySelector('[data-step="1"]').disabled = i.equipped || i.count >= i.max;
|
||||||
|
tr.querySelector("[data-fill]").disabled = i.equipped || i.count >= i.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changedItems() {
|
||||||
|
return [...state.items.values()].filter((i) => i.count !== i.original);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary() {
|
||||||
|
const n = changedItems().length;
|
||||||
|
els.changeSummary.textContent = n === 0 ? "No changes" : `${n} item${n === 1 ? "" : "s"} changed`;
|
||||||
|
els.changeSummary.classList.toggle("has-changes", n > 0);
|
||||||
|
els.resetBtn.disabled = n === 0;
|
||||||
|
els.saveBtn.disabled = !state.source || n === 0;
|
||||||
|
els.saveBtn.title = state.source ? "" : "Load a save first";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCount(id, value) {
|
||||||
|
const i = state.items.get(id);
|
||||||
|
if (!i || i.equipped) return;
|
||||||
|
i.count = clamp(value, i.max);
|
||||||
|
updateRow(i);
|
||||||
|
updateSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
function toast(msg, isError) {
|
||||||
|
els.toast.textContent = msg;
|
||||||
|
els.toast.classList.toggle("error", !!isError);
|
||||||
|
els.toast.classList.add("show");
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => els.toast.classList.remove("show"), isError ? 7000 : 4500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInventory(text) {
|
||||||
|
let outer;
|
||||||
|
try {
|
||||||
|
outer = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${INVENTORY_FILE} isn't valid JSON. The save may be damaged.`);
|
||||||
|
}
|
||||||
|
const raw = outer && outer.Data && outer.Data.PlayerInventory;
|
||||||
|
if (raw === undefined) throw new Error(`${INVENTORY_FILE} has no PlayerInventory data. This doesn't look like a Sons of the Forest save.`);
|
||||||
|
const innerIsString = typeof raw === "string";
|
||||||
|
const inner = innerIsString ? JSON.parse(raw) : raw;
|
||||||
|
const blocks = inner && inner.ItemInstanceManagerData && inner.ItemInstanceManagerData.ItemBlocks;
|
||||||
|
if (!Array.isArray(blocks)) throw new Error("The inventory has no item list. This save format isn't supported.");
|
||||||
|
return { outer, inner, innerIsString };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInventory(inner) {
|
||||||
|
buildCatalog();
|
||||||
|
const addUnknown = (id, count) => {
|
||||||
|
const name = SOTF_NAMES[id] || `Unknown item ${id}`;
|
||||||
|
const item = { id, name, category: "Unknown", max: Math.max(count, 1), count: 0, original: 0, equipped: false, known: false };
|
||||||
|
state.items.set(id, item);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
for (const block of inner.ItemInstanceManagerData.ItemBlocks) {
|
||||||
|
const id = Number(block.ItemId);
|
||||||
|
const count = Math.max(0, Number(block.TotalCount) || 0);
|
||||||
|
const item = state.items.get(id) || addUnknown(id, count);
|
||||||
|
item.count += count;
|
||||||
|
if (item.count > item.max) item.max = item.count;
|
||||||
|
}
|
||||||
|
if (Array.isArray(inner.EquippedItems)) {
|
||||||
|
for (const block of inner.EquippedItems) {
|
||||||
|
const id = Number(block.ItemId);
|
||||||
|
const item = state.items.get(id) || addUnknown(id, 0);
|
||||||
|
item.equipped = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of state.items.values()) item.original = item.count;
|
||||||
|
ensureUnknownCategory();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const isJson = /\.json$/i.test(file.name);
|
||||||
|
let zip = null;
|
||||||
|
let path = INVENTORY_FILE;
|
||||||
|
let text;
|
||||||
|
if (isJson) {
|
||||||
|
text = await file.text();
|
||||||
|
} else {
|
||||||
|
zip = await JSZip.loadAsync(file);
|
||||||
|
const entry = Object.values(zip.files).find((f) => !f.dir && f.name.split("/").pop() === INVENTORY_FILE);
|
||||||
|
if (!entry) throw new Error(`${file.name} has no ${INVENTORY_FILE}. Pick the SaveData.zip inside one of your save folders.`);
|
||||||
|
path = entry.name;
|
||||||
|
text = await entry.async("string");
|
||||||
|
}
|
||||||
|
const parsed = parseInventory(text);
|
||||||
|
applyInventory(parsed.inner);
|
||||||
|
state.source = { kind: isJson ? "json" : "zip", zip, path, fileName: isJson ? INVENTORY_FILE : "SaveData.zip", ...parsed };
|
||||||
|
state.sortKey = "count";
|
||||||
|
state.sortDir = -1;
|
||||||
|
const owned = [...state.items.values()].filter((i) => i.count > 0).length;
|
||||||
|
els.dropZone.classList.add("loaded");
|
||||||
|
els.loaderTitle.textContent = `Loaded ${file.name}`;
|
||||||
|
els.loaderStatus.textContent = `${owned} item types in your inventory. Drop another file to switch saves.`;
|
||||||
|
render();
|
||||||
|
toast(`Loaded ${file.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast(err && err.message ? err.message : `Couldn't read ${file.name}.`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInventoryText() {
|
||||||
|
const { outer, inner, innerIsString } = state.source;
|
||||||
|
const blocks = inner.ItemInstanceManagerData.ItemBlocks;
|
||||||
|
const removed = new Set();
|
||||||
|
for (const item of changedItems()) {
|
||||||
|
if (item.equipped) continue;
|
||||||
|
const matches = blocks.filter((b) => Number(b.ItemId) === item.id);
|
||||||
|
if (item.count === 0) {
|
||||||
|
removed.add(item.id);
|
||||||
|
for (const b of matches) blocks.splice(blocks.indexOf(b), 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (matches.length === 0) {
|
||||||
|
blocks.push({ ItemId: item.id, TotalCount: item.count, UniqueItems: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [first, ...rest] = matches;
|
||||||
|
first.TotalCount = item.count;
|
||||||
|
if (Array.isArray(first.UniqueItems) && first.UniqueItems.length > item.count) {
|
||||||
|
first.UniqueItems = first.UniqueItems.slice(0, item.count);
|
||||||
|
}
|
||||||
|
for (const b of rest) blocks.splice(blocks.indexOf(b), 1);
|
||||||
|
}
|
||||||
|
const slots = inner.QuickSelect && inner.QuickSelect.Slots;
|
||||||
|
if (Array.isArray(slots) && removed.size) {
|
||||||
|
inner.QuickSelect.Slots = slots.filter((s) => !(s && removed.has(Number(s.ItemId))));
|
||||||
|
}
|
||||||
|
outer.Data.PlayerInventory = innerIsString ? JSON.stringify(inner) : inner;
|
||||||
|
return JSON.stringify(outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(blob, name) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave() {
|
||||||
|
try {
|
||||||
|
const text = buildInventoryText();
|
||||||
|
const src = state.source;
|
||||||
|
let blob;
|
||||||
|
if (src.kind === "zip") {
|
||||||
|
src.zip.file(src.path, text);
|
||||||
|
blob = await src.zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } });
|
||||||
|
} else {
|
||||||
|
blob = new Blob([text], { type: "application/json" });
|
||||||
|
}
|
||||||
|
download(blob, src.fileName);
|
||||||
|
for (const item of state.items.values()) item.original = item.count;
|
||||||
|
render();
|
||||||
|
toast(`Downloaded ${src.fileName}. Replace the file in your save folder, then start the game.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast(`Couldn't build the new save: ${err.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browse() {
|
||||||
|
if (window.showOpenFilePicker) {
|
||||||
|
try {
|
||||||
|
const [handle] = await window.showOpenFilePicker({
|
||||||
|
id: "sotf-saves",
|
||||||
|
startIn: "documents",
|
||||||
|
types: [{ description: "Sons of the Forest save", accept: { "application/zip": [".zip"], "application/json": [".json"] } }]
|
||||||
|
});
|
||||||
|
loadFile(await handle.getFile());
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.name === "AbortError") return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
els.fileInput.value = "";
|
||||||
|
els.fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToolbarOffset() {
|
||||||
|
document.documentElement.style.setProperty("--toolbar-h", `${els.toolbar.offsetHeight}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
els.savesPath.textContent = SAVES_PATH;
|
||||||
|
els.copyPathBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(SAVES_PATH);
|
||||||
|
toast("Path copied. Paste it into the file dialog's address bar.");
|
||||||
|
} catch {
|
||||||
|
toast("Couldn't copy. Select the path and copy it manually.", true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
els.browseBtn.addEventListener("click", browse);
|
||||||
|
els.fileInput.addEventListener("change", () => loadFile(els.fileInput.files[0]));
|
||||||
|
|
||||||
|
let dragDepth = 0;
|
||||||
|
const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes("Files");
|
||||||
|
window.addEventListener("dragover", (e) => { if (hasFiles(e)) e.preventDefault(); });
|
||||||
|
window.addEventListener("drop", (e) => { if (hasFiles(e)) e.preventDefault(); });
|
||||||
|
els.dropZone.addEventListener("dragenter", (e) => {
|
||||||
|
if (!hasFiles(e)) return;
|
||||||
|
dragDepth++;
|
||||||
|
els.dropZone.classList.add("dragging");
|
||||||
|
});
|
||||||
|
els.dropZone.addEventListener("dragleave", () => {
|
||||||
|
dragDepth = Math.max(0, dragDepth - 1);
|
||||||
|
if (dragDepth === 0) els.dropZone.classList.remove("dragging");
|
||||||
|
});
|
||||||
|
els.dropZone.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth = 0;
|
||||||
|
els.dropZone.classList.remove("dragging");
|
||||||
|
loadFile(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
els.dropZone.addEventListener("keydown", (e) => {
|
||||||
|
if (e.target === els.dropZone && (e.key === "Enter" || e.key === " ")) {
|
||||||
|
e.preventDefault();
|
||||||
|
browse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let searchTimer;
|
||||||
|
els.search.addEventListener("input", () => {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => { state.query = els.search.value; render(); }, 80);
|
||||||
|
});
|
||||||
|
els.category.addEventListener("change", () => { state.category = els.category.value; render(); });
|
||||||
|
els.ownedOnly.addEventListener("change", () => { state.ownedOnly = els.ownedOnly.checked; render(); });
|
||||||
|
|
||||||
|
els.table.querySelector("thead").addEventListener("click", (e) => {
|
||||||
|
const th = e.target.closest("th[data-sort]");
|
||||||
|
if (!th) return;
|
||||||
|
const key = th.dataset.sort;
|
||||||
|
if (state.sortKey === key) state.sortDir *= -1;
|
||||||
|
else {
|
||||||
|
state.sortKey = key;
|
||||||
|
state.sortDir = key === "name" || key === "category" ? 1 : -1;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.rows.addEventListener("click", (e) => {
|
||||||
|
const tr = e.target.closest("tr[data-id]");
|
||||||
|
if (!tr) return;
|
||||||
|
const id = Number(tr.dataset.id);
|
||||||
|
const item = state.items.get(id);
|
||||||
|
const step = e.target.closest("[data-step]");
|
||||||
|
if (step) {
|
||||||
|
const amount = Number(step.dataset.step) * (e.shiftKey ? 10 : 1);
|
||||||
|
setCount(id, item.count + amount);
|
||||||
|
} else if (e.target.closest("[data-fill]")) {
|
||||||
|
setCount(id, item.max);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
els.rows.addEventListener("input", (e) => {
|
||||||
|
if (e.target.tagName !== "INPUT") return;
|
||||||
|
const id = Number(e.target.closest("tr").dataset.id);
|
||||||
|
const item = state.items.get(id);
|
||||||
|
if (e.target.value === "") return;
|
||||||
|
const v = clamp(e.target.value, item.max);
|
||||||
|
if (String(v) !== e.target.value) e.target.value = v;
|
||||||
|
setCount(id, v);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.rows.addEventListener("focusout", (e) => {
|
||||||
|
if (e.target.tagName !== "INPUT") return;
|
||||||
|
const id = Number(e.target.closest("tr").dataset.id);
|
||||||
|
const item = state.items.get(id);
|
||||||
|
if (item) e.target.value = item.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
els.rows.addEventListener("keydown", (e) => {
|
||||||
|
if (e.target.tagName === "INPUT" && e.key === "Enter") e.target.blur();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.resetBtn.addEventListener("click", () => {
|
||||||
|
for (const item of state.items.values()) item.count = item.original;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.saveBtn.addEventListener("click", () => {
|
||||||
|
if (!state.source) return;
|
||||||
|
els.saveFileName.textContent = state.source.fileName;
|
||||||
|
els.saveDialog.showModal();
|
||||||
|
});
|
||||||
|
els.saveCancel.addEventListener("click", () => els.saveDialog.close());
|
||||||
|
els.saveConfirm.addEventListener("click", () => {
|
||||||
|
els.saveDialog.close();
|
||||||
|
doSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
els.disclaimerOk.addEventListener("click", () => {
|
||||||
|
try { localStorage.setItem(DISCLAIMER_KEY, "1"); } catch {}
|
||||||
|
els.disclaimer.close();
|
||||||
|
});
|
||||||
|
els.disclaimer.addEventListener("cancel", (e) => e.preventDefault());
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", (e) => {
|
||||||
|
if (changedItems().length) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", setToolbarOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDisclaimerIfNeeded() {
|
||||||
|
let seen = false;
|
||||||
|
try { seen = localStorage.getItem(DISCLAIMER_KEY) === "1"; } catch {}
|
||||||
|
if (!seen) els.disclaimer.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildCatalog();
|
||||||
|
fillCategories();
|
||||||
|
bind();
|
||||||
|
render();
|
||||||
|
setToolbarOffset();
|
||||||
|
showDisclaimerIfNeeded();
|
||||||
|
})();
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SOTF Inventory Editor</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌲</text></svg>">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600&family=IM+Fell+English:ital@0;1&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero-inner">
|
||||||
|
<h1>Inventory</h1>
|
||||||
|
<p class="subtitle">A save editor for Sons of the Forest. Load your save, set your counts, download the new file.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="loader" id="dropZone" tabindex="0" aria-label="Load a save file">
|
||||||
|
<div class="loader-text">
|
||||||
|
<h2 id="loaderTitle">Drop your SaveData.zip here</h2>
|
||||||
|
<p id="loaderStatus">No save loaded. You can browse and edit items, but saving needs a loaded save.</p>
|
||||||
|
</div>
|
||||||
|
<div class="loader-actions">
|
||||||
|
<button type="button" class="btn" id="browseBtn">Browse</button>
|
||||||
|
<input type="file" id="fileInput" accept=".zip,.json" hidden>
|
||||||
|
</div>
|
||||||
|
<div class="path-hint">
|
||||||
|
<span>Saves folder</span>
|
||||||
|
<code id="savesPath"></code>
|
||||||
|
<button type="button" class="btn-link" id="copyPathBtn">Copy path</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="toolbar">
|
||||||
|
<input type="search" id="search" placeholder="Search items by name or ID" autocomplete="off" aria-label="Search items">
|
||||||
|
<select id="category" aria-label="Filter by category">
|
||||||
|
<option value="">All categories</option>
|
||||||
|
</select>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="ownedOnly">
|
||||||
|
<span>Owned only</span>
|
||||||
|
</label>
|
||||||
|
<span class="count-label" id="resultCount"></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="inventory">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="name" class="col-name"><button type="button">Item</button></th>
|
||||||
|
<th data-sort="category" class="col-cat"><button type="button">Category</button></th>
|
||||||
|
<th data-sort="id" class="col-id"><button type="button">ID</button></th>
|
||||||
|
<th data-sort="count" class="col-count"><button type="button">Count</button></th>
|
||||||
|
<th data-sort="max" class="col-max"><button type="button">Max</button></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<p class="empty" id="emptyState" hidden>No items match that search. Clear the search or pick another category.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="savebar">
|
||||||
|
<span id="changeSummary">No changes</span>
|
||||||
|
<div class="savebar-actions">
|
||||||
|
<button type="button" class="btn-link" id="resetBtn" disabled>Undo changes</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveBtn" disabled>Save</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<dialog id="disclaimer">
|
||||||
|
<h2>Close the game first</h2>
|
||||||
|
<p>Quit Sons of the Forest before you edit or replace a save. The game rewrites its save files while running and can overwrite your changes or corrupt the save.</p>
|
||||||
|
<p>Unequip items in game before you save and quit. Equipped items are shown here but can't be edited.</p>
|
||||||
|
<p>This is a fan tool with no connection to Endnight Games. Your save never leaves your computer.</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="disclaimerOk">I understand</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="saveDialog">
|
||||||
|
<h2>Download the new save</h2>
|
||||||
|
<p>You'll get a new <strong id="saveFileName">SaveData.zip</strong> with your changes.</p>
|
||||||
|
<p>Keep your original file as a backup. Copy it somewhere safe before you replace it, in case the modified save doesn't load.</p>
|
||||||
|
<p>Then put the new file in your save folder, replacing the old one, and start the game.</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn-link" id="saveCancel">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveConfirm">Download</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||||
|
<script src="items.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
const SOTF_ITEMS = [
|
||||||
|
[78, "Log", "Log", 2],
|
||||||
|
[340, "Guitar", "Weapon", 1],
|
||||||
|
[341, "Binoculars", "Equipment", 1],
|
||||||
|
[346, "Shotgun Rail", "Weapon Modification", 1],
|
||||||
|
[353, "Stun Gun", "Weapon", 1],
|
||||||
|
[354, "Night Vision Goggles", "Equipment", 1],
|
||||||
|
[355, "Compact Pistol", "Weapon", 1],
|
||||||
|
[356, "Modern Axe", "Weapon", 1],
|
||||||
|
[358, "Shotgun", "Weapon", 1],
|
||||||
|
[359, "Machete", "Weapon", 1],
|
||||||
|
[360, "Compound/Tactical Bow", "Weapon", 1],
|
||||||
|
[361, "Rifle", "Weapon", 1],
|
||||||
|
[362, "Pistol Ammo", "Ammo", 1000],
|
||||||
|
[363, "Shotgun Slug", "Ammo", 1000],
|
||||||
|
[364, "Shotgun Buckshot", "Ammo", 1000],
|
||||||
|
[365, "Crossbow", "Weapon", 1],
|
||||||
|
[367, "Katana", "Weapon", 1],
|
||||||
|
[368, "Crossbow Bolt", "Ammo", 20],
|
||||||
|
[369, "Stun Gun Ammo", "Ammo", 1000],
|
||||||
|
[373, "Carbon Fiber Arrow", "Ammo", 20],
|
||||||
|
[374, "Pistol Suppressor/Silencer", "Weapon Modification", 1],
|
||||||
|
[375, "Laser Sight", "Weapon Modification", 1],
|
||||||
|
[376, "Pistol Rail", "Weapon Modification", 1],
|
||||||
|
[378, "Flashlight Attachment", "Attachment", 1],
|
||||||
|
[379, "Tactical Axe", "Weapon", 1],
|
||||||
|
[380, "Utility Knife", "Weapon", 1],
|
||||||
|
[381, "Grenade", "Weapon", 5],
|
||||||
|
[382, "GrenadeAmmo", "Ammo", 10],
|
||||||
|
[383, "Rifle Rail Mod", "Weapon Modification", 1],
|
||||||
|
[386, "Revolver", "Weapon", 1],
|
||||||
|
[387, "Rifle Ammo", "Ammo", 1000],
|
||||||
|
[388, "Molotov", "Weapon", 6],
|
||||||
|
[390, "Printer Resin", "Printed", 1000],
|
||||||
|
[391, "Red Mask", "Equipment", 1],
|
||||||
|
[392, "Stick", "Misc", 20],
|
||||||
|
[393, "Rock", "Misc", 10],
|
||||||
|
[394, "Chainsaw", "Weapon", 1],
|
||||||
|
[396, "Stun Baton", "Weapon", 1],
|
||||||
|
[397, "Shiitake", "Plant", 20],
|
||||||
|
[398, "King Oyster", "Food", 20],
|
||||||
|
[399, "Hydnum Repandum", "Plant", 20],
|
||||||
|
[400, "Fly Amanita", "Misc", 20],
|
||||||
|
[401, "Turtle Egg", "Misc", 4],
|
||||||
|
[402, "Backpack", "Container", 1],
|
||||||
|
[403, "Rope", "Misc", 10],
|
||||||
|
[405, "Bone", "Body Part", 16],
|
||||||
|
[410, "Watch", "Misc", 10],
|
||||||
|
[412, "GPS Tracker", "Misc", 1],
|
||||||
|
[413, "Plasma Lighter", "Printed", 1],
|
||||||
|
[414, "Vodka Bottle", "Misc", 6],
|
||||||
|
[415, "Cloth", "Misc", 1000],
|
||||||
|
[416, "Circuit Board", "Misc", 6],
|
||||||
|
[417, "Time Bomb", "Weapon", 3],
|
||||||
|
[418, "Wire", "Misc", 100],
|
||||||
|
[419, "Duct Tape", "Misc", 100],
|
||||||
|
[420, "C4 Brick", "Misc", 3],
|
||||||
|
[421, "Ramen Noodles", "Food", 4],
|
||||||
|
[422, "Repair Tool", "Tool", 1],
|
||||||
|
[423, "Novel [Pennant Line]", "Book", 1],
|
||||||
|
[424, "Novel [Deep Sleep]", "Book", 1],
|
||||||
|
[425, "Crunchie Wunchies", "Food", 4],
|
||||||
|
[426, "Printed Flask", "Printed", 1],
|
||||||
|
[428, "Printed Sled", "Printed", 1],
|
||||||
|
[430, "Skull", "Body Part", 3],
|
||||||
|
[431, "Firefighter Axe", "Weapon", 1],
|
||||||
|
[432, "Can Opener", "Tool", 1],
|
||||||
|
[433, "Meat", "Food", 7],
|
||||||
|
[434, "Canned Food", "Food", 6],
|
||||||
|
[435, "Golden Mask", "Equipment", 1],
|
||||||
|
[436, "Fish", "Food", 3],
|
||||||
|
[437, "Meds", "Consumable", 6],
|
||||||
|
[438, "MRE Pack", "Food", 6],
|
||||||
|
[439, "Energy Drink", "Food", 10],
|
||||||
|
[440, "Flare", "Misc", 10],
|
||||||
|
[441, "Energy Bar", "Food", 10],
|
||||||
|
[443, "Crafted Bow", "Weapon", 1],
|
||||||
|
[444, "Rebreather", "Equipment", 1],
|
||||||
|
[445, "Blueberries", "Food", 20],
|
||||||
|
[446, "Twinberries", "Food", 20],
|
||||||
|
[447, "Salmonberries", "Food", 20],
|
||||||
|
[448, "Snowberries", "Food", 20],
|
||||||
|
[449, "Devil's Club", "Plant", 20],
|
||||||
|
[450, "Horsetail", "Plant", 20],
|
||||||
|
[451, "Aloe Vera", "Plant", 20],
|
||||||
|
[452, "Yarrow", "Plant", 20],
|
||||||
|
[453, "Fireweed", "Plant", 20],
|
||||||
|
[454, "Arrowleaf", "Plant", 20],
|
||||||
|
[455, "Health Mix", "Consumable", 3],
|
||||||
|
[456, "Health Mix+", "Consumable", 3],
|
||||||
|
[457, "Stun Gun Ammo", "Ammo", 1],
|
||||||
|
[459, "Slingshot", "Weapon", 1],
|
||||||
|
[461, "Energy Mix", "Consumable", 3],
|
||||||
|
[462, "Energy Mix+", "Consumable", 3],
|
||||||
|
[464, "Cat Food", "Food", 1],
|
||||||
|
[465, "Chicory", "Plant", 20],
|
||||||
|
[466, "Oyster", "Food", 5],
|
||||||
|
[467, "Album Cover", "Document", 1],
|
||||||
|
[468, "Cross", "Weapon", 1],
|
||||||
|
[469, "Air Canister", "Misc", 4],
|
||||||
|
[471, "Flashlight", "Equipment", 1],
|
||||||
|
[472, "Deer Hide", "Misc", 5],
|
||||||
|
[473, "Leaf Armour", "Armor", 10],
|
||||||
|
[474, "Crafted Spear", "Weapon", 5],
|
||||||
|
[476, "Small Rock", "Misc", 30],
|
||||||
|
[477, "Crafted Club", "Weapon", 1],
|
||||||
|
[479, "Feather", "Misc", 1000],
|
||||||
|
[480, "Severed Arm", "Body Part", 2],
|
||||||
|
[481, "Severed Leg", "Body Part", 2],
|
||||||
|
[482, "Severed Head", "Body Part", 1],
|
||||||
|
[483, "Emergency Pack", "Food", 1],
|
||||||
|
[484, "Leaf", "Misc", 1000],
|
||||||
|
[485, "Shovel", "Tool", 1],
|
||||||
|
[486, "Walkie Talkie", "Equipment", 1],
|
||||||
|
[487, "Silk Pajamas", "Clothes", 1],
|
||||||
|
[489, "Tactical Pants", "Clothes", 1],
|
||||||
|
[490, "Hoodie", "Clothes", 1],
|
||||||
|
[491, "Blazer", "Clothes", 1],
|
||||||
|
[492, "Tuxedo", "Clothes", 1],
|
||||||
|
[493, "Leather Jacket", "Clothes", 1],
|
||||||
|
[494, "Bone Armour", "Armor", 10],
|
||||||
|
[495, "Tactical Jacket", "Clothes", 2],
|
||||||
|
[496, "Money", "Misc", 1000000],
|
||||||
|
[499, "Wetsuit", "Clothes", 1],
|
||||||
|
[500, "Winter Jacket", "Clothes", 1],
|
||||||
|
[501, "Tactical Boots", "Clothes", 1],
|
||||||
|
[502, "Coins", "Misc", 1000],
|
||||||
|
[503, "Torch", "Misc", 1],
|
||||||
|
[504, "Tarp", "Misc", 5],
|
||||||
|
[506, "Turtle Shell", "Misc", 3],
|
||||||
|
[507, "Stone Arrow", "Ammo", 20],
|
||||||
|
[508, "Skin Pouch", "Misc", 6],
|
||||||
|
[509, "Holosprings Pamphlet", "Document", 1],
|
||||||
|
[517, "Cooking Pot", "Tool", 1],
|
||||||
|
[518, "Paper Target", "Misc", 50],
|
||||||
|
[519, "Deer Hide Armour", "Armor", 10],
|
||||||
|
[521, "Email [More Lighting]", "Document", 1],
|
||||||
|
[522, "Rope Gun", "Equipment", 1],
|
||||||
|
[523, "Zipline Rope", "Misc", 4],
|
||||||
|
[524, "Golf Ball", "Misc", 8],
|
||||||
|
[525, "Putter", "Weapon", 1],
|
||||||
|
[526, "Mr Puffy Will", "Document", 1],
|
||||||
|
[527, "Batteries", "Electricity", 6],
|
||||||
|
[528, "MrPuffy Financials", "Document", 1],
|
||||||
|
[529, "GPS Locator", "Misc", 10],
|
||||||
|
[530, "Drying Rack Blueprint", "Blueprint", 1],
|
||||||
|
[531, "Stick Chair Blueprint", "Blueprint", 1],
|
||||||
|
[532, "Stick Bed Blueprint", "Blueprint", 1],
|
||||||
|
[533, "Weapon Rack Blueprint", "Blueprint", 1],
|
||||||
|
[534, "Golf Email Pt 1", "Document", 1],
|
||||||
|
[535, "Golf Email Pt 2", "Document", 1],
|
||||||
|
[536, "Golf Email Pt 3", "Document", 1],
|
||||||
|
[537, "Golf Email Pt 4", "Document", 1],
|
||||||
|
[538, "Painting Email", "Document", 1],
|
||||||
|
[539, "Document [Out Demonia]", "Document", 1],
|
||||||
|
[540, "Document [Crosses burn Demons]", "Document", 1],
|
||||||
|
[541, "Document [Cube Activation Cycles]", "Document", 1],
|
||||||
|
[542, "Email [Thought you might find this interesting]", "Document", 1],
|
||||||
|
[543, "Email [Another one]", "Document", 1],
|
||||||
|
[544, "Email [One more]", "Document", 1],
|
||||||
|
[545, "Email [Photo Reference]", "Document", 1],
|
||||||
|
[546, "Email [Note to all employees]", "Document", 1],
|
||||||
|
[547, "Email [Safe in Cube]", "Document", 1],
|
||||||
|
[548, "Document [Stay in the Cube]", "Document", 1],
|
||||||
|
[549, "Bone Maker Blueprint", "Blueprint", 1],
|
||||||
|
[550, "Fly Swatter Blueprint", "Blueprint", 1],
|
||||||
|
[551, "Small Animal Catcher Blueprint", "Blueprint", 1],
|
||||||
|
[552, "S.C.R.E.W.", "Document", 1],
|
||||||
|
[553, "Tech Mesh", "Misc", 10],
|
||||||
|
[554, "Tech Armour", "Armor", 10],
|
||||||
|
[555, "Track Suit", "Clothes", 1],
|
||||||
|
[556, "Dress", "Clothes", 1],
|
||||||
|
[557, "Leather Suit", "Clothes", 1],
|
||||||
|
[558, "Camouflage Suit", "Clothes", 1],
|
||||||
|
[560, "Printed Grappling Hook", "Printed", 2],
|
||||||
|
[562, "HolovilleNoticeA", "Document", 1],
|
||||||
|
[563, "HolovilleNoticeB", "Document", 1],
|
||||||
|
[564, "HolovilleNoticeC", "Document", 1],
|
||||||
|
[565, "HolovilleNoticeD", "Document", 1],
|
||||||
|
[566, "Maintenance Key Card", "Key", 1],
|
||||||
|
[567, "Guest Key Card", "Key", 1],
|
||||||
|
[568, "VIP Key Card", "Key", 1],
|
||||||
|
[569, "Brain Bite", "Food", 1],
|
||||||
|
[570, "Steak Bite", "Food", 1],
|
||||||
|
[571, "Steak and Bacon Bite", "Food", 1],
|
||||||
|
[572, "Ancient Armor", "Armor", 1],
|
||||||
|
[574, "Novel [Rescued]", "Book", 1],
|
||||||
|
[575, "Novel [Parallel Universes]", "Book", 1],
|
||||||
|
[579, "Bone Chair Blueprint", "Blueprint", 1],
|
||||||
|
[580, "Stick Ladder Blueprint", "Blueprint", 1],
|
||||||
|
[581, "Bird House Blueprint", "Blueprint", 1],
|
||||||
|
[582, "Fish Trap Blueprint", "Blueprint", 1],
|
||||||
|
[584, "Mannequin Blueprint", "Blueprint", 1],
|
||||||
|
[585, "Scarecrow Blueprint", "Blueprint", 1],
|
||||||
|
[586, "Planter Wall Blueprint", "Blueprint", 1],
|
||||||
|
[587, "Planter Floor Blueprint", "Blueprint", 1],
|
||||||
|
[588, "Bone Chandelier Blueprint", "Blueprint", 1],
|
||||||
|
[589, "S.C.R.E.W. / Tutorials", "Document", 1],
|
||||||
|
[590, "Radio", "Misc", 1],
|
||||||
|
[591, "Small Log Cabin Blueprint", "Blueprint", 1],
|
||||||
|
[593, "Creepy Armour", "Armor", 10],
|
||||||
|
[594, "Guarana Berries", "Plant", 20],
|
||||||
|
[595, "Blackberries", "Food", 20],
|
||||||
|
[596, "AloeVera Seed", "Seed", 20],
|
||||||
|
[597, "Arrowleaf Seed", "Seed", 20],
|
||||||
|
[598, "Blackberry Seed", "Seed", 20],
|
||||||
|
[599, "Blueberry Seed", "Seed", 20],
|
||||||
|
[600, "DevilsClub Seed", "Seed", 20],
|
||||||
|
[601, "Fireweed Seed", "Seed", 20],
|
||||||
|
[602, "Guarana Seed", "Seed", 20],
|
||||||
|
[603, "Horsetail Seed", "Seed", 20],
|
||||||
|
[604, "Salmonberry Seed", "Seed", 20],
|
||||||
|
[605, "Chicory Seed", "Seed", 20],
|
||||||
|
[606, "Yarro Seed", "Seed", 20],
|
||||||
|
[607, "Rock Storage Blueprint", "Blueprint", 1],
|
||||||
|
[608, "Stone Storage Blueprint", "Blueprint", 1],
|
||||||
|
[609, "Log Storage Blueprint", "Blueprint", 1],
|
||||||
|
[610, "Sticks Storage Blueprint", "Blueprint", 1],
|
||||||
|
[611, "Numbers Printout", "Document", 1],
|
||||||
|
[612, "Newspaper Cutout [Missing CEO]", "Document", 1],
|
||||||
|
[613, "Newspaper Cutout [Puffcorp]", "Document", 1],
|
||||||
|
[614, "E-Mail [Artifact - Preliminary Findings]", "Document", 1],
|
||||||
|
[615, "E-Mail [Scientific Data - Cube]", "Document", 1],
|
||||||
|
[616, "Classified Document", "Document", 1],
|
||||||
|
[617, "Stock Price", "Document", 1],
|
||||||
|
[618, "Printed Arrow", "Ammo", 20],
|
||||||
|
[619, "Swim Suit", "Clothes", 1],
|
||||||
|
[620, "Table Blueprint", "Blueprint", 1],
|
||||||
|
[621, "Shelf Blueprint", "Blueprint", 1],
|
||||||
|
[622, "Rock Path Blueprint", "Blueprint", 1],
|
||||||
|
[623, "Wall Shelf Blueprint", "Blueprint", 1],
|
||||||
|
[624, "Wall Torch Blueprint", "Blueprint", 1],
|
||||||
|
[625, "Hanging Skull Light Blueprint", "Blueprint", 1],
|
||||||
|
[626, "Foldable Glider", "Misc", 1],
|
||||||
|
[627, "Mr. Puffton's Reservation Card", "Document", 1],
|
||||||
|
[628, "Mrs. Puffton's Reservation Card", "Document", 1],
|
||||||
|
[629, "Rain Catcher Blueprint", "Blueprint", 1],
|
||||||
|
[630, "KnightV", "Misc", 1],
|
||||||
|
[631, "The Realm Beyond", "Book", 1],
|
||||||
|
[632, "Spring Trap Blueprint", "Blueprint", 1],
|
||||||
|
[633, "Cult Pamphlet", "Document", 1],
|
||||||
|
[634, "Solar Panel", "Electricity", 3],
|
||||||
|
[635, "Light Bulb", "Electricity", 8],
|
||||||
|
[636, "TemplateD", "Misc", 30],
|
||||||
|
[637, "Action Camera", "Equipment", 1],
|
||||||
|
[638, "Basic Log Sled Blueprint", "Blueprint", 1],
|
||||||
|
[639, "Space Suit", "Clothes", 1],
|
||||||
|
[640, "Stone", "Misc", 1],
|
||||||
|
[641, "Stone Fireplace Blueprint", "Blueprint", 1],
|
||||||
|
[642, "Duck Head", "Trophy", 1],
|
||||||
|
[643, "Eagle Head", "Trophy", 1],
|
||||||
|
[644, "Small Turtle Head", "Trophy", 1],
|
||||||
|
[645, "Head Trophy Mount Blueprint", "Blueprint", 1],
|
||||||
|
[646, "Rabbit Head", "Trophy", 1],
|
||||||
|
[647, "Squirrel Head", "Trophy", 1],
|
||||||
|
[648, "Seagull Head", "Trophy", 1],
|
||||||
|
[649, "Stones Storage Blueprint", "Blueprint", 1],
|
||||||
|
[650, "Turtle Head", "Trophy", 1],
|
||||||
|
[651, "Deer Head", "Trophy", 1],
|
||||||
|
[652, "Moose Head", "Trophy", 1],
|
||||||
|
[653, "Personal Note A", "Document", 1],
|
||||||
|
[654, "Personal Note B", "Document", 1],
|
||||||
|
[655, "Personal Note C", "Document", 1],
|
||||||
|
[656, "Blueprint Hokey Pokey Trap", "Blueprint", 1],
|
||||||
|
[657, "Printed GPS Case", "Misc", 5],
|
||||||
|
[658, "Radio Trap Blueprint", "Blueprint", 1],
|
||||||
|
[659, "Radio Blueprint", "Blueprint", 1],
|
||||||
|
[661, "Golf Cart Battery", "Electricity", 3],
|
||||||
|
[662, "Artifact Piece A", "Misc", 1],
|
||||||
|
[663, "Pickaxe", "Misc", 1],
|
||||||
|
[664, "Solafite Ore", "Misc", 20],
|
||||||
|
[665, "Item Plating Blueprint", "Blueprint", 1],
|
||||||
|
[666, "Powered Cross Blueprint", "Blueprint", 1],
|
||||||
|
[667, "Artifact Piece B", "Misc", 1],
|
||||||
|
[668, "Artifact Piece C", "Misc", 1],
|
||||||
|
[669, "Artifact Piece D", "Misc", 1],
|
||||||
|
[670, "Gore Chair Blueprint", "Blueprint", 1],
|
||||||
|
[671, "Gore Couch Blueprint", "Blueprint", 1],
|
||||||
|
[672, "Uber Trap Blueprint", "Blueprint", 1],
|
||||||
|
[673, "Round Table Blueprint", "Blueprint", 1],
|
||||||
|
[674, "Old Note A", "Document", 1],
|
||||||
|
[675, "Old Note B", "Document", 1],
|
||||||
|
[676, "Old Note C", "Document", 1],
|
||||||
|
[677, "Fishing Email A", "Document", 1],
|
||||||
|
[678, "Fishing Email B", "Document", 1],
|
||||||
|
[679, "Grind Trap Blueprint", "Blueprint", 1],
|
||||||
|
[680, "Spotlight Blueprint", "Blueprint", 1],
|
||||||
|
[681, "Clock Blueprint", "Blueprint", 1],
|
||||||
|
[682, "Spin Trap Blueprint", "Blueprint", 1],
|
||||||
|
[683, "Double Bed Blueprint", "Blueprint", 1],
|
||||||
|
[684, "Old Note E", "Document", 1],
|
||||||
|
[685, "Old Note D", "Document", 1],
|
||||||
|
[686, "Cult Sign F", "Document", 1],
|
||||||
|
[687, "Puffton Email A", "Document", 1],
|
||||||
|
[688, "Spear Thrower Trap Blueprint", "Blueprint", 1],
|
||||||
|
[689, "Artifact Piece E", "Misc", 1],
|
||||||
|
[690, "Story Pages", "Document", 1],
|
||||||
|
[691, "Old Note F", "Document", 1],
|
||||||
|
[692, "Fishing Email C", "Document", 1],
|
||||||
|
[693, "Bunker Map A", "Document", 1],
|
||||||
|
[694, "Jianyu Email Printout A", "Document", 1],
|
||||||
|
[695, "Cult Sign G", "Document", 1],
|
||||||
|
[696, "Cult Sign H", "Document", 1],
|
||||||
|
[697, "Cult Sign I", "Document", 1],
|
||||||
|
[698, "Cult Sign J", "Document", 1],
|
||||||
|
[699, "Cult Sign K", "Document", 1],
|
||||||
|
[700, "Spear Storage Blueprint", "Blueprint", 1],
|
||||||
|
[701, "Leg Lamp Blueprint", "Blueprint", 1],
|
||||||
|
[702, "Firewood Storage Blueprint", "Blueprint", 1],
|
||||||
|
[703, "Priest Outfit", "Clothes", 1],
|
||||||
|
[704, "Wall Weapon Rack Blueprint", "Blueprint", 1],
|
||||||
|
[705, "Circus Pamphlet", "Document", 1],
|
||||||
|
[706, "Newspaper Cutout [Husbands]", "Document", 1],
|
||||||
|
[707, "Artifact", "Misc", 1],
|
||||||
|
[708, "Artifact Piece F", "Misc", 1],
|
||||||
|
[709, "Teleporter Blueprint", "Blueprint", 1],
|
||||||
|
[710, "Repel Shrine Blueprint", "Blueprint", 1],
|
||||||
|
[711, "Attract Shrine Blueprint", "Blueprint", 1],
|
||||||
|
[712, "Artifact Piece G", "Misc", 1],
|
||||||
|
[713, "Plater Counter Blueprint", "Blueprint", 1],
|
||||||
|
[714, "Gold Armor Plater Blueprint", "Blueprint", 1],
|
||||||
|
[715, "Raccoon Head", "Trophy", 1],
|
||||||
|
[716, "Bible Paper D", "Document", 1],
|
||||||
|
[717, "Old Note G", "Document", 1],
|
||||||
|
[718, "Personal Note E", "Document", 1],
|
||||||
|
[719, "Personal Note F", "Document", 1],
|
||||||
|
[720, "Personal Note G", "Document", 1],
|
||||||
|
[721, "Bible Paper Scrap A", "Document", 1],
|
||||||
|
[722, "Puffton Email B", "Document", 1],
|
||||||
|
[723, "Puffton Email C", "Document", 1],
|
||||||
|
[724, "Twinberry Seed", "Seed", 20],
|
||||||
|
[725, "Snowberry Seed", "Seed", 20],
|
||||||
|
[726, "Hang Glider Launcher Blueprint", "Blueprint", 1],
|
||||||
|
[727, "Solafite Armor", "Armor", 10],
|
||||||
|
[729, "Skunk Head", "Trophy", 1],
|
||||||
|
[730, "Small Rabbit Hutch Blueprint", "Blueprint", 1],
|
||||||
|
[749, "Stewardess Outfit", "Clothes", 1]
|
||||||
|
];
|
||||||
|
|
||||||
|
const SOTF_NAMES = {
|
||||||
|
"351": "Grab & Go Bag",
|
||||||
|
"357": "Airgun",
|
||||||
|
"366": "Tracker Dart",
|
||||||
|
"370": "Pistol Ammo",
|
||||||
|
"371": "Shotgun Buckshot",
|
||||||
|
"372": "Shotgun Slug",
|
||||||
|
"377": "Scope",
|
||||||
|
"384": "Crossbow Quiver",
|
||||||
|
"389": "MolotovAmmo",
|
||||||
|
"395": "Log Plank",
|
||||||
|
"406": "Quarter Log",
|
||||||
|
"408": "Half Log",
|
||||||
|
"409": "Three Quarter Log",
|
||||||
|
"427": "Printed Knife",
|
||||||
|
"429": "Printed Scuba Mask",
|
||||||
|
"458": "Battery Charger",
|
||||||
|
"460": "Battery Pack",
|
||||||
|
"463": "Flippers",
|
||||||
|
"470": "Airgun Scope",
|
||||||
|
"475": "Flask",
|
||||||
|
"478": "Climbing Axe",
|
||||||
|
"488": "Blue TShirt",
|
||||||
|
"497": "Food Tray",
|
||||||
|
"498": "GRABS Laser Pointer",
|
||||||
|
"505": "Laser Measure Tool",
|
||||||
|
"511": "Contact Trigger",
|
||||||
|
"512": "Filled Food Tray",
|
||||||
|
"513": "Spring",
|
||||||
|
"514": "Contact Spring Trigger",
|
||||||
|
"515": "Basic Building Tutorial Page",
|
||||||
|
"516": "Tarp Tent Tutorial Page",
|
||||||
|
"520": "Structure Element",
|
||||||
|
"559": "Printed Arrow Heads",
|
||||||
|
"561": "Printed Flower Pot",
|
||||||
|
"573": "Sleeping Bag",
|
||||||
|
"576": "Quarter Log Plank",
|
||||||
|
"577": "Half Log Plank",
|
||||||
|
"578": "Three Quarter Log Plank",
|
||||||
|
"583": "Storage Box Blueprint",
|
||||||
|
"592": "Creepy Skin",
|
||||||
|
"728": "Living Rabbit"
|
||||||
|
};
|
||||||
+434
@@ -0,0 +1,434 @@
|
|||||||
|
:root {
|
||||||
|
--spruce: #0f1512;
|
||||||
|
--spruce-2: #161f1a;
|
||||||
|
--spruce-3: #1f2a23;
|
||||||
|
--moss: #6b7a4f;
|
||||||
|
--fog: #9aa39b;
|
||||||
|
--bone: #e4dccb;
|
||||||
|
--blood: #a3281c;
|
||||||
|
--blood-hi: #c23a2b;
|
||||||
|
--line: #2b382f;
|
||||||
|
--display: "IM Fell English", "Georgia", "Times New Roman", serif;
|
||||||
|
--body: "Barlow", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { margin: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--spruce);
|
||||||
|
color: var(--bone);
|
||||||
|
font-family: var(--body);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 4rem 1.5rem 7rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 70% at 30% 10%, rgba(154, 163, 155, 0.22), transparent 70%),
|
||||||
|
radial-gradient(ellipse 60% 60% at 80% 30%, rgba(107, 122, 79, 0.18), transparent 70%),
|
||||||
|
linear-gradient(180deg, #27322b 0%, var(--spruce) 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 90px;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 60' preserveAspectRatio='none'%3E%3Cpath fill='%230f1512' d='M0 60 L0 44 L8 30 L5 30 L12 16 L9 16 L16 2 L23 16 L20 16 L27 30 L24 30 L32 44 L32 60 Z M30 60 L38 40 L35 40 L42 26 L39 26 L46 14 L53 26 L50 26 L57 40 L54 40 L62 60 Z M58 60 L66 36 L63 36 L71 20 L68 20 L76 6 L84 20 L81 20 L89 36 L86 36 L94 60 Z M90 60 L97 44 L94 44 L101 30 L98 30 L105 20 L112 30 L109 30 L116 44 L113 44 L120 60 Z'/%3E%3C/svg%3E") repeat-x bottom / 120px 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: clamp(3.5rem, 11vw, 7.5rem);
|
||||||
|
line-height: 0.9;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bone);
|
||||||
|
text-shadow: 0 0 40px rgba(228, 220, 203, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--fog);
|
||||||
|
max-width: 34em;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px dashed var(--moss);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--spruce-2);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader.dragging {
|
||||||
|
border-color: var(--bone);
|
||||||
|
border-style: solid;
|
||||||
|
background: var(--spruce-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader.loaded {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader h2 {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--fog);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-hint {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fog);
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-hint code {
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
color: var(--bone);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font: 600 1rem var(--body);
|
||||||
|
color: var(--bone);
|
||||||
|
background: var(--spruce-3);
|
||||||
|
border: 1px solid var(--moss);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { background: #29362d; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--blood);
|
||||||
|
border-color: var(--blood);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: var(--blood-hi); }
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
font: 500 0.875rem var(--body);
|
||||||
|
color: var(--bone);
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--bone);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
background: var(--spruce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar input[type="search"] {
|
||||||
|
flex: 1 1 280px;
|
||||||
|
font: 1.05rem var(--body);
|
||||||
|
color: var(--bone);
|
||||||
|
background: var(--spruce-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar input[type="search"]::placeholder { color: var(--fog); }
|
||||||
|
|
||||||
|
.toolbar select {
|
||||||
|
font: 1rem var(--body);
|
||||||
|
color: var(--bone);
|
||||||
|
background: var(--spruce-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.7rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check input { accent-color: var(--blood); width: 1rem; height: 1rem; }
|
||||||
|
|
||||||
|
.count-label {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--fog);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap { position: relative; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--toolbar-h, 4.4rem);
|
||||||
|
background: var(--spruce);
|
||||||
|
border-bottom: 1px solid var(--moss);
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th button {
|
||||||
|
font: 600 0.9rem var(--body);
|
||||||
|
color: var(--fog);
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th button:hover { color: var(--bone); }
|
||||||
|
|
||||||
|
thead th[aria-sort] button { color: var(--bone); }
|
||||||
|
|
||||||
|
thead th[aria-sort="ascending"] button::after { content: " \25B2"; font-size: 0.7em; }
|
||||||
|
thead th[aria-sort="descending"] button::after { content: " \25BC"; font-size: 0.7em; }
|
||||||
|
|
||||||
|
.col-id, .col-max, .col-count { text-align: right; }
|
||||||
|
.col-count { width: 12rem; }
|
||||||
|
.col-max { width: 8rem; }
|
||||||
|
.col-id { width: 5rem; }
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr { border-left: 3px solid transparent; }
|
||||||
|
|
||||||
|
tbody tr.changed td:first-child { box-shadow: inset 3px 0 0 var(--blood); }
|
||||||
|
tbody tr.changed .cell-name { color: #fff; }
|
||||||
|
|
||||||
|
tbody tr.zero .cell-name { color: var(--fog); }
|
||||||
|
|
||||||
|
.cell-name { font-weight: 500; }
|
||||||
|
.cell-cat { color: var(--fog); }
|
||||||
|
.cell-id { color: var(--fog); text-align: right; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--moss);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--fog);
|
||||||
|
vertical-align: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-warn { border-color: var(--blood); color: #e0a39a; }
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper button {
|
||||||
|
font: 600 1.05rem var(--body);
|
||||||
|
width: 2rem;
|
||||||
|
color: var(--bone);
|
||||||
|
background: var(--spruce-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper button:first-child { border-radius: 3px 0 0 3px; }
|
||||||
|
.stepper button:last-child { border-radius: 0 3px 3px 0; }
|
||||||
|
.stepper button:hover:not(:disabled) { background: #2d3b31; }
|
||||||
|
.stepper button:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
|
.stepper input {
|
||||||
|
width: 5.5rem;
|
||||||
|
font: 500 1rem var(--body);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--bone);
|
||||||
|
background: var(--spruce);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
padding: 0.3rem 0.25rem;
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper input::-webkit-outer-spin-button,
|
||||||
|
.stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
|
|
||||||
|
.stepper input:disabled { color: var(--fog); }
|
||||||
|
|
||||||
|
.cell-max {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fog);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-max .btn-link { margin-right: 0.5rem; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fog);
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem max(1.5rem, calc((100vw - 1100px) / 2 + 1.5rem));
|
||||||
|
background: rgba(15, 21, 18, 0.94);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.savebar-actions { display: flex; gap: 1rem; align-items: center; }
|
||||||
|
|
||||||
|
#changeSummary { color: var(--fog); }
|
||||||
|
#changeSummary.has-changes { color: var(--bone); }
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
max-width: 32rem;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
color: var(--bone);
|
||||||
|
background: var(--spruce-2);
|
||||||
|
border: 1px solid var(--moss);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog::backdrop { background: rgba(5, 8, 6, 0.8); }
|
||||||
|
|
||||||
|
dialog h2 {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog p { margin: 0 0 0.9rem; line-height: 1.55; }
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 5rem;
|
||||||
|
transform: translate(-50%, 1rem);
|
||||||
|
max-width: min(36rem, calc(100% - 2rem));
|
||||||
|
padding: 0.8rem 1.1rem;
|
||||||
|
background: var(--spruce-3);
|
||||||
|
border: 1px solid var(--moss);
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
.toast.error { border-color: var(--blood); }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.hero { padding: 2.5rem 1rem 5.5rem; }
|
||||||
|
main { padding: 0 1rem; }
|
||||||
|
.loader { grid-template-columns: 1fr; }
|
||||||
|
.col-cat, .cell-cat, .col-id, .cell-id { display: none; }
|
||||||
|
.col-count { width: auto; }
|
||||||
|
.stepper input { width: 4rem; }
|
||||||
|
.cell-max .btn-link { display: none; }
|
||||||
|
.count-label { margin-left: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { transition: none !important; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user