Inital Commit
This commit is contained in:
557
webapp/app.js
Normal file
557
webapp/app.js
Normal file
@@ -0,0 +1,557 @@
|
||||
"use strict";
|
||||
|
||||
const state = {
|
||||
catalog: null,
|
||||
profileByItem: {},
|
||||
selectedItem: null,
|
||||
};
|
||||
|
||||
const dom = {
|
||||
statusText: document.getElementById("statusText"),
|
||||
catalogFile: document.getElementById("catalogFile"),
|
||||
profileFile: document.getElementById("profileFile"),
|
||||
exportProfile: document.getElementById("exportProfile"),
|
||||
searchInput: document.getElementById("searchInput"),
|
||||
categoryFilter: document.getElementById("categoryFilter"),
|
||||
spawnFilter: document.getElementById("spawnFilter"),
|
||||
itemTableBody: document.getElementById("itemTableBody"),
|
||||
selectedDetails: document.getElementById("selectedDetails"),
|
||||
resetSelected: document.getElementById("resetSelected"),
|
||||
};
|
||||
|
||||
function setStatus(text) {
|
||||
dom.statusText.textContent = text;
|
||||
}
|
||||
|
||||
function readFileText(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
function safeNumber(value, fallback = 0) {
|
||||
const n = Number.parseFloat(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function normalizeItemType(item) {
|
||||
if (typeof item !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.includes(".") ? trimmed : `Base.${trimmed}`;
|
||||
}
|
||||
|
||||
function normalizeCatalog(raw) {
|
||||
if (!raw || !Array.isArray(raw.items)) {
|
||||
throw new Error("Invalid catalog format. Missing items array.");
|
||||
}
|
||||
|
||||
const lists = new Set(Array.isArray(raw.lists) ? raw.lists : []);
|
||||
const items = [];
|
||||
|
||||
for (const rawItem of raw.items) {
|
||||
const itemType = normalizeItemType(rawItem.item);
|
||||
if (!itemType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aggregated = Array.isArray(rawItem.aggregatedPlacements)
|
||||
? rawItem.aggregatedPlacements
|
||||
: [];
|
||||
const placements = {};
|
||||
for (const placement of aggregated) {
|
||||
const listName = placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
||||
const weight = safeNumber(placement && placement.weight, 0);
|
||||
if (!listName || weight <= 0) {
|
||||
continue;
|
||||
}
|
||||
placements[listName] = Number(weight.toFixed(6));
|
||||
lists.add(listName);
|
||||
}
|
||||
|
||||
items.push({
|
||||
item: itemType,
|
||||
shortId: typeof rawItem.shortId === "string" ? rawItem.shortId : itemType.replace(/^Base\./, ""),
|
||||
category: typeof rawItem.category === "string" ? rawItem.category : "unknown",
|
||||
defaultEnabled: rawItem.defaultEnabled !== false,
|
||||
spawnControlKeys: Array.isArray(rawItem.spawnControlKeys) ? rawItem.spawnControlKeys : [],
|
||||
defaultPlacements: placements,
|
||||
});
|
||||
}
|
||||
|
||||
items.sort((a, b) => a.item.localeCompare(b.item));
|
||||
return {
|
||||
generatedAt: raw.generatedAt || null,
|
||||
source: raw.source || {},
|
||||
items,
|
||||
lists: Array.from(lists).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function initializeProfileFromCatalog() {
|
||||
const profileByItem = {};
|
||||
for (const item of state.catalog.items) {
|
||||
const placements = {};
|
||||
for (const [listName, weight] of Object.entries(item.defaultPlacements)) {
|
||||
placements[listName] = weight;
|
||||
}
|
||||
profileByItem[item.item] = {
|
||||
item: item.item,
|
||||
category: item.category,
|
||||
enabled: item.defaultEnabled,
|
||||
placements,
|
||||
};
|
||||
}
|
||||
state.profileByItem = profileByItem;
|
||||
}
|
||||
|
||||
function getProfileEntry(itemType) {
|
||||
return state.profileByItem[itemType] || null;
|
||||
}
|
||||
|
||||
function updateProfileEntry(itemType, updater) {
|
||||
const current = getProfileEntry(itemType);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
updater(current);
|
||||
}
|
||||
|
||||
function getFilteredItems() {
|
||||
if (!state.catalog) {
|
||||
return [];
|
||||
}
|
||||
const search = dom.searchInput.value.trim().toLowerCase();
|
||||
const category = dom.categoryFilter.value;
|
||||
const spawnState = dom.spawnFilter.value;
|
||||
|
||||
return state.catalog.items.filter((item) => {
|
||||
const entry = getProfileEntry(item.item);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
if (category !== "all" && item.category !== category) {
|
||||
return false;
|
||||
}
|
||||
if (spawnState === "enabled" && !entry.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (spawnState === "disabled" && entry.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
item.item.toLowerCase().includes(search) ||
|
||||
item.shortId.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderItemTable() {
|
||||
const filteredItems = getFilteredItems();
|
||||
dom.itemTableBody.innerHTML = "";
|
||||
|
||||
for (const item of filteredItems) {
|
||||
const entry = getProfileEntry(item.item);
|
||||
const row = document.createElement("tr");
|
||||
if (state.selectedItem === item.item) {
|
||||
row.classList.add("selected");
|
||||
}
|
||||
|
||||
const spawnTd = document.createElement("td");
|
||||
const spawnCheck = document.createElement("input");
|
||||
spawnCheck.type = "checkbox";
|
||||
spawnCheck.checked = entry.enabled;
|
||||
spawnCheck.addEventListener("click", (event) => event.stopPropagation());
|
||||
spawnCheck.addEventListener("change", () => {
|
||||
updateProfileEntry(item.item, (target) => {
|
||||
target.enabled = spawnCheck.checked;
|
||||
});
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
});
|
||||
spawnTd.appendChild(spawnCheck);
|
||||
|
||||
const itemTd = document.createElement("td");
|
||||
itemTd.textContent = item.shortId;
|
||||
|
||||
const categoryTd = document.createElement("td");
|
||||
categoryTd.textContent = item.category;
|
||||
|
||||
const listsTd = document.createElement("td");
|
||||
listsTd.textContent = String(Object.keys(entry.placements).length);
|
||||
|
||||
row.appendChild(spawnTd);
|
||||
row.appendChild(itemTd);
|
||||
row.appendChild(categoryTd);
|
||||
row.appendChild(listsTd);
|
||||
|
||||
row.addEventListener("click", () => {
|
||||
state.selectedItem = item.item;
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
});
|
||||
|
||||
dom.itemTableBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlacementsTable(item, entry, container) {
|
||||
const table = document.createElement("table");
|
||||
table.className = "placements-table";
|
||||
const thead = document.createElement("thead");
|
||||
thead.innerHTML = "<tr><th>List</th><th>Weight</th><th></th></tr>";
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
const placementNames = Object.keys(entry.placements).sort();
|
||||
|
||||
for (const listName of placementNames) {
|
||||
const weight = entry.placements[listName];
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
const listTd = document.createElement("td");
|
||||
listTd.textContent = listName;
|
||||
|
||||
const weightTd = document.createElement("td");
|
||||
const weightInput = document.createElement("input");
|
||||
weightInput.type = "number";
|
||||
weightInput.min = "0";
|
||||
weightInput.step = "0.001";
|
||||
weightInput.value = String(weight);
|
||||
weightInput.addEventListener("change", () => {
|
||||
const next = safeNumber(weightInput.value, 0);
|
||||
if (next <= 0) {
|
||||
delete entry.placements[listName];
|
||||
} else {
|
||||
entry.placements[listName] = Number(next.toFixed(6));
|
||||
}
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
});
|
||||
weightTd.appendChild(weightInput);
|
||||
|
||||
const actionTd = document.createElement("td");
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "small-btn remove";
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.addEventListener("click", () => {
|
||||
delete entry.placements[listName];
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
});
|
||||
actionTd.appendChild(removeBtn);
|
||||
|
||||
tr.appendChild(listTd);
|
||||
tr.appendChild(weightTd);
|
||||
tr.appendChild(actionTd);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
table.appendChild(tbody);
|
||||
container.appendChild(table);
|
||||
}
|
||||
|
||||
function renderSelectedDetails() {
|
||||
const selected = state.selectedItem;
|
||||
dom.selectedDetails.innerHTML = "";
|
||||
|
||||
if (!state.catalog || !selected) {
|
||||
dom.selectedDetails.textContent = "Select an item to edit placements and spawn rate.";
|
||||
dom.selectedDetails.className = "details-empty";
|
||||
return;
|
||||
}
|
||||
|
||||
const item = state.catalog.items.find((it) => it.item === selected);
|
||||
const entry = getProfileEntry(selected);
|
||||
if (!item || !entry) {
|
||||
dom.selectedDetails.textContent = "Selected item not found.";
|
||||
dom.selectedDetails.className = "details-empty";
|
||||
return;
|
||||
}
|
||||
|
||||
dom.selectedDetails.className = "details-body";
|
||||
|
||||
const itemHeader = document.createElement("div");
|
||||
itemHeader.className = "item-header";
|
||||
itemHeader.innerHTML = `
|
||||
<div>
|
||||
<h3 class="item-title">${item.item}</h3>
|
||||
<span class="badge">${item.category}</span>
|
||||
</div>
|
||||
`;
|
||||
dom.selectedDetails.appendChild(itemHeader);
|
||||
|
||||
const enabledRow = document.createElement("div");
|
||||
enabledRow.className = "inline-row";
|
||||
const enabledInput = document.createElement("input");
|
||||
enabledInput.type = "checkbox";
|
||||
enabledInput.checked = entry.enabled;
|
||||
enabledInput.addEventListener("change", () => {
|
||||
entry.enabled = enabledInput.checked;
|
||||
renderItemTable();
|
||||
});
|
||||
|
||||
const enabledLabel = document.createElement("label");
|
||||
enabledLabel.textContent = "Spawn enabled";
|
||||
enabledLabel.prepend(enabledInput);
|
||||
enabledLabel.style.display = "inline-flex";
|
||||
enabledLabel.style.alignItems = "center";
|
||||
enabledLabel.style.gap = "0.35rem";
|
||||
enabledRow.appendChild(enabledLabel);
|
||||
dom.selectedDetails.appendChild(enabledRow);
|
||||
|
||||
const placementsLabel = document.createElement("p");
|
||||
placementsLabel.textContent = "Placements (distribution list + spawn rate weight):";
|
||||
placementsLabel.style.margin = "0 0 0.4rem";
|
||||
dom.selectedDetails.appendChild(placementsLabel);
|
||||
|
||||
renderPlacementsTable(item, entry, dom.selectedDetails);
|
||||
|
||||
const addRow = document.createElement("div");
|
||||
addRow.className = "inline-row";
|
||||
|
||||
const listSelect = document.createElement("select");
|
||||
const usedLists = new Set(Object.keys(entry.placements));
|
||||
const availableLists = state.catalog.lists.filter((listName) => !usedLists.has(listName));
|
||||
for (const listName of availableLists) {
|
||||
const option = document.createElement("option");
|
||||
option.value = listName;
|
||||
option.textContent = listName;
|
||||
listSelect.appendChild(option);
|
||||
}
|
||||
|
||||
const customInput = document.createElement("input");
|
||||
customInput.type = "text";
|
||||
customInput.placeholder = "or custom list name";
|
||||
|
||||
const weightInput = document.createElement("input");
|
||||
weightInput.type = "number";
|
||||
weightInput.min = "0";
|
||||
weightInput.step = "0.001";
|
||||
weightInput.value = "1";
|
||||
|
||||
const addButton = document.createElement("button");
|
||||
addButton.type = "button";
|
||||
addButton.className = "small-btn";
|
||||
addButton.textContent = "Add Placement";
|
||||
addButton.addEventListener("click", () => {
|
||||
const custom = customInput.value.trim();
|
||||
const selectedList = custom || listSelect.value;
|
||||
const weight = safeNumber(weightInput.value, 0);
|
||||
if (!selectedList || weight <= 0) {
|
||||
return;
|
||||
}
|
||||
entry.placements[selectedList] = Number(weight.toFixed(6));
|
||||
if (!state.catalog.lists.includes(selectedList)) {
|
||||
state.catalog.lists.push(selectedList);
|
||||
state.catalog.lists.sort();
|
||||
}
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
});
|
||||
|
||||
addRow.appendChild(listSelect);
|
||||
addRow.appendChild(customInput);
|
||||
addRow.appendChild(weightInput);
|
||||
addRow.appendChild(addButton);
|
||||
dom.selectedDetails.appendChild(addRow);
|
||||
|
||||
const resetRow = document.createElement("div");
|
||||
resetRow.className = "inline-row";
|
||||
|
||||
const restoreButton = document.createElement("button");
|
||||
restoreButton.type = "button";
|
||||
restoreButton.className = "small-btn";
|
||||
restoreButton.textContent = "Restore Catalog Placements";
|
||||
restoreButton.addEventListener("click", () => {
|
||||
entry.enabled = item.defaultEnabled;
|
||||
entry.placements = { ...item.defaultPlacements };
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
});
|
||||
|
||||
const clearButton = document.createElement("button");
|
||||
clearButton.type = "button";
|
||||
clearButton.className = "small-btn remove";
|
||||
clearButton.textContent = "Clear Placements";
|
||||
clearButton.addEventListener("click", () => {
|
||||
entry.placements = {};
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
});
|
||||
|
||||
resetRow.appendChild(restoreButton);
|
||||
resetRow.appendChild(clearButton);
|
||||
dom.selectedDetails.appendChild(resetRow);
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "meta";
|
||||
meta.innerHTML = `
|
||||
Sandbox weight controls: ${item.spawnControlKeys.length ? item.spawnControlKeys.join(", ") : "none"}<br>
|
||||
Catalog list count: ${Object.keys(item.defaultPlacements).length}
|
||||
`;
|
||||
dom.selectedDetails.appendChild(meta);
|
||||
}
|
||||
|
||||
function buildExportProfile() {
|
||||
const entries = Object.keys(state.profileByItem)
|
||||
.sort()
|
||||
.map((itemType) => {
|
||||
const entry = state.profileByItem[itemType];
|
||||
const placements = Object.keys(entry.placements)
|
||||
.sort()
|
||||
.map((listName) => ({
|
||||
list: listName,
|
||||
weight: Number(entry.placements[listName]),
|
||||
}));
|
||||
return {
|
||||
item: itemType,
|
||||
category: entry.category,
|
||||
enabled: entry.enabled,
|
||||
placements,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
formatVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
sourceCatalog: {
|
||||
generatedAt: state.catalog ? state.catalog.generatedAt : null,
|
||||
source: state.catalog ? state.catalog.source : {},
|
||||
},
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function downloadTextFile(fileName, content) {
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function onCatalogFileSelected() {
|
||||
const file = dom.catalogFile.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await readFileText(file);
|
||||
const rawCatalog = JSON.parse(text);
|
||||
state.catalog = normalizeCatalog(rawCatalog);
|
||||
initializeProfileFromCatalog();
|
||||
state.selectedItem = state.catalog.items.length ? state.catalog.items[0].item : null;
|
||||
setStatus(
|
||||
`Catalog loaded (${state.catalog.items.length} items, ${state.catalog.lists.length} lists).`
|
||||
);
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
} catch (error) {
|
||||
setStatus(`Catalog load failed: ${error.message}`);
|
||||
} finally {
|
||||
dom.catalogFile.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function onProfileFileSelected() {
|
||||
if (!state.catalog) {
|
||||
setStatus("Load a catalog first.");
|
||||
dom.profileFile.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const file = dom.profileFile.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await readFileText(file);
|
||||
const raw = JSON.parse(text);
|
||||
if (!Array.isArray(raw.entries)) {
|
||||
throw new Error("Profile must contain an entries array.");
|
||||
}
|
||||
|
||||
for (const row of raw.entries) {
|
||||
const itemType = normalizeItemType(row.item);
|
||||
if (!itemType || !state.profileByItem[itemType]) {
|
||||
continue;
|
||||
}
|
||||
const entry = state.profileByItem[itemType];
|
||||
entry.enabled = row.enabled !== false;
|
||||
entry.placements = {};
|
||||
if (Array.isArray(row.placements)) {
|
||||
for (const placement of row.placements) {
|
||||
const listName =
|
||||
placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
||||
const weight = safeNumber(placement && placement.weight, 0);
|
||||
if (!listName || weight <= 0) {
|
||||
continue;
|
||||
}
|
||||
entry.placements[listName] = Number(weight.toFixed(6));
|
||||
if (!state.catalog.lists.includes(listName)) {
|
||||
state.catalog.lists.push(listName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.catalog.lists.sort();
|
||||
setStatus("Profile loaded and applied to current catalog.");
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
} catch (error) {
|
||||
setStatus(`Profile load failed: ${error.message}`);
|
||||
} finally {
|
||||
dom.profileFile.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function onExportProfile() {
|
||||
if (!state.catalog) {
|
||||
setStatus("Load a catalog before exporting.");
|
||||
return;
|
||||
}
|
||||
const payload = buildExportProfile();
|
||||
downloadTextFile("of-spawn-profile.json", `${JSON.stringify(payload, null, 2)}\n`);
|
||||
setStatus("Profile exported.");
|
||||
}
|
||||
|
||||
function onResetSelected() {
|
||||
if (!state.catalog || !state.selectedItem) {
|
||||
return;
|
||||
}
|
||||
const item = state.catalog.items.find((it) => it.item === state.selectedItem);
|
||||
const entry = getProfileEntry(state.selectedItem);
|
||||
if (!item || !entry) {
|
||||
return;
|
||||
}
|
||||
entry.enabled = item.defaultEnabled;
|
||||
entry.placements = { ...item.defaultPlacements };
|
||||
renderItemTable();
|
||||
renderSelectedDetails();
|
||||
setStatus(`Reset ${item.shortId} to catalog defaults.`);
|
||||
}
|
||||
|
||||
dom.catalogFile.addEventListener("change", onCatalogFileSelected);
|
||||
dom.profileFile.addEventListener("change", onProfileFileSelected);
|
||||
dom.exportProfile.addEventListener("click", onExportProfile);
|
||||
dom.searchInput.addEventListener("input", renderItemTable);
|
||||
dom.categoryFilter.addEventListener("change", renderItemTable);
|
||||
dom.spawnFilter.addEventListener("change", renderItemTable);
|
||||
dom.resetSelected.addEventListener("click", onResetSelected);
|
||||
77
webapp/index.html
Normal file
77
webapp/index.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Opinionated Firearms Spawn List Builder</title>
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Opinionated Firearms Spawn List Builder</h1>
|
||||
<p>Edit firearm/attachment spawn enablement, placement lists, and spawn rates.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<label class="file-button">
|
||||
Load Catalog JSON
|
||||
<input id="catalogFile" type="file" accept=".json,application/json">
|
||||
</label>
|
||||
<label class="file-button secondary">
|
||||
Load Profile JSON
|
||||
<input id="profileFile" type="file" accept=".json,application/json">
|
||||
</label>
|
||||
<button id="exportProfile" type="button">Export Profile JSON</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="status">
|
||||
<span id="statusText">Load an extracted catalog JSON to begin.</span>
|
||||
</section>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Items</h2>
|
||||
<div class="filters">
|
||||
<input id="searchInput" type="search" placeholder="Search item ID...">
|
||||
<select id="categoryFilter">
|
||||
<option value="all">All categories</option>
|
||||
<option value="firearm">Firearms</option>
|
||||
<option value="attachment">Attachments</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
<select id="spawnFilter">
|
||||
<option value="all">All spawn states</option>
|
||||
<option value="enabled">Spawn enabled</option>
|
||||
<option value="disabled">Spawn disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Spawn</th>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Spawn Loacation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="itemTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel details-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Selected Item</h2>
|
||||
<button id="resetSelected" type="button">Reset to Catalog</button>
|
||||
</div>
|
||||
<div id="selectedDetails" class="details-empty">Select an item to edit placements and spawn rate.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
263
webapp/styles.css
Normal file
263
webapp/styles.css
Normal file
@@ -0,0 +1,263 @@
|
||||
:root {
|
||||
--bg: #f4efe6;
|
||||
--panel: #fffaf1;
|
||||
--panel-2: #f8f0e2;
|
||||
--line: #d8ccb8;
|
||||
--text: #2f2418;
|
||||
--muted: #7d6852;
|
||||
--accent: #9c3f1f;
|
||||
--accent-2: #4f6f52;
|
||||
--focus: #c77800;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1200px 700px at 95% 0%, #e2c8a0 0%, transparent 60%),
|
||||
linear-gradient(145deg, #f8f2e7 0%, var(--bg) 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem 1.3rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 250, 241, 0.8);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.file-button,
|
||||
button {
|
||||
border: 1px solid var(--accent);
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
padding: 0.48rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-button.secondary {
|
||||
border-color: var(--accent-2);
|
||||
background: var(--accent-2);
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.file-button:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
.file-button:focus-within {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.5rem 1.3rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1.1fr) minmax(320px, 0.9fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.3rem 1.3rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
min-height: 66vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.8rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input[type="search"],
|
||||
select,
|
||||
input[type="number"],
|
||||
input[type="text"] {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.83rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #fff2de;
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: #f9dfbc;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.details-body {
|
||||
padding: 0.8rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.details-empty {
|
||||
padding: 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.inline-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.placements-table th,
|
||||
.placements-table td {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.34rem 0.4rem;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.small-btn.remove {
|
||||
border-color: #b74a38;
|
||||
color: #8f2617;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0.7rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 45vh;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user