Files
Daniel Legt b9d035d71a 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.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.
2026-03-16 09:56:47 +02:00

434 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}