fixed remove issue, made items avaiable for available view
This commit is contained in:
34
README.md
34
README.md
@@ -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
|
||||
`updated` (most recently changed first).
|
||||
|
||||
Inventory rows store `(user_id, class_name, quantity, notes)`. Adding an item
|
||||
that is already present increments its quantity; quantity and notes can then be
|
||||
edited or the row removed. Responses 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.
|
||||
Inventory rows store `(user_id, class_name, quantity, notes, visibility)`.
|
||||
Adding an item that is already present increments its quantity; quantity, notes
|
||||
and visibility can then be edited or the row removed. Every added item starts
|
||||
`private`; the Visibility toggle switches it to `available` and back. Responses
|
||||
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
|
||||
|
||||
@@ -161,7 +172,7 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory
|
||||
cd trade-hub/backend
|
||||
python3 -m venv .venv
|
||||
.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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
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
|
||||
when the schema starts evolving in place.
|
||||
not crash the backend. Additive columns for existing deployments (currently
|
||||
`inventory_entries.visibility`) are applied automatically on startup; changing
|
||||
or dropping a column still requires Alembic.
|
||||
|
||||
## 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/summary
|
||||
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}
|
||||
|
||||
GET /api/v1/available?category=&search=&sort=&page=&page_size=
|
||||
```
|
||||
|
||||
`/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
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import config
|
||||
from app.auth import router as auth_router
|
||||
from app.available import router as available_router
|
||||
from app.catalog import store
|
||||
from app.db import db_healthy, init_db
|
||||
from app.inventory import router as inventory_router
|
||||
@@ -45,6 +46,7 @@ if config.CORS_ORIGINS:
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(available_router)
|
||||
|
||||
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}
|
||||
|
||||
|
||||
@@ -51,12 +51,46 @@ def get_db():
|
||||
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:
|
||||
"""Create missing tables, retrying while PostgreSQL finishes booting.
|
||||
|
||||
`create_all(checkfirst=True)` is idempotent and safe to run on every start.
|
||||
It does not migrate changed columns; introduce Alembic when the schema
|
||||
starts evolving in place.
|
||||
Additive columns for existing deployments are applied afterwards; changing
|
||||
or dropping an existing column still requires Alembic.
|
||||
"""
|
||||
deadline = time.monotonic() + max(wait_seconds, 0)
|
||||
while True:
|
||||
@@ -65,6 +99,7 @@ def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
Base.metadata.create_all(bind=engine, checkfirst=True)
|
||||
_upgrade_schema(engine)
|
||||
return
|
||||
except (OperationalError, IntegrityError, ProgrammingError) as exc:
|
||||
# A concurrent startup may have created the tables first.
|
||||
|
||||
@@ -33,7 +33,7 @@ router = APIRouter(
|
||||
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)
|
||||
if item is None:
|
||||
return {
|
||||
@@ -46,6 +46,7 @@ def _enrich(entry: InventoryEntry) -> dict:
|
||||
"manufacturer": None,
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"visibility": entry.visibility,
|
||||
"available": False,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
@@ -60,6 +61,7 @@ def _enrich(entry: InventoryEntry) -> dict:
|
||||
"manufacturer": item["manufacturer"],
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"visibility": entry.visibility,
|
||||
"available": True,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
@@ -91,7 +93,7 @@ def _sort_key_updated(item):
|
||||
return (-item["updated_at"].timestamp(), item["name"].lower())
|
||||
|
||||
|
||||
_SORT_KEYS = {
|
||||
SORT_KEYS = {
|
||||
"name": _sort_key_name,
|
||||
"category": _sort_key_category,
|
||||
"quantity": _sort_key_quantity,
|
||||
@@ -114,7 +116,7 @@ def list_inventory(
|
||||
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
|
||||
).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():
|
||||
wanted = category.strip()
|
||||
@@ -127,7 +129,7 @@ def list_inventory(
|
||||
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_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
@@ -213,7 +215,7 @@ def add_to_inventory(
|
||||
|
||||
db.refresh(entry)
|
||||
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)
|
||||
@@ -231,10 +233,12 @@ def update_inventory_entry(
|
||||
entry.quantity = payload.quantity
|
||||
if payload.notes is not None:
|
||||
entry.notes = payload.notes
|
||||
if payload.visibility is not None:
|
||||
entry.visibility = payload.visibility
|
||||
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return InventoryEntryOut.model_validate(_enrich(entry))
|
||||
return InventoryEntryOut.model_validate(enrich_entry(entry))
|
||||
|
||||
|
||||
@router.delete("/{class_name}", status_code=204)
|
||||
|
||||
@@ -74,6 +74,10 @@ class InventoryEntry(Base):
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
|
||||
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)
|
||||
@@ -83,6 +87,12 @@ class InventoryEntry(Base):
|
||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
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(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Pydantic request/response models for auth and personal inventory."""
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -38,6 +38,7 @@ class InventoryAddRequest(BaseModel):
|
||||
class InventoryPatchRequest(BaseModel):
|
||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||
visibility: Literal["private", "available"] | None = None
|
||||
|
||||
|
||||
class InventoryEntryOut(BaseModel):
|
||||
@@ -50,6 +51,8 @@ class InventoryEntryOut(BaseModel):
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
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
|
||||
# a dataset refresh). The row is preserved rather than silently deleted.
|
||||
available: bool
|
||||
@@ -72,6 +75,28 @@ class InventorySummary(BaseModel):
|
||||
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):
|
||||
entry: InventoryEntryOut
|
||||
created: bool
|
||||
|
||||
@@ -60,6 +60,7 @@ def test_add_creates_enriched_entry():
|
||||
assert entry["name"] == "Anvil Carrack"
|
||||
assert entry["category"] == "ships"
|
||||
assert entry["quantity"] == 1
|
||||
assert entry["visibility"] == "private"
|
||||
assert entry["available"] is True
|
||||
assert entry["manufacturer"]["code"] == "ANVL"
|
||||
|
||||
@@ -105,6 +106,43 @@ def test_patch_updates_quantity_and_notes():
|
||||
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():
|
||||
client = authed_client()
|
||||
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">×</button>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user