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
`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

View File

@@ -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"}

View File

@@ -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.

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

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 */