fixed remove issue, made items avaiable for available view

This commit is contained in:
2026-11-12 12:49:19 -07:00
parent 2807ffd160
commit 120daf8c31
10 changed files with 445 additions and 27 deletions

View File

@@ -18,6 +18,7 @@ const state = {
view: "catalog",
user: null,
inventory: { search: "", category: "", sort: "name" },
available: { search: "", category: "", sort: "updated" },
};
let authMode = "login";
@@ -34,9 +35,13 @@ function cacheElements() {
"modal-backdrop", "modal-close", "modal-title", "modal-body",
"catalog-view", "inventory-view", "inv-search", "inv-category", "inv-sort",
"inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine",
"available-view", "avail-search", "avail-category", "avail-sort",
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
"tab-available",
"auth-backdrop", "auth-close", "auth-title", "auth-form",
"auth-username", "auth-password", "auth-error", "auth-submit",
"auth-toggle", "toast",
"confirm-backdrop", "confirm-message", "confirm-ok", "confirm-cancel",
];
for (const id of ids) els[id] = document.getElementById(id);
}
@@ -369,6 +374,26 @@ function closeModal() {
els["modal-backdrop"].classList.add("hidden");
}
let confirmResolve = null;
function confirmAction(message, label = "Remove") {
if (confirmResolve) closeConfirm(false);
els["confirm-message"].textContent = message;
els["confirm-ok"].textContent = label;
els["confirm-backdrop"].classList.remove("hidden");
els["confirm-ok"].focus();
return new Promise((resolve) => {
confirmResolve = resolve;
});
}
function closeConfirm(result) {
els["confirm-backdrop"].classList.add("hidden");
const resolve = confirmResolve;
confirmResolve = null;
if (resolve) resolve(result);
}
/* ---------- personal inventory ---------- */
async function switchToMine() {
@@ -380,6 +405,7 @@ async function switchToMine() {
setActiveTab(els["tab-mine"]);
state.view = "mine";
els["catalog-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden");
await loadInventory();
}
@@ -387,6 +413,7 @@ async function switchToMine() {
function showCatalogView() {
state.view = "catalog";
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["catalog-view"].classList.remove("hidden");
}
@@ -443,6 +470,29 @@ function renderInventorySummary(summary) {
(parts ? ` · ${parts}` : "") + unavailable;
}
function itemNameCell(entry) {
const cell = document.createElement("td");
const button = document.createElement("button");
button.type = "button";
button.className = "inv-item-link";
button.innerHTML =
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>`;
if (entry.available === false) {
button.disabled = true;
button.classList.add("is-static");
button.insertAdjacentHTML(
"beforeend",
'<div class="unavailable-note">Not in current catalog</div>'
);
} else {
button.title = "View item details";
button.addEventListener("click", () => openModal(entry.class_name));
}
cell.appendChild(button);
return cell;
}
function renderInventory(listing) {
const tbody = els["inv-tbody"];
tbody.innerHTML = "";
@@ -452,11 +502,7 @@ function renderInventory(listing) {
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = document.createElement("td");
nameCell.innerHTML =
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>` +
(entry.available ? "" : '<div class="unavailable-note">Not in current catalog</div>');
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
@@ -503,11 +549,48 @@ function renderInventory(listing) {
remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name));
actionCell.appendChild(remove);
row.append(nameCell, categoryCell, qtyCell, notesCell, actionCell);
const visibilityCell = document.createElement("td");
const visibility = document.createElement("button");
visibility.type = "button";
visibility.className = "visibility-toggle";
visibility.addEventListener("click", () => toggleVisibility(entry, visibility));
visibilityCell.appendChild(visibility);
applyVisibility(visibility, entry.visibility);
row.append(nameCell, categoryCell, qtyCell, notesCell, visibilityCell, actionCell);
tbody.appendChild(row);
}
}
function applyVisibility(button, value) {
const isAvailable = value === "available";
button.textContent = isAvailable ? "Available" : "Private";
button.classList.toggle("is-available", isAvailable);
button.setAttribute("aria-pressed", String(isAvailable));
button.title = isAvailable
? "Marked available — click to make private"
: "Private to you — click to mark available";
}
async function toggleVisibility(entry, button) {
const next = entry.visibility === "available" ? "private" : "available";
button.disabled = true;
try {
const updated = await apiFetch(
`/api/v1/me/inventory/${encodeURIComponent(entry.class_name)}`,
{ method: "PATCH", body: { visibility: next } }
);
entry.visibility = updated.visibility;
applyVisibility(button, updated.visibility);
showToast(updated.visibility === "available" ? "Marked available" : "Marked private");
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
loadInventory();
} finally {
button.disabled = false;
}
}
async function patchEntry(class_name, payload) {
try {
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
@@ -523,13 +606,18 @@ async function patchEntry(class_name, payload) {
}
async function deleteEntry(class_name, name) {
if (!window.confirm(`Remove "${name}" from your inventory?`)) return;
const confirmed = await confirmAction(
`Remove "${name}" from your inventory? If it is marked available it will ` +
"disappear from everyone else's view.",
"Remove"
);
if (!confirmed) return;
try {
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
method: "DELETE",
});
showToast("Removed from your inventory");
loadInventory();
await loadInventory();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
}
@@ -557,6 +645,74 @@ async function addToInventory(class_name, quantity = 1) {
}
}
/* ---------- available items (public) ---------- */
async function switchToAvailable() {
setActiveTab(els["tab-available"]);
state.view = "available";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.remove("hidden");
await loadAvailable();
}
function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
async function loadAvailable() {
const params = new URLSearchParams();
if (state.available.search) params.set("search", state.available.search);
if (state.available.category) params.set("category", state.available.category);
params.set("sort", state.available.sort);
params.set("page_size", String(INVENTORY_PAGE_SIZE));
els["avail-summary"].textContent = "Loading…";
try {
const listing = await apiFetch(`/api/v1/available?${params}`);
renderAvailable(listing);
} catch (err) {
els["avail-summary"].textContent = "";
showToast(err.message, true);
}
}
function renderAvailable(listing) {
const tbody = els["avail-tbody"];
tbody.innerHTML = "";
els["avail-summary"].textContent =
`${listing.total} item${listing.total === 1 ? "" : "s"} offered`;
els["avail-empty"].classList.toggle("hidden", listing.total !== 0);
els["avail-table-wrap"].classList.toggle("hidden", listing.total === 0);
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '<span class="badge size">—</span>';
const qtyCell = document.createElement("td");
qtyCell.textContent = String(entry.quantity);
const userCell = document.createElement("td");
userCell.className = "avail-user";
userCell.textContent = entry.username;
const updatedCell = document.createElement("td");
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
row.append(nameCell, categoryCell, qtyCell, userCell, updatedCell);
tbody.appendChild(row);
}
}
/* ---------- events ---------- */
function debounce(fn, wait) {
@@ -585,6 +741,10 @@ function bindEvents() {
switchToMine();
return;
}
if (tab.dataset.view === "available") {
switchToAvailable();
return;
}
setActiveTab(tab);
showCatalogView();
state.category = tab.dataset.category;
@@ -647,8 +807,14 @@ function bindEvents() {
els["modal-backdrop"].addEventListener("click", (event) => {
if (event.target === els["modal-backdrop"]) closeModal();
});
els["confirm-ok"].addEventListener("click", () => closeConfirm(true));
els["confirm-cancel"].addEventListener("click", () => closeConfirm(false));
els["confirm-backdrop"].addEventListener("click", (event) => {
if (event.target === els["confirm-backdrop"]) closeConfirm(false);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
closeConfirm(false);
closeModal();
closeAuth();
});
@@ -683,6 +849,24 @@ function bindInventoryEvents() {
});
}
function bindAvailableEvents() {
els["avail-search"].addEventListener(
"input",
debounce(() => {
state.available.search = els["avail-search"].value.trim();
loadAvailable();
}, 300)
);
els["avail-category"].addEventListener("change", () => {
state.available.category = els["avail-category"].value;
loadAvailable();
});
els["avail-sort"].addEventListener("change", () => {
state.available.sort = els["avail-sort"].value;
loadAvailable();
});
}
/* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", async () => {
@@ -690,6 +874,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindEvents();
bindAuthEvents();
bindInventoryEvents();
bindAvailableEvents();
initHeader();
await initAuth();
loadItems();

View File

@@ -29,6 +29,7 @@
<button class="tab" data-view="catalog" data-category="ship-items">Ship Equipment</button>
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</button>
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
<button class="tab" id="tab-available" data-view="available">Available</button>
</nav>
<div id="catalog-view">
@@ -88,6 +89,7 @@
<th>Category</th>
<th>Qty</th>
<th>Notes</th>
<th>Visibility</th>
<th></th>
</tr>
</thead>
@@ -96,6 +98,43 @@
</div>
</section>
<section id="available-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="avail-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="avail-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
</select>
<select id="avail-sort" aria-label="Sort available items">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="avail-summary" class="inv-summary"></div>
<div id="avail-empty" class="empty-state hidden">
<p>Nothing is being offered right now.</p>
<p class="hint">Mark items as available in My Inventory to list them here.</p>
</div>
<div id="avail-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Offered by</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="avail-tbody"></tbody>
</table>
</div>
</section>
<footer class="site-footer">
<p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
@@ -111,6 +150,18 @@
</div>
</div>
<div id="confirm-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="alertdialog" aria-modal="true"
aria-labelledby="confirm-title" aria-describedby="confirm-message">
<h2 id="confirm-title">Remove item</h2>
<p id="confirm-message" class="modal-desc"></p>
<div class="modal-actions">
<button type="button" id="confirm-cancel" class="btn-secondary">Cancel</button>
<button type="button" id="confirm-ok" class="btn-danger">Remove</button>
</div>
</div>
</div>
<div id="auth-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<button class="modal-close" id="auth-close" aria-label="Close">&times;</button>

View File

@@ -132,6 +132,23 @@ body {
}
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-danger {
background: var(--danger);
color: #1b0505;
border: 1px solid var(--danger);
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.btn-danger:hover { filter: brightness(1.1); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.2rem;
}
/* Main */
main {
@@ -350,7 +367,8 @@ main {
#inv-sort { flex: 1 1 170px; }
.inventory-controls input:focus,
.inventory-controls select:focus { outline: none; border-color: var(--accent); }
#inventory-view {
#inventory-view,
#available-view {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 1.25rem 0;
@@ -381,6 +399,21 @@ main {
}
.inv-table tr:last-child td { border-bottom: none; }
.inv-item-name { font-weight: 600; }
.inv-item-link {
display: block;
width: 100%;
background: none;
border: none;
padding: 0;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
}
.inv-item-link:hover .inv-item-name { text-decoration: underline; }
.inv-item-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.inv-item-link.is-static { cursor: default; }
.inv-item-link.is-static:hover .inv-item-name { text-decoration: none; }
.inv-item-sub {
color: var(--text-dim);
font-size: 0.75rem;
@@ -414,6 +447,25 @@ main {
cursor: pointer;
}
.inv-remove:hover { border-color: var(--danger); }
.visibility-toggle {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
cursor: pointer;
white-space: nowrap;
}
.visibility-toggle:hover { border-color: var(--accent); color: var(--text); }
.visibility-toggle.is-available {
border-color: var(--success);
color: var(--success);
background: rgba(34, 197, 94, 0.12);
}
.visibility-toggle:disabled { opacity: 0.6; cursor: progress; }
.avail-user { font-weight: 600; }
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
/* Auth modal */