Compare commits

...

10 Commits

Author SHA1 Message Date
c8c5e658e2 updated quality for ore 2026-11-12 13:57:23 -07:00
4fbad900a2 updated commodities 2026-11-12 13:16:57 -07:00
c8ac8e63ee <h1>Free People of the Verse <span>NEXUS</span></h1>
<p class=subtitle>Network for Exchange of Xeno and Utility Supplies</p>
2026-11-12 13:13:16 -07:00
b53e3a8a30 added comodities 2026-11-12 13:04:02 -07:00
120daf8c31 fixed remove issue, made items avaiable for available view 2026-11-12 12:49:19 -07:00
2807ffd160 document inventory sorting and test count
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
51cd47bd47 recreate app containers when images are rebuilt
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
83611e2a2c add inventory sort control to the ui
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
8f3bff2e62 add inventory sorting to the api
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
c17d8a9f8a stop tracking bytecode and local session files
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
48 changed files with 2585 additions and 137 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.omo/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,9 @@ A Star Citizen item catalog built from the
[scunpacked](https://github.com/richardthombs/scunpacked) game data (mirrored at [scunpacked](https://github.com/richardthombs/scunpacked) game data (mirrored at
[StarCitizenWiki/scunpacked-data](https://github.com/StarCitizenWiki/scunpacked-data)). [StarCitizenWiki/scunpacked-data](https://github.com/StarCitizenWiki/scunpacked-data)).
Ships, ship equipment, and FPS items are browsable with search, manufacturer / Ships, ship equipment, FPS items, and cargo commodities (ores, refined metals,
type / size filters, pagination, and a detail view per item. RMC, construction materials, …) are browsable with search, manufacturer / type /
size filters, pagination, and a detail view per item.
Users can optionally create an account and build a **personal inventory**: items Users can optionally create an account and build a **personal inventory**: items
from the catalog are added to their own list with a quantity and notes. The from the catalog are added to their own list with a quantity and notes. The
@@ -22,8 +23,10 @@ dataset can be replaced wholesale without touching user data.
| database | PostgreSQL 16 | Accounts, sessions, and per-user inventory rows | | database | PostgreSQL 16 | Accounts, sessions, and per-user inventory rows |
| deploy | Ansible + Podman (containers.podman) | Builds images and runs the three containers | | deploy | Ansible + Podman (containers.podman) | Builds images and runs the three containers |
The backend image downloads the full scunpacked dataset (~83 MB) at build time, The backend image downloads the full scunpacked dataset at build time, then
so the catalog needs no database — only user data does. reduces `items.json` to a small `commodities.json` (one entry per commodity) and
discards the ~68 MB raw file, so the catalog needs no database — only user data
does.
## Layout ## Layout
@@ -112,11 +115,37 @@ 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,
quality)`. Adding an item that is already present increments its quantity;
quantity, notes, visibility and quality 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.
`quality` is an optional mining/refining value from **1 to 1000**. It is only
accepted for commodities that are actually mineable — ores, minerals and gems
(and their refined/raw/pure variants). Gases, organics, processed goods, crafted
alloys and salvage are excluded. The catalog exposes this as
`quality_applicable`; the UI shows a Quality input only for those commodities.
`null` means the owner has not recorded a quality. On `PATCH`, sending
`"quality": null` clears it, while omitting the field leaves it unchanged. Each
inventory row holds one quality (the schema keeps one row per commodity, so
different quality lots are not separated). 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, quality (when applicable), 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 +186,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 # 65 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
``` ```
@@ -167,10 +196,17 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL.
- `data/samples/` — small real-schema subset for local dev/tests - `data/samples/` — small real-schema subset for local dev/tests
- `scripts/fetch_data.py` — downloads the full dataset (used by the Dockerfile) - `scripts/fetch_data.py` — downloads the full dataset (used by the Dockerfile)
Commodities are deduplicated to one catalog entry per name (the 1 SCU container
variant is canonical); quantity lives on the inventory row, so 1 SCU, 8 SCU and
32 SCU crates of the same commodity share a single entry. Refined and unrefined
materials stay separate (`Iron` vs `Iron (Ore)`). Quality applies to both the
refined and raw/ore entries and is user-recorded per inventory row.
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 +222,19 @@ 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?, quality?}
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?} PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, quality?}
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`). Catalog `category` values are `ships`, `ship-items`,
`fps-items` and `commodities`.
### Configuration ### Configuration

View File

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

View File

@@ -11,8 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Download the full scunpacked dataset (~83 MB) at build time so the image # Download the full scunpacked dataset at build time so the image is
# is self-contained; DATA_DIR points the API at it. # self-contained; fetch_data.py reduces items.json to commodities.json.
RUN python scripts/fetch_data.py --output-dir /app/data/full RUN python scripts/fetch_data.py --output-dir /app/data/full
ENV DATA_DIR=/app/data/full ENV DATA_DIR=/app/data/full

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app import config from app import config
from app.auth import router as auth_router from app.auth import router as auth_router
from app.available import router as available_router
from app.catalog import store from app.catalog import store
from app.db import db_healthy, init_db from app.db import db_healthy, init_db
from app.inventory import router as inventory_router from app.inventory import router as inventory_router
@@ -45,8 +46,9 @@ 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", "commodities"}
def _filter_items(category, search, manufacturer, item_type, size): def _filter_items(category, search, manufacturer, item_type, size):

View File

@@ -0,0 +1,69 @@
"""Public listing of inventory items owners marked as `available`.
Read-only and unauthenticated, like the catalog. Notes are never included: the
listing only reveals the owner's username, quantity and catalog details.
"""
import math
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession
from app.db import get_db
from app.inventory import SORT_KEYS, enrich_entry
from app.models import InventoryEntry, User
from app.schemas import AvailablePage
router = APIRouter(prefix="/api/v1/available", tags=["available"])
MAX_PAGE_SIZE = 100
@router.get("", response_model=AvailablePage)
def list_available(
category: str | None = None,
search: str | None = None,
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(InventoryEntry, User.username)
.join(User, User.id == InventoryEntry.user_id)
.where(InventoryEntry.visibility == "available")
).all()
items = []
for entry, username in rows:
enriched = enrich_entry(entry)
if not enriched["available"]:
continue
enriched["username"] = username
items.append(enriched)
if category is not None and category.strip():
wanted = category.strip()
items = [i for i in items if i["category"] == wanted]
if search is not None and search.strip():
needle = search.strip().lower()
items = [
i
for i in items
if needle in i["name"].lower()
or needle in i["class_name"].lower()
or needle in i["username"].lower()
]
items.sort(key=SORT_KEYS[sort])
total = len(items)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": items[start : start + page_size],
}

View File

@@ -55,3 +55,4 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
# --- Input limits --------------------------------------------------------- # --- Input limits ---------------------------------------------------------
MAX_NOTES_LENGTH = 1000 MAX_NOTES_LENGTH = 1000
MAX_QUANTITY = 1_000_000 MAX_QUANTITY = 1_000_000
MAX_QUALITY = 1000

View File

@@ -51,12 +51,69 @@ 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 $$
"""
)
)
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"quality INTEGER"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_quality_range'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_quality_range
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
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 +122,7 @@ def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("SELECT 1")) conn.execute(text("SELECT 1"))
Base.metadata.create_all(bind=engine, checkfirst=True) Base.metadata.create_all(bind=engine, checkfirst=True)
_upgrade_schema(engine)
return return
except (OperationalError, IntegrityError, ProgrammingError) as exc: except (OperationalError, IntegrityError, ProgrammingError) as exc:
# A concurrent startup may have created the tables first. # A concurrent startup may have created the tables first.

View File

@@ -33,7 +33,7 @@ router = APIRouter(
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100
def _enrich(entry: InventoryEntry) -> dict: def enrich_entry(entry: InventoryEntry) -> dict:
item = catalog.store.by_class_name.get(entry.class_name) item = catalog.store.by_class_name.get(entry.class_name)
if item is None: if item is None:
return { return {
@@ -46,6 +46,9 @@ def _enrich(entry: InventoryEntry) -> dict:
"manufacturer": None, "manufacturer": None,
"quantity": entry.quantity, "quantity": entry.quantity,
"notes": entry.notes, "notes": entry.notes,
"quality": entry.quality,
"quality_applicable": False,
"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,12 +63,23 @@ def _enrich(entry: InventoryEntry) -> dict:
"manufacturer": item["manufacturer"], "manufacturer": item["manufacturer"],
"quantity": entry.quantity, "quantity": entry.quantity,
"notes": entry.notes, "notes": entry.notes,
"quality": entry.quality,
"quality_applicable": item.get("quality_applicable", False),
"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,
} }
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None: def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
return db.execute( return db.execute(
select(InventoryEntry).where( select(InventoryEntry).where(
@@ -75,10 +89,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 +128,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 +141,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
@@ -151,10 +190,12 @@ def add_to_inventory(
db: DbSession = Depends(get_db), db: DbSession = Depends(get_db),
): ):
class_name = payload.class_name.strip() class_name = payload.class_name.strip()
if class_name not in catalog.store.by_class_name: item = catalog.store.by_class_name.get(class_name)
if item is None:
raise HTTPException( raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found" status_code=404, detail=f"Catalog item '{class_name}' not found"
) )
_validate_quality(item, payload.quality)
entry = _find(db, user, class_name) entry = _find(db, user, class_name)
created = entry is None created = entry is None
@@ -165,12 +206,15 @@ def add_to_inventory(
class_name=class_name, class_name=class_name,
quantity=payload.quantity, quantity=payload.quantity,
notes=payload.notes, notes=payload.notes,
quality=payload.quality,
) )
db.add(entry) db.add(entry)
else: else:
entry.quantity += payload.quantity entry.quantity += payload.quantity
if payload.notes: if payload.notes:
entry.notes = payload.notes entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
try: try:
db.commit() db.commit()
@@ -183,12 +227,14 @@ def add_to_inventory(
entry.quantity += payload.quantity entry.quantity += payload.quantity
if payload.notes: if payload.notes:
entry.notes = payload.notes entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
db.commit() db.commit()
created = False created = False
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 +252,23 @@ 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
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{entry.class_name}'",
)
_validate_quality(item, payload.quality)
entry.quality = payload.quality
db.commit() db.commit()
db.refresh(entry) db.refresh(entry)
return InventoryEntryOut.model_validate(_enrich(entry)) return InventoryEntryOut.model_validate(enrich_entry(entry))
@router.delete("/{class_name}", status_code=204) @router.delete("/{class_name}", status_code=204)

View File

@@ -17,6 +17,7 @@ Real schemas this loader consumes:
""" """
import json import json
import os import os
import re
from app import config from app import config
@@ -25,6 +26,69 @@ PLACEHOLDER = "<= PLACEHOLDER =>"
CATEGORY_SHIPS = "ships" CATEGORY_SHIPS = "ships"
CATEGORY_SHIP_ITEMS = "ship-items" CATEGORY_SHIP_ITEMS = "ship-items"
CATEGORY_FPS_ITEMS = "fps-items" CATEGORY_FPS_ITEMS = "fps-items"
CATEGORY_COMMODITIES = "commodities"
# Commodities that can carry a mining/refining quality value (1-1000). These are
# the ores, minerals and gems that players actually mine (and their refined
# counterparts); crafted alloys, gases, organics, processed goods and junk
# by-products are deliberately excluded. Keep in sync with the Ore quality table
# at https://starcitizen.tools/Ore_quality.
QUALITY_MATERIALS = frozenset(
name.lower()
for name in (
"Agricium",
"Aluminum",
"Aphorite",
"Aslarite",
"Beradom",
"Beryl",
"Bexalite",
"Borase",
"Carinite",
"Copper",
"Corundum",
"Diamond",
"Dolivine",
"Feynmaline",
"Glacosite",
"Gold",
"Hadanite",
"Hephaestanite",
"Iron",
"Jaclium",
"Janalite",
"Laranite",
"Lindinium",
"Ouratite",
"Pressurized Ice",
"Quantainium",
"Quartz",
"Riccite",
"Sadaryx",
"Saldynium",
"Savrilium",
"Silicon",
"Stileron",
"Taranite",
"Tin",
"Titanium",
"Torite",
"Tungsten",
)
)
# Variant suffixes are presentation labels, not a different material: "Iron
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
def _quality_applicable(name, category):
if category != CATEGORY_COMMODITIES:
return False
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
return base_name in QUALITY_MATERIALS
def _load_json(data_dir, filename): def _load_json(data_dir, filename):
@@ -79,6 +143,7 @@ def _normalize_ship(ship):
"classification": "Ship", "classification": "Ship",
"description": ship.get("Description") or ship.get("DescriptionText") or "", "description": ship.get("Description") or ship.get("DescriptionText") or "",
"tags": tags, "tags": tags,
"quality_applicable": False,
}, { }, {
"career": ship.get("Career") or "", "career": ship.get("Career") or "",
"role": ship.get("Role") or "", "role": ship.get("Role") or "",
@@ -110,7 +175,13 @@ def _normalize_item(item, category):
"classification": item.get("classification") or "", "classification": item.get("classification") or "",
"description": std.get("Description") or std.get("DescriptionText") or "", "description": std.get("Description") or std.get("DescriptionText") or "",
"tags": tags, "tags": tags,
"quality_applicable": _quality_applicable(
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
category,
),
} }
if category == CATEGORY_COMMODITIES:
summary["size"] = None
details = { details = {
"ports": std.get("Ports") or [], "ports": std.get("Ports") or [],
"mass": std.get("Mass"), "mass": std.get("Mass"),
@@ -133,6 +204,9 @@ class Store:
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS), CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS), CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS),
CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS), CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS),
CATEGORY_COMMODITIES: sum(
1 for i in items if i["category"] == CATEGORY_COMMODITIES
),
} }
@@ -157,6 +231,11 @@ def load_store(data_dir=None):
items.append(summary) items.append(summary)
details[summary["class_name"]] = detail details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "commodities.json"):
summary, detail = _normalize_item(entry, CATEGORY_COMMODITIES)
items.append(summary)
details[summary["class_name"]] = detail
# Deterministic order so pagination is stable. # Deterministic order so pagination is stable.
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"])) items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))

View File

@@ -74,6 +74,14 @@ 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",
),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_inventory_quality_range",
),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -83,6 +91,16 @@ 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="")
# Mining/refining quality (1-1000) for commodities where it applies. NULL
# means "not specified"; non-quality commodities must keep this NULL. The
# applicability itself is catalog data, not stored here.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
# "private" (default): only the owner sees the row. "available": the owner
# is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item.
visibility: Mapped[str] = mapped_column(
String(16), nullable=False, default="private", server_default="private"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow DateTime(timezone=True), nullable=False, default=utcnow
) )

View File

@@ -1,6 +1,6 @@
"""Pydantic request/response models for auth and personal inventory.""" """Pydantic request/response models for auth and personal inventory."""
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -33,11 +33,15 @@ class InventoryAddRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255) class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY) quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH) notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
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
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryEntryOut(BaseModel): class InventoryEntryOut(BaseModel):
@@ -50,6 +54,12 @@ class InventoryEntryOut(BaseModel):
manufacturer: dict | None manufacturer: dict | None
quantity: int quantity: int
notes: str notes: str
# Mining/refining quality (1-1000); only meaningful when
# `quality_applicable` is true. None means the owner has not set it.
quality: int | None = None
quality_applicable: bool = False
# 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 +82,30 @@ 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
quality: int | None = None
quality_applicable: bool = False
# Public listing: only the owner's display name, never notes.
username: str
updated_at: datetime
class AvailablePage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[AvailableItemOut]
class InventoryAddResponse(BaseModel): class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut entry: InventoryEntryOut
created: bool created: bool

View File

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ Usage:
Default output: backend/data/full (used by the Docker image build). Default output: backend/data/full (used by the Docker image build).
""" """
import argparse import argparse
import json
import os import os
import sys import sys
import urllib.request import urllib.request
@@ -16,7 +17,38 @@ FILES = [
"ship-items.json", "ship-items.json",
"fps-items.json", "fps-items.json",
"manufacturers.json", "manufacturers.json",
"items.json",
] ]
COMMODITY_KEYS = ("className", "name", "type", "subType", "reference")
PLACEHOLDER = "<= PLACEHOLDER =>"
def _commodity_rank(entry):
class_name = entry.get("className") or ""
return (0 if "_1SCU_" in class_name else 1, entry.get("size") or 0, class_name)
def build_commodities(items_path, out_path):
with open(items_path, encoding="utf-8") as f:
items = json.load(f)
grouped = {}
for entry in items:
class_name = (entry.get("className") or "").lower()
name = (entry.get("name") or "").strip()
if entry.get("type") != "Cargo" or "commodity" not in class_name:
continue
if not name or name == PLACEHOLDER:
continue
grouped.setdefault(name, []).append(entry)
commodities = [
{key: best.get(key) for key in COMMODITY_KEYS}
for best in (min(grouped[name], key=_commodity_rank) for name in sorted(grouped))
]
with open(out_path, "w", encoding="utf-8") as f:
json.dump(commodities, f, ensure_ascii=False, separators=(",", ":"))
return len(commodities)
def main(): def main():
@@ -35,6 +67,13 @@ def main():
urllib.request.urlretrieve(url, dest) urllib.request.urlretrieve(url, dest)
size = os.path.getsize(dest) size = os.path.getsize(dest)
print(f" done ({size / 1_000_000:.1f} MB)", flush=True) print(f" done ({size / 1_000_000:.1f} MB)", flush=True)
items_path = os.path.join(args.output_dir, "items.json")
count = build_commodities(
items_path, os.path.join(args.output_dir, "commodities.json")
)
os.remove(items_path)
print(f"Wrote {count} commodities (items.json discarded)", flush=True)
return 0 return 0

View File

@@ -0,0 +1,30 @@
[
{
"className": "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials",
"name": "Construction Materials",
"type": "Cargo",
"subType": "Cargo",
"reference": "5ee15a9b-4ce5-445b-a02d-415c63f43764"
},
{
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron",
"name": "Iron",
"type": "Cargo",
"subType": "Cargo",
"reference": "332096b9-1cbf-436d-b8b0-5ed422c23da9"
},
{
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron",
"name": "Iron (Ore)",
"type": "Cargo",
"subType": "Cargo",
"reference": "271effd5-3aa8-4538-b9a0-b0f33dc86d68"
},
{
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_RMC",
"name": "Recycled Material Composite",
"type": "Cargo",
"subType": "Cargo",
"reference": "2e50dde5-30d4-4faa-af8f-8e5aa8004b96"
}
]

View File

@@ -11,14 +11,24 @@ def test_healthz():
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert body["status"] == "ok" assert body["status"] == "ok"
assert body["counts"] == {"ships": 2, "ship-items": 2, "fps-items": 1} assert body["counts"] == {
"ships": 2,
"ship-items": 2,
"fps-items": 1,
"commodities": 4,
}
def test_stats(): def test_stats():
resp = client.get("/api/v1/stats") resp = client.get("/api/v1/stats")
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert body["categories"] == {"ships": 2, "ship-items": 2, "fps-items": 1} assert body["categories"] == {
"ships": 2,
"ship-items": 2,
"fps-items": 1,
"commodities": 4,
}
assert body["manufacturers"] == 4 assert body["manufacturers"] == 4
@@ -33,11 +43,11 @@ def test_items_default_pagination():
resp = client.get("/api/v1/items") resp = client.get("/api/v1/items")
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert body["total"] == 5 assert body["total"] == 9
assert body["page"] == 1 assert body["page"] == 1
assert body["page_size"] == 25 assert body["page_size"] == 25
assert body["total_pages"] == 1 assert body["total_pages"] == 1
assert len(body["items"]) == 5 assert len(body["items"]) == 9
def test_items_page_size_capped(): def test_items_page_size_capped():
@@ -155,3 +165,29 @@ def test_deterministic_sort_order():
body = client.get("/api/v1/items", params={"page_size": 100}).json() body = client.get("/api/v1/items", params={"page_size": 100}).json()
keys = [(i["category"], i["name"].lower()) for i in body["items"]] keys = [(i["category"], i["name"].lower()) for i in body["items"]]
assert keys == sorted(keys) assert keys == sorted(keys)
def test_items_category_filter_commodities():
resp = client.get("/api/v1/items", params={"category": "commodities"})
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 4
assert all(i["category"] == "commodities" for i in body["items"])
def test_items_search_matches_refined_and_ore_iron():
resp = client.get("/api/v1/items", params={"search": "iron"})
body = resp.json()
assert body["total"] == 2
assert {i["name"] for i in body["items"]} == {"Iron", "Iron (Ore)"}
def test_item_detail_commodity():
resp = client.get("/api/v1/items/Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "Iron (Ore)"
assert body["category"] == "commodities"
assert body["type"] == "Cargo"
assert body["size"] is None
assert isinstance(body["details"], dict)

View File

@@ -0,0 +1,156 @@
"""Public available-items listing: open access, owner attribution, no notes."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str) -> TestClient:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def add(
client: TestClient,
class_name: str,
quantity: int = 1,
notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
resp = client.post("/api/v1/me/inventory", json=payload)
assert resp.status_code in (200, 201), resp.text
return resp
def offer(client: TestClient, class_name: str, quantity: int = 1):
add(client, class_name, quantity)
resp = client.patch(
f"/api/v1/me/inventory/{class_name}", json={"visibility": "available"}
)
assert resp.status_code == 200, resp.text
def public_items(**params):
return TestClient(app).get("/api/v1/available", params=params).json()
def test_listing_is_public_and_empty_by_default():
body = public_items()
assert body["total"] == 0
assert body["items"] == []
assert body["page"] == 1
def test_private_items_are_not_listed_until_marked_available():
alice = authed_client("alice")
add(alice, CARROCK)
assert public_items()["total"] == 0
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
body = public_items()
assert body["total"] == 1
assert body["items"][0]["class_name"] == CARROCK
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"})
assert public_items()["total"] == 0
def test_listing_shows_owner_and_catalog_details_but_not_notes():
alice = authed_client("alice")
offer(alice, CARROCK, quantity=3)
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"notes": "hidden stash"})
item = public_items()["items"][0]
assert item["username"] == "alice"
assert item["name"] == "Anvil Carrack"
assert item["category"] == "ships"
assert item["manufacturer"]["code"] == "ANVL"
assert item["quantity"] == 3
assert "notes" not in item
assert "hidden stash" not in str(public_items())
def test_each_owner_appears_as_its_own_offer():
offer(authed_client("alice"), CARROCK, quantity=1)
offer(authed_client("bob"), CARROCK, quantity=2)
items = public_items(sort="quantity")["items"]
assert [(i["username"], i["quantity"], i["class_name"]) for i in items] == [
("bob", 2, CARROCK),
("alice", 1, CARROCK),
]
def test_removing_an_offer_removes_it_from_the_listing():
alice = authed_client("alice")
offer(alice, CARROCK)
assert public_items()["total"] == 1
assert alice.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
assert public_items()["total"] == 0
assert public_items()["items"] == []
def test_listing_filters_by_category_and_searches_owner_or_item():
offer(authed_client("alice"), CARROCK)
offer(authed_client("bob"), RIFLE)
assert [i["class_name"] for i in public_items(category="ships")["items"]] == [CARROCK]
assert [i["username"] for i in public_items(search="bob")["items"]] == ["bob"]
assert [i["class_name"] for i in public_items(search="carrack")["items"]] == [CARROCK]
def test_listing_paginates_and_rejects_unknown_sort():
for quantity, class_name in ((1, CARROCK), (2, RIFLE)):
offer(authed_client(f"pilot{quantity}"), class_name, quantity=quantity)
body = public_items(page=2, page_size=1)
assert body["total"] == 2
assert body["total_pages"] == 2
assert len(body["items"]) == 1
assert public_items(page_size=500)["page_size"] == 100
assert TestClient(app).get("/api/v1/available?sort=bogus").status_code == 422
def test_commodity_offers_are_listed_with_owner():
alice = authed_client("alice")
offer(alice, IRON_ORE, quantity=12)
body = public_items()
assert body["total"] == 1
item = body["items"][0]
assert item["name"] == "Iron (Ore)"
assert item["category"] == "commodities"
assert item["quantity"] == 12
assert item["username"] == "alice"
def test_commodity_quality_is_listed_publicly():
alice = authed_client("alice")
offer(alice, IRON_ORE, quantity=12)
add(alice, IRON_ORE, quality=875)
item = public_items()["items"][0]
assert item["quality"] == 875
assert item["quality_applicable"] is True
def test_non_quality_commodity_is_listed_without_quality():
alice = authed_client("alice")
offer(alice, CONSTRUCTION)
item = public_items()["items"][0]
assert item["quality"] is None
assert item["quality_applicable"] is False

View File

@@ -6,6 +6,9 @@ from app.api import app
PASSWORD = "correct horse battery staple" PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack" CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01" RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str = "alice") -> TestClient: def authed_client(username: str = "alice") -> TestClient:
@@ -17,11 +20,17 @@ def authed_client(username: str = "alice") -> TestClient:
return client return client
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""): def add(
return client.post( client: TestClient,
"/api/v1/me/inventory", class_name: str,
json={"class_name": class_name, "quantity": quantity, "notes": notes}, quantity: int = 1,
) notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
return client.post("/api/v1/me/inventory", json=payload)
def test_new_account_starts_with_empty_inventory(): def test_new_account_starts_with_empty_inventory():
@@ -60,6 +69,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 +115,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 +236,121 @@ 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
def test_add_commodity_uses_catalog_name_and_quantity():
client = authed_client("refiner")
resp = add(client, IRON_ORE, quantity=12, notes="from Aaron Halo")
assert resp.status_code == 201
entry = client.get("/api/v1/me/inventory").json()["items"][0]
assert entry["name"] == "Iron (Ore)"
assert entry["category"] == "commodities"
assert entry["quantity"] == 12
assert entry["available"] is True
assert entry["visibility"] == "private"
def test_catalog_marks_only_mineable_commodities_as_quality_applicable():
client = TestClient(app)
items = client.get(
"/api/v1/items", params={"category": "commodities", "page_size": 100}
).json()["items"]
flags = {item["class_name"]: item["quality_applicable"] for item in items}
assert flags[IRON] is True
assert flags[IRON_ORE] is True
assert flags[CONSTRUCTION] is False
assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False
ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"]
assert all(item["quality_applicable"] is False for item in ships)
def test_quality_can_be_set_and_cleared_on_a_quality_commodity():
client = authed_client()
assert add(client, IRON_ORE).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750})
assert resp.status_code == 200
body = resp.json()
assert body["quality"] == 750
assert body["quality_applicable"] is True
cleared = client.patch(
f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None}
)
assert cleared.status_code == 200
assert cleared.json()["quality"] is None
def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented():
client = authed_client()
first = add(client, IRON, quality=900)
assert first.status_code == 201
assert first.json()["entry"]["quality"] == 900
second = add(client, IRON, quantity=2)
assert second.status_code == 200
entry = second.json()["entry"]
assert entry["quality"] == 900
assert entry["quantity"] == 3
replaced = add(client, IRON, quality=400)
assert replaced.json()["entry"]["quality"] == 400
def test_quality_is_rejected_for_non_mineable_commodities():
client = authed_client()
assert add(client, CONSTRUCTION).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500}
)
assert resp.status_code == 422
assert "does not apply" in resp.json()["detail"]
add_resp = add(client, CONSTRUCTION, quality=500)
assert add_resp.status_code == 422
def test_quality_is_rejected_for_ships():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100})
assert resp.status_code == 422
def test_quality_range_is_enforced():
client = authed_client()
assert add(client, IRON_ORE, quality=1).status_code == 201
assert add(client, IRON, quality=1000).status_code == 201
assert add(client, IRON_ORE, quality=0).status_code == 422
assert add(client, IRON_ORE, quality=1001).status_code == 422
assert add(client, IRON, quantity=1).status_code == 200
assert client.patch(
f"/api/v1/me/inventory/{IRON}", json={"quality": 1001}
).status_code == 422

View File

@@ -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);
} }
@@ -102,7 +107,7 @@ async function initHeader() {
const c = health.counts || {}; const c = health.counts || {};
els["stats-line"].textContent = els["stats-line"].textContent =
`${c.ships ?? 0} ships · ${c["ship-items"] ?? 0} ship items · ` + `${c.ships ?? 0} ships · ${c["ship-items"] ?? 0} ship items · ` +
`${c["fps-items"] ?? 0} FPS items`; `${c["fps-items"] ?? 0} FPS items · ${c.commodities ?? 0} commodities`;
} catch { } catch {
els["api-status"].textContent = "API OFFLINE"; els["api-status"].textContent = "API OFFLINE";
els["api-status"].className = "status-pill offline"; els["api-status"].className = "status-pill offline";
@@ -234,7 +239,12 @@ function buildQuery() {
} }
function categoryBadge(category) { function categoryBadge(category) {
const labels = { ships: "Ship", "ship-items": "Ship Item", "fps-items": "FPS Item" }; const labels = {
ships: "Ship",
"ship-items": "Ship Item",
"fps-items": "FPS Item",
commodities: "Commodity",
};
return `<span class="badge cat-${category}">${labels[category] || category}</span>`; return `<span class="badge cat-${category}">${labels[category] || category}</span>`;
} }
@@ -351,6 +361,10 @@ async function openModal(class_name) {
<div class="add-row"> <div class="add-row">
<label for="add-qty">Qty</label> <label for="add-qty">Qty</label>
<input type="number" id="add-qty" min="1" max="1000000" value="1"> <input type="number" id="add-qty" min="1" max="1000000" value="1">
${item.quality_applicable ? `
<label for="add-quality">Quality</label>
<input type="number" id="add-quality" min="1" max="1000" placeholder="—">
` : ""}
<button type="button" id="add-to-inventory" class="btn-primary"> <button type="button" id="add-to-inventory" class="btn-primary">
${state.user ? "Add to my inventory" : "Log in to add"} ${state.user ? "Add to my inventory" : "Log in to add"}
</button> </button>
@@ -358,7 +372,16 @@ async function openModal(class_name) {
`; `;
document.getElementById("add-to-inventory").addEventListener("click", () => { document.getElementById("add-to-inventory").addEventListener("click", () => {
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
addToInventory(item.class_name, qty); let quality = null;
const qualityInput = document.getElementById("add-quality");
if (qualityInput && qualityInput.value.trim() !== "") {
quality = Number(qualityInput.value);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
showToast("Quality must be between 1 and 1000", true);
return;
}
}
addToInventory(item.class_name, qty, quality);
}); });
} catch (err) { } catch (err) {
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`; els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
@@ -369,6 +392,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 +423,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 +431,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 +439,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 +451,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));
@@ -427,7 +475,12 @@ async function refreshSummary() {
} }
function renderInventorySummary(summary) { function renderInventorySummary(summary) {
const labels = { ships: "ships", "ship-items": "ship items", "fps-items": "FPS items" }; const labels = {
ships: "ships",
"ship-items": "ship items",
"fps-items": "FPS items",
commodities: "commodities",
};
const parts = Object.entries(summary.categories || {}) const parts = Object.entries(summary.categories || {})
.map(([key, qty]) => `${qty} ${labels[key] || key}`) .map(([key, qty]) => `${qty} ${labels[key] || key}`)
.join(" · "); .join(" · ");
@@ -440,6 +493,71 @@ 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 qualityCell(entry, editable) {
const cell = document.createElement("td");
if (!entry.quality_applicable) {
// Keep a recorded quality visible even if the catalog item disappeared.
cell.innerHTML =
entry.quality == null
? '<span class="inv-quality-na">—</span>'
: escapeHtml(String(entry.quality));
return cell;
}
if (!editable) {
cell.textContent = entry.quality == null ? "—" : String(entry.quality);
return cell;
}
const input = document.createElement("input");
input.type = "number";
input.className = "inv-quality";
input.min = "1";
input.max = "1000";
input.placeholder = "—";
input.value = entry.quality == null ? "" : String(entry.quality);
input.setAttribute("aria-label", `Quality of ${entry.name}`);
input.addEventListener("change", () => {
const raw = input.value.trim();
if (raw === "") {
entry.quality = null;
patchEntry(entry.class_name, { quality: null });
return;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 1000) {
input.value = entry.quality == null ? "" : String(entry.quality);
showToast("Quality must be between 1 and 1000", true);
return;
}
entry.quality = value;
patchEntry(entry.class_name, { quality: value });
});
cell.appendChild(input);
return cell;
}
function renderInventory(listing) { function renderInventory(listing) {
const tbody = els["inv-tbody"]; const tbody = els["inv-tbody"];
tbody.innerHTML = ""; tbody.innerHTML = "";
@@ -449,11 +567,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 +614,56 @@ 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,
qualityCell(entry, true),
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,27 +679,34 @@ 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);
} }
} }
async function addToInventory(class_name, quantity = 1) { async function addToInventory(class_name, quantity = 1, quality = null) {
if (!state.user) { if (!state.user) {
openAuth("login", "Log in to add items to your inventory."); openAuth("login", "Log in to add items to your inventory.");
return; return;
} }
const body = { class_name, quantity };
if (quality !== null) body.quality = quality;
try { try {
const data = await apiFetch("/api/v1/me/inventory", { const data = await apiFetch("/api/v1/me/inventory", {
method: "POST", method: "POST",
body: { class_name, quantity }, body,
}); });
showToast( showToast(
data.created data.created
@@ -554,6 +720,81 @@ 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,
qualityCell(entry, false),
userCell,
updatedCell
);
tbody.appendChild(row);
}
}
/* ---------- events ---------- */ /* ---------- events ---------- */
function debounce(fn, wait) { function debounce(fn, wait) {
@@ -582,6 +823,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 +889,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 +925,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 +956,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindEvents(); bindEvents();
bindAuthEvents(); bindAuthEvents();
bindInventoryEvents(); bindInventoryEvents();
bindAvailableEvents();
initHeader(); initHeader();
await initAuth(); await initAuth();
loadItems(); loadItems();

View File

@@ -3,15 +3,15 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Star Citizen Inventory</title> <title>FPOV Logistics Readiness</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
<div class="header-inner"> <div class="header-inner">
<div class="brand"> <div class="brand">
<h1>STAR CITIZEN <span>INVENTORY</span></h1> <h1>Free People of the Verse <span>NEXUS</span></h1>
<p class="subtitle">Item catalog from scunpacked game data</p> <p class="subtitle">Network for Exchange of Xeno and Utility Supplies</p>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="header-status"> <div class="header-status">
@@ -28,7 +28,9 @@
<button class="tab" data-view="catalog" data-category="ships">Ships</button> <button class="tab" data-view="catalog" data-category="ships">Ships</button>
<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" data-view="catalog" data-category="commodities">Commodities</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">
@@ -67,6 +69,13 @@
<option value="ships">Ships</option> <option value="ships">Ships</option>
<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>
<option value="commodities">Commodities</option>
</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> </select>
</div> </div>
<div id="inv-summary" class="inv-summary"></div> <div id="inv-summary" class="inv-summary"></div>
@@ -81,7 +90,9 @@
<th>Item</th> <th>Item</th>
<th>Category</th> <th>Category</th>
<th>Qty</th> <th>Qty</th>
<th>Quality</th>
<th>Notes</th> <th>Notes</th>
<th>Visibility</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -90,6 +101,45 @@
</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>
<option value="commodities">Commodities</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>Quality</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 +155,18 @@
</div> </div>
</div> </div>
<div id="confirm-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="alertdialog" aria-modal="true"
aria-labelledby="confirm-title" aria-describedby="confirm-message">
<h2 id="confirm-title">Remove item</h2>
<p id="confirm-message" class="modal-desc"></p>
<div class="modal-actions">
<button type="button" id="confirm-cancel" class="btn-secondary">Cancel</button>
<button type="button" id="confirm-ok" class="btn-danger">Remove</button>
</div>
</div>
</div>
<div id="auth-backdrop" class="modal-backdrop hidden"> <div id="auth-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title"> <div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<button class="modal-close" id="auth-close" aria-label="Close">&times;</button> <button class="modal-close" id="auth-close" aria-label="Close">&times;</button>

View File

@@ -14,6 +14,7 @@
--cat-ships: #38bdf8; --cat-ships: #38bdf8;
--cat-ship-items: #a78bfa; --cat-ship-items: #a78bfa;
--cat-fps-items: #f59e0b; --cat-fps-items: #f59e0b;
--cat-commodities: #4ade80;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -132,6 +133,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 {
@@ -198,6 +216,7 @@ main {
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); } .badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
.badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); } .badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); }
.badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); } .badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); }
.badge.cat-commodities { color: var(--cat-commodities); border-color: var(--cat-commodities); }
.badge.size { color: var(--text-dim); border-color: var(--border); } .badge.size { color: var(--text-dim); border-color: var(--border); }
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; } .item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }
@@ -347,9 +366,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 +401,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;
@@ -393,6 +429,16 @@ main {
border-radius: 6px; border-radius: 6px;
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
} }
.inv-quality {
width: 5rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-quality-na { color: var(--text-dim); }
.inv-quality:focus { outline: none; border-color: var(--accent); }
.inv-notes { .inv-notes {
width: 100%; width: 100%;
min-width: 12rem; min-width: 12rem;
@@ -413,6 +459,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 */