Compare commits
6 Commits
0a93925e11
...
120daf8c31
| Author | SHA1 | Date | |
|---|---|---|---|
| 120daf8c31 | |||
| 2807ffd160 | |||
| 51cd47bd47 | |||
| 83611e2a2c | |||
| 8f3bff2e62 | |||
| c17d8a9f8a |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.omo/
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionID": "ses_e2f302d08ffewav8qUiPZ0aI2j",
|
|
||||||
"updatedAt": "2026-11-12T17:30:43.353Z",
|
|
||||||
"sources": {
|
|
||||||
"background-task": {
|
|
||||||
"state": "idle",
|
|
||||||
"updatedAt": "2026-11-12T17:30:43.353Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionID": "ses_e2f304d0bffewgGWmy7An2rOeJ",
|
|
||||||
"updatedAt": "2026-11-12T17:30:35.146Z",
|
|
||||||
"sources": {
|
|
||||||
"background-task": {
|
|
||||||
"state": "idle",
|
|
||||||
"updatedAt": "2026-11-12T17:30:35.146Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionID": "ses_e2f306c90ffe63BtszteHo8OAl",
|
|
||||||
"updatedAt": "2026-11-12T17:30:27.126Z",
|
|
||||||
"sources": {
|
|
||||||
"background-task": {
|
|
||||||
"state": "idle",
|
|
||||||
"updatedAt": "2026-11-12T17:30:27.126Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionID": "ses_e2f306cc1ffemzh3awaA137wZh",
|
|
||||||
"updatedAt": "2026-11-12T17:30:27.053Z",
|
|
||||||
"sources": {
|
|
||||||
"background-task": {
|
|
||||||
"state": "idle",
|
|
||||||
"updatedAt": "2026-11-12T17:30:27.053Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionID": "ses_e2f306cedffevymy11Qu04FoB0",
|
|
||||||
"updatedAt": "2026-11-12T17:30:27.004Z",
|
|
||||||
"sources": {
|
|
||||||
"background-task": {
|
|
||||||
"state": "idle",
|
|
||||||
"updatedAt": "2026-11-12T17:30:27.004Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionID": "ses_e2f393836ffeUi9lBqb1kfxxoz",
|
|
||||||
"updatedAt": "2026-11-12T18:38:36.743Z",
|
|
||||||
"sources": {
|
|
||||||
"background-task": {
|
|
||||||
"state": "idle",
|
|
||||||
"updatedAt": "2026-11-12T18:38:36.743Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"sessionID": "ses_e2f45eaccffeiis1QONAKg7gzm",
|
|
||||||
"updatedAt": "2026-11-12T17:10:58.656Z",
|
|
||||||
"sources": {
|
|
||||||
"background-task": {
|
|
||||||
"state": "idle",
|
|
||||||
"updatedAt": "2026-11-12T17:10:58.656Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
40
README.md
40
README.md
@@ -112,11 +112,26 @@ from scratch.
|
|||||||
- Catalog browsing works without an account; the catalog endpoints remain
|
- Catalog browsing works without an account; the catalog endpoints remain
|
||||||
read-only.
|
read-only.
|
||||||
|
|
||||||
Inventory rows store `(user_id, class_name, quantity, notes)`. Adding an item
|
The inventory view can be filtered by category and searched by name/class
|
||||||
that is already present increments its quantity; quantity and notes can then be
|
name, and sorted by `name` (default), `category`, `quantity` (high to low) or
|
||||||
edited or the row removed. Responses are enriched live from the catalog. If a
|
`updated` (most recently changed first).
|
||||||
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
|
### Security: read before exposing this publicly
|
||||||
|
|
||||||
@@ -157,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 # 45 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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -169,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
|
||||||
|
|
||||||
@@ -186,14 +202,18 @@ POST /api/v1/auth/login {username, password} -> 200 + session c
|
|||||||
POST /api/v1/auth/logout -> 204
|
POST /api/v1/auth/logout -> 204
|
||||||
GET /api/v1/auth/me -> {user}
|
GET /api/v1/auth/me -> {user}
|
||||||
|
|
||||||
GET /api/v1/me/inventory?category=&search=&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
|
||||||
|
|
||||||
|
|||||||
15
playbook.yml
15
playbook.yml
@@ -60,17 +60,6 @@
|
|||||||
{{ playbook_dir }}/trade-hub/frontend
|
{{ playbook_dir }}/trade-hub/frontend
|
||||||
when: frontend_image_check.rc != 0 or force_rebuild | bool
|
when: frontend_image_check.rc != 0 or force_rebuild | bool
|
||||||
|
|
||||||
# podman_container does not recreate a container whose image tag now
|
|
||||||
# resolves to a new ID, so rebuilt images require removing them first.
|
|
||||||
- name: Remove containers so rebuilt images are used
|
|
||||||
containers.podman.podman_container:
|
|
||||||
name: "{{ item }}"
|
|
||||||
state: absent
|
|
||||||
loop:
|
|
||||||
- sc-inventory-backend
|
|
||||||
- sc-inventory-frontend
|
|
||||||
when: force_rebuild | bool
|
|
||||||
|
|
||||||
- name: Create podman network
|
- name: Create podman network
|
||||||
containers.podman.podman_network:
|
containers.podman.podman_network:
|
||||||
name: "{{ network_name }}"
|
name: "{{ network_name }}"
|
||||||
@@ -102,6 +91,9 @@
|
|||||||
name: sc-inventory-backend
|
name: sc-inventory-backend
|
||||||
image: "{{ backend_image }}:{{ image_tag }}"
|
image: "{{ backend_image }}:{{ image_tag }}"
|
||||||
state: started
|
state: started
|
||||||
|
# podman does not recreate a container when its image tag resolves to
|
||||||
|
# a new ID, so without this a rebuilt image would be ignored.
|
||||||
|
recreate: true
|
||||||
network: "{{ network_name }}"
|
network: "{{ network_name }}"
|
||||||
# Network alias "backend" matches the compose service name, so the
|
# Network alias "backend" matches the compose service name, so the
|
||||||
# nginx upstream (http://backend:8000) resolves in both deployments.
|
# nginx upstream (http://backend:8000) resolves in both deployments.
|
||||||
@@ -118,6 +110,7 @@
|
|||||||
name: sc-inventory-frontend
|
name: sc-inventory-frontend
|
||||||
image: "{{ frontend_image }}:{{ image_tag }}"
|
image: "{{ frontend_image }}:{{ image_tag }}"
|
||||||
state: started
|
state: started
|
||||||
|
recreate: true
|
||||||
network: "{{ network_name }}"
|
network: "{{ network_name }}"
|
||||||
ports:
|
ports:
|
||||||
- "{{ frontend_port }}"
|
- "{{ frontend_port }}"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -75,10 +77,35 @@ def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key_name(item):
|
||||||
|
return (item["name"].lower(), item["class_name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key_category(item):
|
||||||
|
return ((item["category"] or "").lower(), item["name"].lower(), item["class_name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key_quantity(item):
|
||||||
|
return (-item["quantity"], item["name"].lower(), item["class_name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key_updated(item):
|
||||||
|
return (-item["updated_at"].timestamp(), item["name"].lower())
|
||||||
|
|
||||||
|
|
||||||
|
SORT_KEYS = {
|
||||||
|
"name": _sort_key_name,
|
||||||
|
"category": _sort_key_category,
|
||||||
|
"quantity": _sort_key_quantity,
|
||||||
|
"updated": _sort_key_updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=InventoryPage)
|
@router.get("", response_model=InventoryPage)
|
||||||
def list_inventory(
|
def list_inventory(
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
sort: str = Query(default="name", pattern="^(name|category|quantity|updated)$"),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=25, ge=1),
|
page_size: int = Query(default=25, ge=1),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -89,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()
|
||||||
@@ -102,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=lambda i: (i["name"].lower(), i["class_name"]))
|
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
|
||||||
@@ -188,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)
|
||||||
@@ -206,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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -189,3 +227,27 @@ def test_catalog_is_not_mutated_by_inventory_operations():
|
|||||||
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
|
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
|
||||||
assert "quantity" not in carrack
|
assert "quantity" not in carrack
|
||||||
assert "notes" not in carrack
|
assert "notes" not in carrack
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_by_category_groups_categories():
|
||||||
|
client = authed_client()
|
||||||
|
add(client, CARROCK)
|
||||||
|
add(client, RIFLE)
|
||||||
|
items = client.get("/api/v1/me/inventory?sort=category").json()["items"]
|
||||||
|
assert [i["class_name"] for i in items] == [RIFLE, CARROCK]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_by_quantity_is_descending():
|
||||||
|
client = authed_client()
|
||||||
|
add(client, CARROCK, quantity=5)
|
||||||
|
add(client, RIFLE, quantity=1)
|
||||||
|
items = client.get("/api/v1/me/inventory?sort=quantity").json()["items"]
|
||||||
|
assert [i["class_name"] for i in items] == [CARROCK, RIFLE]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sort_defaults_to_name_and_rejects_unknown_values():
|
||||||
|
client = authed_client()
|
||||||
|
add(client, CARROCK)
|
||||||
|
listing = client.get("/api/v1/me/inventory").json()
|
||||||
|
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
|
||||||
|
assert client.get("/api/v1/me/inventory?sort=bogus").status_code == 422
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const state = {
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
view: "catalog",
|
view: "catalog",
|
||||||
user: null,
|
user: null,
|
||||||
inventory: { search: "", category: "" },
|
inventory: { search: "", category: "", sort: "name" },
|
||||||
|
available: { search: "", category: "", sort: "updated" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let authMode = "login";
|
let authMode = "login";
|
||||||
@@ -32,11 +33,15 @@ function cacheElements() {
|
|||||||
"error-banner", "loading", "empty-state", "results-grid",
|
"error-banner", "loading", "empty-state", "results-grid",
|
||||||
"pagination", "prev-page", "next-page", "page-info",
|
"pagination", "prev-page", "next-page", "page-info",
|
||||||
"modal-backdrop", "modal-close", "modal-title", "modal-body",
|
"modal-backdrop", "modal-close", "modal-title", "modal-body",
|
||||||
"catalog-view", "inventory-view", "inv-search", "inv-category",
|
"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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,8 +421,10 @@ function showCatalogView() {
|
|||||||
function resetInventoryView() {
|
function resetInventoryView() {
|
||||||
state.inventory.search = "";
|
state.inventory.search = "";
|
||||||
state.inventory.category = "";
|
state.inventory.category = "";
|
||||||
|
state.inventory.sort = "name";
|
||||||
els["inv-search"].value = "";
|
els["inv-search"].value = "";
|
||||||
els["inv-category"].value = "";
|
els["inv-category"].value = "";
|
||||||
|
els["inv-sort"].value = "name";
|
||||||
els["inv-summary"].textContent = "";
|
els["inv-summary"].textContent = "";
|
||||||
renderInventory({ total: 0, items: [] });
|
renderInventory({ total: 0, items: [] });
|
||||||
}
|
}
|
||||||
@@ -404,6 +433,7 @@ async function loadInventory() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (state.inventory.search) params.set("search", state.inventory.search);
|
if (state.inventory.search) params.set("search", state.inventory.search);
|
||||||
if (state.inventory.category) params.set("category", state.inventory.category);
|
if (state.inventory.category) params.set("category", state.inventory.category);
|
||||||
|
params.set("sort", state.inventory.sort);
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
params.set("page_size", String(INVENTORY_PAGE_SIZE));
|
params.set("page_size", String(INVENTORY_PAGE_SIZE));
|
||||||
|
|
||||||
@@ -440,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 = "";
|
||||||
@@ -449,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
|
||||||
@@ -500,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)}`, {
|
||||||
@@ -520,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);
|
||||||
}
|
}
|
||||||
@@ -554,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) {
|
||||||
@@ -582,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;
|
||||||
@@ -644,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();
|
||||||
});
|
});
|
||||||
@@ -674,6 +843,28 @@ function bindInventoryEvents() {
|
|||||||
state.inventory.category = els["inv-category"].value;
|
state.inventory.category = els["inv-category"].value;
|
||||||
loadInventory();
|
loadInventory();
|
||||||
});
|
});
|
||||||
|
els["inv-sort"].addEventListener("change", () => {
|
||||||
|
state.inventory.sort = els["inv-sort"].value;
|
||||||
|
loadInventory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ---------- */
|
||||||
@@ -683,6 +874,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
bindEvents();
|
bindEvents();
|
||||||
bindAuthEvents();
|
bindAuthEvents();
|
||||||
bindInventoryEvents();
|
bindInventoryEvents();
|
||||||
|
bindAvailableEvents();
|
||||||
initHeader();
|
initHeader();
|
||||||
await initAuth();
|
await initAuth();
|
||||||
loadItems();
|
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="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">
|
||||||
@@ -68,6 +69,12 @@
|
|||||||
<option value="ship-items">Ship Equipment</option>
|
<option value="ship-items">Ship Equipment</option>
|
||||||
<option value="fps-items">FPS Items</option>
|
<option value="fps-items">FPS Items</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="inv-sort" aria-label="Sort inventory">
|
||||||
|
<option value="name">Sort: Name</option>
|
||||||
|
<option value="category">Sort: Category</option>
|
||||||
|
<option value="quantity">Sort: Quantity</option>
|
||||||
|
<option value="updated">Sort: Recently updated</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="inv-summary" class="inv-summary"></div>
|
<div id="inv-summary" class="inv-summary"></div>
|
||||||
<div id="inv-empty" class="empty-state hidden">
|
<div id="inv-empty" class="empty-state hidden">
|
||||||
@@ -82,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>
|
||||||
@@ -90,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>
|
||||||
@@ -105,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">×</button>
|
<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: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 {
|
||||||
@@ -347,9 +364,11 @@ main {
|
|||||||
}
|
}
|
||||||
#inv-search { flex: 2 1 280px; }
|
#inv-search { flex: 2 1 280px; }
|
||||||
#inv-category { flex: 1 1 180px; }
|
#inv-category { flex: 1 1 180px; }
|
||||||
|
#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;
|
||||||
@@ -380,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;
|
||||||
@@ -413,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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user