Add disk-backed image proxy support to the web UI and expose it via `/api/image`. The proxy validates image URLs, fetches remote images with a timeout, stores image bytes + metadata in a local cache, and serves cached responses with proper content type and cache headers. Also add `SCRAPPR_IMAGE_CACHE` (default `.cache/webui-images`) and pass it through `cmd/outward-web` into `webui.Run`, with startup logging updated to include the cache location. This reduces repeated remote fetches and makes image delivery more reliable for the UI.feat(webui): add cached image proxy with configurable dir Add disk-backed image proxy support to the web UI and expose it via `/api/image`. The proxy validates image URLs, fetches remote images with a timeout, stores image bytes + metadata in a local cache, and serves cached responses with proper content type and cache headers. Also add `SCRAPPR_IMAGE_CACHE` (default `.cache/webui-images`) and pass it through `cmd/outward-web` into `webui.Run`, with startup logging updated to include the cache location. This reduces repeated remote fetches and makes image delivery more reliable for the UI.
434 lines
12 KiB
JavaScript
434 lines
12 KiB
JavaScript
const state = {
|
||
catalog: null,
|
||
itemByKey: new Map(),
|
||
};
|
||
|
||
const inventoryInput = document.querySelector("#inventory-input");
|
||
const quickItemInput = document.querySelector("#quick-item");
|
||
const quickQtyInput = document.querySelector("#quick-qty");
|
||
const itemList = document.querySelector("#item-list");
|
||
const summaryText = document.querySelector("#summary-text");
|
||
const resultsRoot = document.querySelector("#results");
|
||
const chipsRoot = document.querySelector("#inventory-chips");
|
||
|
||
document.querySelector("#analyze-btn").addEventListener("click", render);
|
||
document.querySelector("#sample-btn").addEventListener("click", () => {
|
||
inventoryInput.value = [
|
||
"Water",
|
||
"2x Ableroot",
|
||
"2x Oil",
|
||
"2x Gravel Beetle",
|
||
"Linen Cloth",
|
||
"Seaweed",
|
||
"Salt",
|
||
].join("\n");
|
||
render();
|
||
});
|
||
document.querySelector("#clear-btn").addEventListener("click", () => {
|
||
inventoryInput.value = "";
|
||
quickItemInput.value = "";
|
||
quickQtyInput.value = "1";
|
||
render();
|
||
});
|
||
document.querySelector("#add-item").addEventListener("click", addQuickItem);
|
||
quickItemInput.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
addQuickItem();
|
||
}
|
||
});
|
||
inventoryInput.addEventListener("input", renderInventoryPreview);
|
||
|
||
bootstrap().catch((error) => {
|
||
summaryText.textContent = "Failed to load crafting catalog.";
|
||
resultsRoot.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
|
||
});
|
||
|
||
async function bootstrap() {
|
||
const response = await fetch("/api/catalog");
|
||
if (!response.ok) {
|
||
throw new Error(`Catalog request failed with ${response.status}`);
|
||
}
|
||
|
||
state.catalog = await response.json();
|
||
state.itemByKey = buildItemIndex(state.catalog.items || []);
|
||
hydrateItemList(state.catalog.item_names || []);
|
||
summaryText.textContent = `Loaded ${state.catalog.item_names.length} known items and ${state.catalog.craftables.length} craftable entries.`;
|
||
render();
|
||
}
|
||
|
||
function buildItemIndex(items) {
|
||
const index = new Map();
|
||
for (const item of items) {
|
||
index.set(normalizeName(item.name), item);
|
||
}
|
||
return index;
|
||
}
|
||
|
||
function hydrateItemList(names) {
|
||
itemList.innerHTML = names.map((name) => `<option value="${escapeHtml(name)}"></option>`).join("");
|
||
}
|
||
|
||
function addQuickItem() {
|
||
const name = quickItemInput.value.trim();
|
||
const qty = Math.max(1, Number.parseInt(quickQtyInput.value || "1", 10) || 1);
|
||
if (!name) {
|
||
return;
|
||
}
|
||
|
||
const line = qty > 1 ? `${qty}x ${name}` : name;
|
||
inventoryInput.value = inventoryInput.value.trim()
|
||
? `${inventoryInput.value.trim()}\n${line}`
|
||
: line;
|
||
|
||
quickItemInput.value = "";
|
||
quickQtyInput.value = "1";
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
renderInventoryPreview();
|
||
|
||
if (!state.catalog) {
|
||
return;
|
||
}
|
||
|
||
const inventory = parseInventory(inventoryInput.value);
|
||
const craftable = findCraftableRecipes(state.catalog.craftables, inventory);
|
||
|
||
if (!craftable.length) {
|
||
summaryText.textContent = `No craftable recipes found from ${inventory.totalKinds} inventory item types.`;
|
||
resultsRoot.innerHTML = `
|
||
<div class="empty-state">
|
||
Add more ingredients or load the sample inventory to see matching recipes.
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
summaryText.textContent = `${craftable.length} recipe matches from ${inventory.totalKinds} inventory item types.`;
|
||
resultsRoot.innerHTML = craftable.map(renderRecipeCard).join("");
|
||
wireResultImages();
|
||
}
|
||
|
||
function renderInventoryPreview() {
|
||
const inventory = parseInventory(inventoryInput.value);
|
||
const entries = Object.values(inventory.byKey).sort((a, b) => a.name.localeCompare(b.name));
|
||
|
||
if (!entries.length) {
|
||
chipsRoot.innerHTML = `<div class="chip">No inventory entered yet</div>`;
|
||
return;
|
||
}
|
||
|
||
chipsRoot.innerHTML = entries
|
||
.map((entry) => `<div class="chip">${escapeHtml(entry.display)}</div>`)
|
||
.join("");
|
||
}
|
||
|
||
function parseInventory(raw) {
|
||
const byKey = {};
|
||
const lines = raw
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
|
||
for (const line of lines) {
|
||
const parsed = parseStackText(line);
|
||
if (!parsed.name) {
|
||
continue;
|
||
}
|
||
|
||
const key = normalizeName(parsed.name);
|
||
if (!byKey[key]) {
|
||
byKey[key] = {
|
||
name: parsed.name,
|
||
count: 0,
|
||
display: "",
|
||
};
|
||
}
|
||
|
||
byKey[key].count += parsed.count;
|
||
byKey[key].display = `${byKey[key].count}x ${byKey[key].name}`;
|
||
}
|
||
|
||
return {
|
||
byKey,
|
||
totalKinds: Object.keys(byKey).length,
|
||
};
|
||
}
|
||
|
||
function findCraftableRecipes(items, inventory) {
|
||
const matches = [];
|
||
|
||
for (const item of items) {
|
||
for (const recipe of item.recipes || []) {
|
||
if (normalizeName(recipe.result || "") !== normalizeName(item.name || "")) {
|
||
continue;
|
||
}
|
||
|
||
const requirements = collapseRequirements(recipe.ingredients || []);
|
||
|
||
if (!requirements.length) {
|
||
continue;
|
||
}
|
||
|
||
let maxCrafts = Number.POSITIVE_INFINITY;
|
||
let canCraft = true;
|
||
|
||
for (const requirement of requirements) {
|
||
const have = availableCountForIngredient(requirement.name, inventory);
|
||
if (have < requirement.count) {
|
||
canCraft = false;
|
||
break;
|
||
}
|
||
maxCrafts = Math.min(maxCrafts, Math.floor(have / requirement.count));
|
||
}
|
||
|
||
if (!canCraft) {
|
||
continue;
|
||
}
|
||
|
||
matches.push({
|
||
item,
|
||
recipe,
|
||
maxCrafts: Number.isFinite(maxCrafts) ? maxCrafts : 0,
|
||
resultYield: parseResultYield(recipe.result_count),
|
||
});
|
||
}
|
||
}
|
||
|
||
return matches.sort((left, right) => {
|
||
if (right.maxCrafts !== left.maxCrafts) {
|
||
return right.maxCrafts - left.maxCrafts;
|
||
}
|
||
return left.item.name.localeCompare(right.item.name);
|
||
});
|
||
}
|
||
|
||
function renderRecipeCard(entry) {
|
||
const initials = escapeHtml(entry.item.name.slice(0, 2).toUpperCase());
|
||
const ingredients = (entry.recipe.ingredients || [])
|
||
.map((ingredient) => `<span class="ingredient-pill">${escapeHtml(ingredient)}</span>`)
|
||
.join("");
|
||
|
||
const canonicalImageUrl = normalizeImageUrl(entry.item.image_url || "");
|
||
const proxiedImageUrl = canonicalImageUrl ? imageProxyUrl(canonicalImageUrl) : "";
|
||
const image = proxiedImageUrl
|
||
? `<img src="${escapeHtml(proxiedImageUrl)}" alt="${escapeHtml(entry.item.name)}">`
|
||
: `<div class="result-fallback">${initials}</div>`;
|
||
|
||
const station = entry.recipe.station ? `Station: ${escapeHtml(entry.recipe.station)}` : "Station: Any";
|
||
const yieldLabel = entry.resultYield > 1 ? `${entry.resultYield}x per craft` : "1x per craft";
|
||
|
||
return `
|
||
<article class="result-card">
|
||
${image}
|
||
<div>
|
||
<div class="result-top">
|
||
<div>
|
||
<h3 class="result-name">
|
||
<a href="${escapeHtml(entry.item.url)}" target="_blank" rel="noreferrer">${escapeHtml(entry.item.name)}</a>
|
||
</h3>
|
||
<p class="meta">${station} · ${yieldLabel}</p>
|
||
</div>
|
||
<div class="recipe-badge">${entry.maxCrafts} craft${entry.maxCrafts === 1 ? "" : "s"} possible</div>
|
||
</div>
|
||
<div class="ingredients">${ingredients}</div>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function wireResultImages() {
|
||
const images = resultsRoot.querySelectorAll("img");
|
||
for (const image of images) {
|
||
image.onerror = () => {
|
||
image.onerror = null;
|
||
image.replaceWith(createFallbackNode(image.alt || "?"));
|
||
};
|
||
}
|
||
}
|
||
|
||
function createFallbackNode(label) {
|
||
const node = document.createElement("div");
|
||
node.className = "result-fallback";
|
||
node.textContent = String(label).slice(0, 2).toUpperCase();
|
||
return node;
|
||
}
|
||
|
||
function parseStackText(raw) {
|
||
const cleaned = raw.replace(/×/g, "x").trim();
|
||
if (!cleaned) {
|
||
return { name: "", count: 0 };
|
||
}
|
||
|
||
const prefixed = cleaned.match(/^(\d+)\s*x\s+(.+)$/i);
|
||
if (prefixed) {
|
||
return {
|
||
count: Number.parseInt(prefixed[1], 10),
|
||
name: prefixed[2].trim(),
|
||
};
|
||
}
|
||
|
||
return {
|
||
count: 1,
|
||
name: cleaned,
|
||
};
|
||
}
|
||
|
||
function parseResultYield(raw) {
|
||
const parsed = parseStackText(raw || "1x Result");
|
||
return parsed.count || 1;
|
||
}
|
||
|
||
function normalizeImageUrl(raw) {
|
||
const value = String(raw || "").trim();
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const [base, query = ""] = value.split("?");
|
||
const canonicalBase = base.replace(/\/revision\/latest\/scale-to-width-down\/\d+$/, "/revision/latest");
|
||
return query ? `${canonicalBase}?${query}` : canonicalBase;
|
||
}
|
||
|
||
function fallbackImageUrl(raw) {
|
||
const value = normalizeImageUrl(raw);
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
const [base] = value.split("?");
|
||
return base.replace(/\/revision\/latest$/, "");
|
||
}
|
||
|
||
function imageProxyUrl(raw) {
|
||
return `/api/image?url=${encodeURIComponent(raw)}`;
|
||
}
|
||
|
||
function collapseRequirements(ingredients) {
|
||
const counts = new Map();
|
||
|
||
for (const ingredient of ingredients) {
|
||
const parsed = parseStackText(ingredient);
|
||
if (!parsed.name) {
|
||
continue;
|
||
}
|
||
|
||
const key = normalizeName(parsed.name);
|
||
const existing = counts.get(key) || {
|
||
name: parsed.name,
|
||
count: 0,
|
||
};
|
||
|
||
existing.count += parsed.count;
|
||
counts.set(key, existing);
|
||
}
|
||
|
||
return Array.from(counts.values());
|
||
}
|
||
|
||
function availableCountForIngredient(ingredientName, inventory) {
|
||
const requirementKey = normalizeName(ingredientName);
|
||
|
||
if (requirementKey === "water") {
|
||
return countInventory(inventory, ["water", "clean water"]);
|
||
}
|
||
|
||
if (requirementKey === "meat") {
|
||
return countMatchingInventory(inventory, (item) => isMeatLike(item));
|
||
}
|
||
|
||
if (requirementKey === "vegetable") {
|
||
return countMatchingInventory(inventory, (item) => isVegetableLike(item));
|
||
}
|
||
|
||
if (requirementKey === "ration ingredient") {
|
||
return countMatchingInventory(inventory, (item) => isRationIngredient(item));
|
||
}
|
||
|
||
return inventory.byKey[requirementKey]?.count || 0;
|
||
}
|
||
|
||
function countInventory(inventory, keys) {
|
||
return keys.reduce((sum, key) => sum + (inventory.byKey[key]?.count || 0), 0);
|
||
}
|
||
|
||
function countMatchingInventory(inventory, predicate) {
|
||
let total = 0;
|
||
|
||
for (const [key, entry] of Object.entries(inventory.byKey)) {
|
||
if (!predicate(resolveInventoryItem(key, entry.name))) {
|
||
continue;
|
||
}
|
||
total += entry.count;
|
||
}
|
||
|
||
return total;
|
||
}
|
||
|
||
function resolveInventoryItem(key, fallbackName) {
|
||
const item = state.itemByKey.get(key);
|
||
if (item) {
|
||
return item;
|
||
}
|
||
|
||
return {
|
||
name: fallbackName,
|
||
categories: [],
|
||
item_type: "",
|
||
};
|
||
}
|
||
|
||
function isMeatLike(item) {
|
||
const name = normalizeName(item.name);
|
||
return name.includes("meat") || name.includes("jerky");
|
||
}
|
||
|
||
function isVegetableLike(item) {
|
||
const name = normalizeName(item.name);
|
||
const categories = normalizeList(item.categories || []);
|
||
const itemType = normalizeName(item.item_type || "");
|
||
const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food";
|
||
|
||
if (!foodLike) {
|
||
return false;
|
||
}
|
||
|
||
if (name.includes("salt") || name.includes("water") || name.includes("meat") || name.includes("fish") || name.includes("egg")) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function isRationIngredient(item) {
|
||
const name = normalizeName(item.name);
|
||
const categories = normalizeList(item.categories || []);
|
||
const itemType = normalizeName(item.item_type || "");
|
||
const foodLike = categories.includes("food") || categories.includes("consumables") || itemType === "food";
|
||
|
||
if (!foodLike) {
|
||
return false;
|
||
}
|
||
|
||
return !name.includes("salt") && !name.includes("water");
|
||
}
|
||
|
||
function normalizeList(values) {
|
||
return values.map((value) => normalizeName(value));
|
||
}
|
||
|
||
function normalizeName(value) {
|
||
return value.toLowerCase().replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|