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

@@ -116,11 +116,22 @@ The inventory view can be filtered by category and searched by name/class
name, and sorted by `name` (default), `category`, `quantity` (high to low) or name, and sorted by `name` (default), `category`, `quantity` (high to low) or
`updated` (most recently changed first). `updated` (most recently changed first).
Inventory rows store `(user_id, class_name, quantity, notes)`. Adding an item Inventory rows store `(user_id, class_name, quantity, notes, visibility)`.
that is already present increments its quantity; quantity and notes can then be Adding an item that is already present increments its quantity; quantity, notes
edited or the row removed. Responses are enriched live from the catalog. If a and visibility can then be edited or the row removed. Every added item starts
catalog item disappears after a dataset refresh, the row is reported with `private`; the Visibility toggle switches it to `available` and back. Responses
`available: false` instead of being silently deleted. are enriched live from the catalog. If a catalog item disappears after a
dataset refresh, the row is reported with `available: false` instead of being
silently deleted (`available` is about the catalog, `visibility` is the owner's
classification).
The **Available** tab (backed by `GET /api/v1/available`) is public, like the
catalog: it lists every row marked `available` across all accounts with the
item's catalog details, quantity, owner username and last update. Private rows
never appear there and inventory notes are never exposed.
Clicking an item name in either **My Inventory** or **Available** opens the same
catalog detail view used by the catalog grid.
### Security: read before exposing this publicly ### Security: read before exposing this publicly
@@ -161,7 +172,7 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory
cd trade-hub/backend cd trade-hub/backend
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt .venv/bin/pip install -r requirements.txt -r requirements-dev.txt
.venv/bin/python -m pytest tests/ -q # 50 tests, no database needed .venv/bin/python -m pytest tests/ -q # 59 tests, no database needed
DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000 DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000
``` ```
@@ -173,8 +184,9 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL.
Tables are created on startup with an idempotent `create_all` and a connection Tables are created on startup with an idempotent `create_all` and a connection
retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does
not crash the backend. This does not migrate changed columns — introduce Alembic not crash the backend. Additive columns for existing deployments (currently
when the schema starts evolving in place. `inventory_entries.visibility`) are applied automatically on startup; changing
or dropping a column still requires Alembic.
## API ## API
@@ -193,11 +205,15 @@ GET /api/v1/auth/me -> {user}
GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size= GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
GET /api/v1/me/inventory/summary GET /api/v1/me/inventory/summary
POST /api/v1/me/inventory {class_name, quantity?, notes?} POST /api/v1/me/inventory {class_name, quantity?, notes?}
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?} PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?}
DELETE /api/v1/me/inventory/{class_name} DELETE /api/v1/me/inventory/{class_name}
GET /api/v1/available?category=&search=&sort=&page=&page_size=
``` ```
`/api/v1/me/*` requires a valid session cookie and returns `401` otherwise. `/api/v1/me/*` requires a valid session cookie and returns `401` otherwise.
`/api/v1/available` is public and read-only; it accepts `sort=name|category|quantity|updated`
(default `updated`).
### Configuration ### Configuration

View File

@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app import config from app import config
from app.auth import router as auth_router from app.auth import router as auth_router
from app.available import router as available_router
from app.catalog import store from app.catalog import store
from app.db import db_healthy, init_db from app.db import db_healthy, init_db
from app.inventory import router as inventory_router from app.inventory import router as inventory_router
@@ -45,6 +46,7 @@ if config.CORS_ORIGINS:
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(inventory_router) app.include_router(inventory_router)
app.include_router(available_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"} VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}

View File

@@ -51,12 +51,46 @@ def get_db():
db.close() db.close()
def _upgrade_schema(engine) -> None:
"""Add columns that `create_all` cannot add to an existing table.
PostgreSQL only: the SQLite database used by the tests is created fresh
from the models on every run, so it already has every column.
"""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"visibility VARCHAR(16) NOT NULL DEFAULT 'private'"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_visibility'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_visibility
CHECK (visibility IN ('private', 'available'));
END IF;
END $$
"""
)
)
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None: def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
"""Create missing tables, retrying while PostgreSQL finishes booting. """Create missing tables, retrying while PostgreSQL finishes booting.
`create_all(checkfirst=True)` is idempotent and safe to run on every start. `create_all(checkfirst=True)` is idempotent and safe to run on every start.
It does not migrate changed columns; introduce Alembic when the schema Additive columns for existing deployments are applied afterwards; changing
starts evolving in place. or dropping an existing column still requires Alembic.
""" """
deadline = time.monotonic() + max(wait_seconds, 0) deadline = time.monotonic() + max(wait_seconds, 0)
while True: while True:
@@ -65,6 +99,7 @@ def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("SELECT 1")) conn.execute(text("SELECT 1"))
Base.metadata.create_all(bind=engine, checkfirst=True) Base.metadata.create_all(bind=engine, checkfirst=True)
_upgrade_schema(engine)
return return
except (OperationalError, IntegrityError, ProgrammingError) as exc: except (OperationalError, IntegrityError, ProgrammingError) as exc:
# A concurrent startup may have created the tables first. # A concurrent startup may have created the tables first.

View File

@@ -33,7 +33,7 @@ router = APIRouter(
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100
def _enrich(entry: InventoryEntry) -> dict: def enrich_entry(entry: InventoryEntry) -> dict:
item = catalog.store.by_class_name.get(entry.class_name) item = catalog.store.by_class_name.get(entry.class_name)
if item is None: if item is None:
return { return {
@@ -46,6 +46,7 @@ def _enrich(entry: InventoryEntry) -> dict:
"manufacturer": None, "manufacturer": None,
"quantity": entry.quantity, "quantity": entry.quantity,
"notes": entry.notes, "notes": entry.notes,
"visibility": entry.visibility,
"available": False, "available": False,
"created_at": entry.created_at, "created_at": entry.created_at,
"updated_at": entry.updated_at, "updated_at": entry.updated_at,
@@ -60,6 +61,7 @@ def _enrich(entry: InventoryEntry) -> dict:
"manufacturer": item["manufacturer"], "manufacturer": item["manufacturer"],
"quantity": entry.quantity, "quantity": entry.quantity,
"notes": entry.notes, "notes": entry.notes,
"visibility": entry.visibility,
"available": True, "available": True,
"created_at": entry.created_at, "created_at": entry.created_at,
"updated_at": entry.updated_at, "updated_at": entry.updated_at,
@@ -91,7 +93,7 @@ def _sort_key_updated(item):
return (-item["updated_at"].timestamp(), item["name"].lower()) return (-item["updated_at"].timestamp(), item["name"].lower())
_SORT_KEYS = { SORT_KEYS = {
"name": _sort_key_name, "name": _sort_key_name,
"category": _sort_key_category, "category": _sort_key_category,
"quantity": _sort_key_quantity, "quantity": _sort_key_quantity,
@@ -114,7 +116,7 @@ def list_inventory(
select(InventoryEntry).where(InventoryEntry.user_id == user.id) select(InventoryEntry).where(InventoryEntry.user_id == user.id)
).scalars().all() ).scalars().all()
items = [_enrich(row) for row in rows] items = [enrich_entry(row) for row in rows]
if category is not None and category.strip(): if category is not None and category.strip():
wanted = category.strip() wanted = category.strip()
@@ -127,7 +129,7 @@ def list_inventory(
if needle in i["name"].lower() or needle in i["class_name"].lower() if needle in i["name"].lower() or needle in i["class_name"].lower()
] ]
items.sort(key=_SORT_KEYS[sort]) items.sort(key=SORT_KEYS[sort])
total = len(items) total = len(items)
total_pages = max(1, math.ceil(total / page_size)) total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size start = (page - 1) * page_size
@@ -213,7 +215,7 @@ def add_to_inventory(
db.refresh(entry) db.refresh(entry)
response.status_code = 201 if created else 200 response.status_code = 201 if created else 200
return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created} return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created}
@router.patch("/{class_name}", response_model=InventoryEntryOut) @router.patch("/{class_name}", response_model=InventoryEntryOut)
@@ -231,10 +233,12 @@ def update_inventory_entry(
entry.quantity = payload.quantity entry.quantity = payload.quantity
if payload.notes is not None: if payload.notes is not None:
entry.notes = payload.notes entry.notes = payload.notes
if payload.visibility is not None:
entry.visibility = payload.visibility
db.commit() db.commit()
db.refresh(entry) db.refresh(entry)
return InventoryEntryOut.model_validate(_enrich(entry)) return InventoryEntryOut.model_validate(enrich_entry(entry))
@router.delete("/{class_name}", status_code=204) @router.delete("/{class_name}", status_code=204)

View File

@@ -74,6 +74,10 @@ class InventoryEntry(Base):
__table_args__ = ( __table_args__ = (
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"), UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"), CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
CheckConstraint(
"visibility IN ('private', 'available')",
name="ck_inventory_visibility",
),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -83,6 +87,12 @@ class InventoryEntry(Base):
class_name: Mapped[str] = mapped_column(String(255), nullable=False) class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="") notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
# "private" (default): only the owner sees the row. "available": the owner
# is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item.
visibility: Mapped[str] = mapped_column(
String(16), nullable=False, default="private", server_default="private"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow DateTime(timezone=True), nullable=False, default=utcnow
) )

View File

@@ -1,6 +1,6 @@
"""Pydantic request/response models for auth and personal inventory.""" """Pydantic request/response models for auth and personal inventory."""
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -38,6 +38,7 @@ class InventoryAddRequest(BaseModel):
class InventoryPatchRequest(BaseModel): class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY) quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH) notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
visibility: Literal["private", "available"] | None = None
class InventoryEntryOut(BaseModel): class InventoryEntryOut(BaseModel):
@@ -50,6 +51,8 @@ class InventoryEntryOut(BaseModel):
manufacturer: dict | None manufacturer: dict | None
quantity: int quantity: int
notes: str notes: str
# Owner-chosen classification. Unrelated to `available` below.
visibility: Literal["private", "available"]
# False when the catalog no longer contains the referenced item (e.g. after # False when the catalog no longer contains the referenced item (e.g. after
# a dataset refresh). The row is preserved rather than silently deleted. # a dataset refresh). The row is preserved rather than silently deleted.
available: bool available: bool
@@ -72,6 +75,28 @@ class InventorySummary(BaseModel):
unavailable: int unavailable: int
class AvailableItemOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
# Public listing: only the owner's display name, never notes.
username: str
updated_at: datetime
class AvailablePage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[AvailableItemOut]
class InventoryAddResponse(BaseModel): class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut entry: InventoryEntryOut
created: bool created: bool

View File

@@ -60,6 +60,7 @@ def test_add_creates_enriched_entry():
assert entry["name"] == "Anvil Carrack" assert entry["name"] == "Anvil Carrack"
assert entry["category"] == "ships" assert entry["category"] == "ships"
assert entry["quantity"] == 1 assert entry["quantity"] == 1
assert entry["visibility"] == "private"
assert entry["available"] is True assert entry["available"] is True
assert entry["manufacturer"]["code"] == "ANVL" assert entry["manufacturer"]["code"] == "ANVL"
@@ -105,6 +106,43 @@ def test_patch_updates_quantity_and_notes():
assert body["notes"] == "hangar 3" assert body["notes"] == "hangar 3"
def test_visibility_toggles_between_private_and_available():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"}
)
assert resp.status_code == 200
assert resp.json()["visibility"] == "available"
listed = client.get("/api/v1/me/inventory").json()["items"]
assert listed[0]["visibility"] == "available"
back = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"}
)
assert back.json()["visibility"] == "private"
def test_patch_rejects_unknown_visibility():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "public"})
assert resp.status_code == 422
def test_visibility_is_kept_when_quantity_changes_and_item_is_readded():
client = authed_client()
add(client, CARROCK)
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 3})
assert add(client, CARROCK, quantity=1).status_code == 200
entry = client.get("/api/v1/me/inventory").json()["items"][0]
assert entry["quantity"] == 4
assert entry["visibility"] == "available"
def test_patch_unknown_entry_is_not_found(): def test_patch_unknown_entry_is_not_found():
client = authed_client() client = authed_client()
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404 assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404

View File

@@ -18,6 +18,7 @@ const state = {
view: "catalog", view: "catalog",
user: null, user: null,
inventory: { search: "", category: "", sort: "name" }, inventory: { search: "", category: "", sort: "name" },
available: { search: "", category: "", sort: "updated" },
}; };
let authMode = "login"; let authMode = "login";
@@ -34,9 +35,13 @@ function cacheElements() {
"modal-backdrop", "modal-close", "modal-title", "modal-body", "modal-backdrop", "modal-close", "modal-title", "modal-body",
"catalog-view", "inventory-view", "inv-search", "inv-category", "inv-sort", "catalog-view", "inventory-view", "inv-search", "inv-category", "inv-sort",
"inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine", "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-backdrop", "auth-close", "auth-title", "auth-form",
"auth-username", "auth-password", "auth-error", "auth-submit", "auth-username", "auth-password", "auth-error", "auth-submit",
"auth-toggle", "toast", "auth-toggle", "toast",
"confirm-backdrop", "confirm-message", "confirm-ok", "confirm-cancel",
]; ];
for (const id of ids) els[id] = document.getElementById(id); for (const id of ids) els[id] = document.getElementById(id);
} }
@@ -369,6 +374,26 @@ function closeModal() {
els["modal-backdrop"].classList.add("hidden"); 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 ---------- */ /* ---------- personal inventory ---------- */
async function switchToMine() { async function switchToMine() {
@@ -380,6 +405,7 @@ async function switchToMine() {
setActiveTab(els["tab-mine"]); setActiveTab(els["tab-mine"]);
state.view = "mine"; state.view = "mine";
els["catalog-view"].classList.add("hidden"); els["catalog-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden"); els["inventory-view"].classList.remove("hidden");
await loadInventory(); await loadInventory();
} }
@@ -387,6 +413,7 @@ async function switchToMine() {
function showCatalogView() { function showCatalogView() {
state.view = "catalog"; state.view = "catalog";
els["inventory-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["catalog-view"].classList.remove("hidden"); els["catalog-view"].classList.remove("hidden");
} }
@@ -443,6 +470,29 @@ function renderInventorySummary(summary) {
(parts ? ` · ${parts}` : "") + unavailable; (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) { function renderInventory(listing) {
const tbody = els["inv-tbody"]; const tbody = els["inv-tbody"];
tbody.innerHTML = ""; tbody.innerHTML = "";
@@ -452,11 +502,7 @@ function renderInventory(listing) {
for (const entry of listing.items) { for (const entry of listing.items) {
const row = document.createElement("tr"); const row = document.createElement("tr");
const nameCell = document.createElement("td"); const nameCell = itemNameCell(entry);
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 categoryCell = document.createElement("td"); const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category categoryCell.innerHTML = entry.category
@@ -503,11 +549,48 @@ function renderInventory(listing) {
remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name)); remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name));
actionCell.appendChild(remove); 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); 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) { async function patchEntry(class_name, payload) {
try { try {
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { 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) { 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 { try {
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
method: "DELETE", method: "DELETE",
}); });
showToast("Removed from your inventory"); showToast("Removed from your inventory");
loadInventory(); await loadInventory();
} catch (err) { } catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true); 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 ---------- */ /* ---------- events ---------- */
function debounce(fn, wait) { function debounce(fn, wait) {
@@ -585,6 +741,10 @@ function bindEvents() {
switchToMine(); switchToMine();
return; return;
} }
if (tab.dataset.view === "available") {
switchToAvailable();
return;
}
setActiveTab(tab); setActiveTab(tab);
showCatalogView(); showCatalogView();
state.category = tab.dataset.category; state.category = tab.dataset.category;
@@ -647,8 +807,14 @@ function bindEvents() {
els["modal-backdrop"].addEventListener("click", (event) => { els["modal-backdrop"].addEventListener("click", (event) => {
if (event.target === els["modal-backdrop"]) closeModal(); 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) => { document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeConfirm(false);
closeModal(); closeModal();
closeAuth(); 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 ---------- */ /* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
@@ -690,6 +874,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindEvents(); bindEvents();
bindAuthEvents(); bindAuthEvents();
bindInventoryEvents(); bindInventoryEvents();
bindAvailableEvents();
initHeader(); initHeader();
await initAuth(); await initAuth();
loadItems(); 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="ship-items">Ship Equipment</button>
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</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 tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
<button class="tab" id="tab-available" data-view="available">Available</button>
</nav> </nav>
<div id="catalog-view"> <div id="catalog-view">
@@ -88,6 +89,7 @@
<th>Category</th> <th>Category</th>
<th>Qty</th> <th>Qty</th>
<th>Notes</th> <th>Notes</th>
<th>Visibility</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -96,6 +98,43 @@
</div> </div>
</section> </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"> <footer class="site-footer">
<p>Data source: <p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a> <a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
@@ -111,6 +150,18 @@
</div> </div>
</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 id="auth-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title"> <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> <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:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; } .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 */
main { main {
@@ -350,7 +367,8 @@ main {
#inv-sort { flex: 1 1 170px; } #inv-sort { flex: 1 1 170px; }
.inventory-controls input:focus, .inventory-controls input:focus,
.inventory-controls select:focus { outline: none; border-color: var(--accent); } .inventory-controls select:focus { outline: none; border-color: var(--accent); }
#inventory-view { #inventory-view,
#available-view {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem 1.25rem 0; padding: 1rem 1.25rem 0;
@@ -381,6 +399,21 @@ main {
} }
.inv-table tr:last-child td { border-bottom: none; } .inv-table tr:last-child td { border-bottom: none; }
.inv-item-name { font-weight: 600; } .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 { .inv-item-sub {
color: var(--text-dim); color: var(--text-dim);
font-size: 0.75rem; font-size: 0.75rem;
@@ -414,6 +447,25 @@ main {
cursor: pointer; cursor: pointer;
} }
.inv-remove:hover { border-color: var(--danger); } .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; } .unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
/* Auth modal */ /* Auth modal */