Lots of stuff

This commit is contained in:
Aodhan
2025-07-08 23:04:43 +01:00
parent cfa2eff6ef
commit 65c1972c49
26 changed files with 4094 additions and 104 deletions

View File

@@ -19,13 +19,13 @@
## Key Features
* **Crafting Recipes** browse every craft with filters, icons, ingredients, HQ yields, and cross-links to inventory.
* **Inventory Manager** compact grid with tooltips, duplicate highlighting, scroll badges, and wiki links.
* **Item Explorer** searchable catalogue with dynamic sub-tabs for each item type.
* **Spell Database** scroll parsing auto-populates a `spells` table with job-level learn data.
* **Responsive UI** desktop / tablet / mobile layouts.
* **Crafting Recipes** colour-coded craft tabs (woodworking brown, smithing grey, etc.), full filters, icons, ingredients, HQ yields, and cross-links to inventory.
* **Inventory Manager** grid view with sorting (slot ▶ default, name, type), duplicate-item indicator (orange bar on top), tooltips, quantity badges, and direct wiki links.
* **Item Explorer** fast, fuzzy search across all items with type sub-tabs and recipe cross-navigation.
* **Item Detail Dialog** centred modal with icon, description, usage breakdown, and craft-coloured section headers.
* **Responsive UI** desktop / tablet / mobile layouts with dropdown storage selector on narrow screens.
* **Database-backed** PostgreSQL stores normalised recipe / item / inventory / spells data.
* **Automation Scripts** Python ETL & loaders for recipes and spells.
* **Automation Scripts** Python ETL & loaders for recipes and spells, plus scroll → spell parser.
## Project Structure

View File

@@ -1,9 +1,11 @@
# Project TODO
## Bugs
- [ ] Add automated regression test for explorer search (Vitest + RTL)
- [X] Pagination not working
- [ ] Fix search in explorer
- [X] Fix search in explorer
## UI Improvements

View File

@@ -1,8 +1,8 @@
from typing import List, Optional, Tuple, Any
from datetime import datetime
from fastapi import APIRouter, Depends, Query, HTTPException, Path, Response
from sqlalchemy import text, select
from fastapi import APIRouter, Depends, Query, HTTPException, Path, Response, UploadFile, File
from sqlalchemy import text, select, insert
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
@@ -14,6 +14,52 @@ router = APIRouter()
# ---------------------------------------------------------------------------
# Crafting Recipes endpoints
@router.post("/inventory/import")
async def import_inventory_csv(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
):
"""Replace the entire inventory table with contents from an uploaded CSV.
The CSV must use the delimiter ``;`` and column headers: ``char;storage;item;quantity``.
"""
import csv
import io
# Read file bytes and decode
contents = await file.read()
try:
text_data = contents.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=400, detail="CSV must be UTF-8 encoded")
reader = csv.DictReader(io.StringIO(text_data), delimiter=";", quotechar='"')
rows = []
for r in reader:
try:
qty = int(r["quantity"].strip()) if r["quantity"].strip() else 0
except (KeyError, ValueError):
raise HTTPException(status_code=400, detail="Invalid CSV schema or quantity value")
rows.append(
{
"character_name": r["char"].strip(),
"storage_type": r["storage"].strip(),
"item_name": r["item"].strip(),
"quantity": qty,
}
)
# Replace table contents inside a transaction
try:
await session.execute(text("TRUNCATE TABLE inventory;"))
if rows:
await session.execute(insert(Inventory), rows)
await session.commit()
except Exception as e:
await session.rollback()
raise HTTPException(status_code=500, detail=f"Failed to import CSV: {e}")
return {"imported": len(rows)}
ALLOWED_CRAFTS = {
"woodworking": "recipes_woodworking",
# Future crafts can be added here, e.g. "smithing": "recipes_smithing"
@@ -102,6 +148,7 @@ class ItemSummary(BaseModel):
name: str
icon_id: Optional[str]
type_description: Optional[str]
jobs_description: Optional[List[str]]
@router.get("/items", response_model=List[ItemSummary])
@@ -139,12 +186,14 @@ async def items(
total_count = total_res.scalar() or 0
response.headers["X-Total-Count"] = str(total_count)
# Use LEFT JOIN to fetch jobs_description from armor_items table; it will be NULL for non-armor.
join_sql = "FROM all_items a LEFT JOIN armor_items ai ON ai.id = a.id"
q = text(
f"SELECT id, name, icon_id, type_description FROM all_items {where_sql} ORDER BY id LIMIT :limit OFFSET :offset"
f"SELECT a.id, a.name, a.icon_id, a.type_description, ai.jobs_description {join_sql} {where_sql} ORDER BY a.id LIMIT :limit OFFSET :offset"
)
result = await session.execute(q, params)
rows = result.fetchall()
return [ItemSummary(id=r.id, name=r.name, icon_id=r.icon_id, type_description=r.type_description) for r in rows]
return [ItemSummary(id=r.id, name=r.name, icon_id=r.icon_id, type_description=r.type_description, jobs_description=r.jobs_description) for r in rows]
class ItemDetail(BaseModel):

View File

@@ -4,3 +4,4 @@ SQLAlchemy[asyncio]==2.0.27
asyncpg==0.29.0
pydantic==2.7.1
python-dotenv==1.0.1
python-multipart==0.0.9

967
datasets/inventory.csv Executable file
View File

@@ -0,0 +1,967 @@
"char";"storage";"item";"quantity"
"Rynore";"inventory";"Bone Quiver";"1"
"Rynore";"inventory";"Instant Warp";"1"
"Rynore";"inventory";"Bone Chip";"1"
"Rynore";"inventory";"Moko Grass";"1"
"Rynore";"inventory";"Florid Leaf Mold";"7"
"Rynore";"inventory";"Adoulinian Kelp";"1"
"Rynore";"inventory";"Linkpearl";"1"
"Rynore";"inventory";"Frgtn. Thought";"1"
"Rynore";"inventory";"Goblin Stir-Fry";"1"
"Rynore";"inventory";"Potion";"1"
"Rynore";"inventory";"Onion Dagger";"2"
"Rynore";"inventory";"Maple Table";"3"
"Rynore";"inventory";"Hatchet";"10"
"Rynore";"inventory";"Onion Rod";"2"
"Rynore";"inventory";"Onion Staff";"1"
"Rynore";"inventory";"Ark Shield";"1"
"Rynore";"inventory";"Apple Pie";"1"
"Rynore";"inventory";"Echo Drops";"1"
"Rynore";"inventory";"Distilled Water";"1"
"Rynore";"inventory";"Derfland Pear";"1"
"Rynore";"inventory";"Rem's Tale Ch.2";"2"
"Rynore";"inventory";"Vlr. Gauntlets -1";"1"
"Rynore";"inventory";"Eggplant";"1"
"Rynore";"inventory";"Roast Mushroom";"5"
"Rynore";"inventory";"Ginger";"1"
"Rynore";"inventory";"Little Worm";"5"
"Rynore";"inventory";"Pugil Scales";"3"
"Rynore";"inventory";"Vile Elixir";"1"
"Rynore";"inventory";"Chariot Band";"1"
"Rynore";"inventory";"Sickle";"1"
"Rynore";"inventory";"San d'Or. Carrot";"6"
"Rynore";"inventory";"Woozyshroom";"12"
"Rynore";"inventory";"Mythril Ore";"1"
"Rynore";"inventory";"Elm Strongbox";"1"
"Rynore";"inventory";"Mahogany Bed";"1"
"Rynore";"inventory";"Venus Orb";"1"
"Rynore";"inventory";"Win. Tea Leaves";"4"
"Rynore";"inventory";"Baked Popoto";"8"
"Rynore";"inventory";"M Rice Cake";"1"
"Rynore";"inventory";"Lilac";"2"
"Rynore";"inventory";"Rockoil";"7"
"Rynore";"inventory";"Flint Caviar";"1"
"Rynore";"inventory";"Selbina Clay";"1"
"Rynore";"inventory";"Napa";"2"
"Rynore";"inventory";"Spider Web";"1"
"Rynore";"inventory";"Iolite";"1"
"Rynore";"inventory";"Rusty Bucket";"5"
"Rynore";"inventory";"Sea Foliage";"1"
"Rynore";"inventory";"Pea Soup";"1"
"Rynore";"inventory";"Auric Sand";"1"
"Rynore";"inventory";"Beetle Jaw";"3"
"Rynore";"inventory";"Roast Mutton";"2"
"Rynore";"inventory";"Saruta Orange";"1"
"Rynore";"inventory";"Herb Seeds";"4"
"Rynore";"inventory";"Vegetable Gruel";"1"
"Rynore";"inventory";"Moldy Torque";"1"
"Rynore";"inventory";"Crawler Cocoon";"5"
"Rynore";"inventory";"Voidleg: DRG";"1"
"Rynore";"inventory";"Parchment";"1"
"Rynore";"inventory";"G. Seed Pouch";"11"
"Rynore";"inventory";"King Locust";"1"
"Rynore";"inventory";"Snapping Mole";"1"
"Rynore";"inventory";"Moat Carp";"5"
"Rynore";"inventory";"Insect Wing";"1"
"Rynore";"inventory";"E Rice Cake";"1"
"Rynore";"inventory";"Gnatbane";"1"
"Rynore";"inventory";"Whine Cellar Key";"1"
"Rynore";"inventory";"Burdock";"1"
"Rynore";"inventory";"Date";"1"
"Rynore";"inventory";"Sheep Tooth";"7"
"Rynore";"inventory";"Tomato Juice";"1"
"Rynore";"wardrobe";"Windshear Hat";"1"
"Rynore";"wardrobe";"Duelist's Gloves";"1"
"Rynore";"wardrobe";"Asn. Poulaines +1";"1"
"Rynore";"wardrobe";"Beetle Leggings";"1"
"Rynore";"wardrobe";"Cuffs";"1"
"Rynore";"wardrobe";"Chocobo Masque +1";"1"
"Rynore";"wardrobe";"She-Slime Hat";"1"
"Rynore";"wardrobe";"Shade Tights";"1"
"Rynore";"wardrobe";"Chocobo Shirt";"1"
"Rynore";"wardrobe";"Targe";"1"
"Rynore";"wardrobe";"Bl. Chocobo Suit";"1"
"Rynore";"wardrobe";"Studded Gloves";"1"
"Rynore";"wardrobe";"Slacks";"1"
"Rynore";"wardrobe";"Scale Greaves";"1"
"Rynore";"wardrobe";"Lizard Ledelsens";"1"
"Rynore";"wardrobe";"Rusty Cap";"3"
"Rynore";"wardrobe";"Purple Ribbon";"1"
"Rynore";"wardrobe";"Scale Cuisses";"1"
"Rynore";"wardrobe";"Chain Hose";"1"
"Rynore";"wardrobe";"Garish Crown";"1"
"Rynore";"wardrobe";"Cotton Gaiters";"1"
"Rynore";"wardrobe";"Vgd. Gloves";"2"
"Rynore";"wardrobe";"Studded Vest";"1"
"Rynore";"wardrobe";"Alumine Moufles";"1"
"Rynore";"wardrobe";"Alumine Sollerets";"1"
"Rynore";"wardrobe";"Mitts";"1"
"Rynore";"wardrobe";"Trader's Slops";"1"
"Rynore";"wardrobe";"Lizard Trousers";"1"
"Rynore";"wardrobe";"Scale Fng. Gnt.";"1"
"Rynore";"wardrobe";"Chocobo Suit +1";"1"
"Rynore";"wardrobe";"Gloves";"1"
"Rynore";"wardrobe";"Blue Ribbon";"1"
"Rynore";"wardrobe";"Raising Earring";"1"
"Rynore";"wardrobe";"Destrier Beret";"1"
"Rynore";"wardrobe";"Naga Hakama";"1"
"Rynore";"wardrobe";"Thur. Gloves +1";"1"
"Rynore";"wardrobe";"White Mitts";"1"
"Rynore";"wardrobe";"Thur. Tabard +1";"1"
"Rynore";"wardrobe";"Bone Pick";"1"
"Rynore";"wardrobe";"Slime Cap";"1"
"Rynore";"wardrobe";"Elm Pole";"1"
"Rynore";"wardrobe";"Tabar";"1"
"Rynore";"wardrobe";"Dream Hat";"1"
"Rynore";"wardrobe";"Bone Hairpin";"1"
"Rynore";"wardrobe";"Alumine Salade";"1"
"Rynore";"wardrobe";"Studded Trousers";"1"
"Rynore";"wardrobe";"Iron Cuisses";"1"
"Rynore";"wardrobe";"Overalls";"1"
"Rynore";"wardrobe";"Trader's Cuffs";"1"
"Rynore";"wardrobe";"Garish Tunic";"1"
"Rynore";"wardrobe";"Alumine Brayettes";"1"
"Rynore";"wardrobe";"Alumine Haubert";"1"
"Rynore";"wardrobe";"Trader's Pigaches";"1"
"Rynore";"wardrobe";"Brass Subligar";"1"
"Rynore";"wardrobe";"Lizard Gloves";"1"
"Rynore";"wardrobe";"Tide Gages";"1"
"Rynore";"wardrobe";"Brass Mittens";"1"
"Rynore";"wardrobe";"Bl. Chocobo Cap";"1"
"Rynore";"wardrobe";"Phl. Trousers";"1"
"Rynore";"wardrobe";"Snowman Cap";"1"
"Rynore";"wardrobe";"Slops";"1"
"Rynore";"wardrobe";"Greaves";"1"
"Rynore";"wardrobe";"Thur. Chapeau +1";"1"
"Rynore";"wardrobe";"Cotton Brais";"1"
"Rynore";"wardrobe";"Sitabaki";"1"
"Rynore";"wardrobe";"Thur. Boots +1";"1"
"Rynore";"wardrobe";"Thur. Tights +1";"1"
"Rynore";"wardrobe";"Temple Torque";"1"
"Rynore";"wardrobe";"Kingdom Aketon";"1"
"Rynore";"wardrobe";"Metal Slime Hat";"1"
"Rynore";"wardrobe";"Solea";"1"
"Rynore";"wardrobe";"Garish Slacks";"1"
"Rynore";"wardrobe";"Cotton Gloves";"1"
"Rynore";"safe";"Bookshelf";"2"
"Rynore";"safe";"Treant Bulb";"8"
"Rynore";"safe";"Giant Fish Bones";"1"
"Rynore";"safe";"Wool Thread";"1"
"Rynore";"safe";"Cabinet";"1"
"Rynore";"safe";"Pugil Scales";"10"
"Rynore";"safe";"Cupboard";"1"
"Rynore";"safe";"VCS Reg. Card";"1"
"Rynore";"safe";"Wastebasket";"1"
"Rynore";"safe";"Qdv. Mage Blood";"1"
"Rynore";"safe";"Gnole Pellets";"1"
"Rynore";"safe";"Mannequin Feet";"1"
"Rynore";"safe";"Recital Bench";"1"
"Rynore";"safe";"Bahut";"1"
"Rynore";"safe";"Bookstack";"1"
"Rynore";"safe";"Regen III";"1"
"Rynore";"safe";"Slime Rocket";"9"
"Rynore";"safe";"R. Orchid Vase";"1"
"Rynore";"safe";"Stationery Set";"1"
"Rynore";"safe";"Ceramic Flowerpot";"1"
"Rynore";"safe";"Ram Skin";"12"
"Rynore";"safe";"Lizard Skin";"8"
"Rynore";"safe";"Dial Key #Ab";"16"
"Rynore";"safe";"Butterfly Cage";"1"
"Rynore";"safe";"Chest";"1"
"Rynore";"safe";"Wicker Box";"1"
"Rynore";"safe";"Maple Table";"1"
"Rynore";"safe";"Wolf Hide";"7"
"Rynore";"safe";"San d'Orian Tree";"1"
"Rynore";"safe";"Noble's Bed";"1"
"Rynore";"safe";"Vana'clock";"1"
"Rynore";"safe";"Thundaga II";"1"
"Rynore";"safe";"Reraise III";"1"
"Rynore";"safe";"Snowman Knight";"1"
"Rynore";"safe";"Gain-STR";"1"
"Rynore";"safe";"Firesand";"1"
"Rynore";"safe";"Fiend Blood";"5"
"Rynore";"safe";"Brass Ingot";"2"
"Rynore";"safe";"Silver Beastcoin";"12"
"Rynore";"safe";"6-Drawer Almirah";"1"
"Rynore";"safe";"F.Abjuration: Bd.";"1"
"Rynore";"safe";"Shellra IV";"1"
"Rynore";"safe";"Wardrobe";"1"
"Rynore";"safe";"Foe Lullaby";"1"
"Rynore";"safe";"Tantra Seal: Ft.";"4"
"Rynore";"safe";"San d'Orian Flag";"1"
"Rynore";"safe";"Bonbori";"1"
"Rynore";"safe";"Arrowwood Lbr.";"5"
"Rynore";"safe";"Refresh";"1"
"Rynore";"safe";"Ram Horn";"5"
"Rynore";"safe";"Luminicloth";"4"
"Rynore";"safe";"Giant Stinger";"4"
"Rynore";"safe";"Cotton Cloth";"4"
"Rynore";"safe";"Yel. VCS Plaque";"1"
"Rynore";"safe";"Deton. Sphere";"5"
"Rynore";"safe";"Ornate Fragment";"1"
"Rynore";"safe";"Adaman Ore";"4"
"Rynore";"safe";"Prismatic Chest";"1"
"Rynore";"safe";"Insect Wing";"12"
"Rynore";"storage";"Red Moko Grass";"4"
"Rynore";"storage";"Va.Abjuration: Ft.";"1"
"Rynore";"storage";"Mahogany Log";"3"
"Rynore";"storage";"Ephemeral Cloth";"1"
"Rynore";"storage";"Sleepshroom";"1"
"Rynore";"storage";"Coeurl Meat";"1"
"Rynore";"storage";"Vegetable Seeds";"7"
"Rynore";"storage";"Mythril Leaf";"1"
"Rynore";"storage";"Titanite";"4"
"Rynore";"storage";"Ash Lumber";"12"
"Rynore";"storage";"Land Crab Meat";"12"
"Rynore";"storage";"Vanilla";"5"
"Rynore";"storage";"Chamomile";"1"
"Rynore";"storage";"Bukktooth";"1"
"Rynore";"storage";"Mhaura Garlic";"3"
"Rynore";"storage";"Cockatrice Meat";"1"
"Rynore";"storage";"Poison Dust";"2"
"Rynore";"storage";"Mushrm. Locust";"3"
"Rynore";"storage";"Cactus Stems";"3"
"Rynore";"storage";"Dried Marjoram";"1"
"Rynore";"storage";"Elm Log";"12"
"Rynore";"storage";"Rotten Meat";"17"
"Rynore";"storage";"Red Terrapin";"16"
"Rynore";"storage";"Mighty Sardonyx";"1"
"Rynore";"storage";"Quus";"18"
"Rynore";"storage";"Hare Meat";"29"
"Rynore";"storage";"Insect Wing";"6"
"Rynore";"storage";"Lizard Egg";"2"
"Rynore";"storage";"Bomb Ash";"1"
"Rynore";"storage";"Eastern Gem";"3"
"Rynore";"storage";"Dhalmel Hide";"1"
"Rynore";"storage";"Blk. Tiger Fang";"12"
"Rynore";"storage";"Mulsum";"11"
"Rynore";"storage";"Crawler Calculus";"1"
"Rynore";"storage";"Royal Jelly";"1"
"Rynore";"storage";"Crystal Petrifact";"1"
"Rynore";"storage";"Chestnut";"7"
"Rynore";"storage";"Win. Tea Leaves";"10"
"Rynore";"storage";"Honey";"31"
"Rynore";"storage";"Hecteyes Eye";"2"
"Rynore";"storage";"Crawler Cocoon";"11"
"Rynore";"storage";"Acorn";"5"
"Rynore";"storage";"Chocobo Fltchg.";"2"
"Rynore";"storage";"Faerie Apple";"8"
"Rynore";"storage";"Deathball";"1"
"Rynore";"storage";"Seedspall Lux";"1"
"Rynore";"storage";"Poison Potion";"3"
"Rynore";"storage";"Recoll. of Pain";"1"
"Rynore";"storage";"Chestnut Log";"1"
"Rynore";"storage";"Pine Nuts";"7"
"Rynore";"storage";"Scop's Operetta";"1"
"Rynore";"storage";"Black Pepper";"7"
"Rynore";"storage";"Jade Cell";"2"
"Rynore";"storage";"Wild Onion";"5"
"Rynore";"storage";"Yogurt";"6"
"Rynore";"storage";"Yagudo Feather";"12"
"Rynore";"storage";"Black C. Feather";"1"
"Rynore";"storage";"Kazham Peppers";"9"
"Rynore";"storage";"Crawler Egg";"1"
"Rynore";"storage";"Fish Mithkabob";"12"
"Rynore";"storage";"Toko. Wildgrass";"12"
"Rynore";"storage";"Dryad Root";"2"
"Rynore";"storage";"Mtl. Beastcoin";"2"
"Rynore";"storage";"Dhalmel Meat";"11"
"Rynore";"storage";"Arrowwood Lbr.";"12"
"Rynore";"storage";"Tarutaru Rice";"12"
"Rynore";"storage";"Danceshroom";"1"
"Rynore";"storage";"G. Sheep Meat";"4"
"Rynore";"storage";"Beetle Shell";"24"
"Rynore";"storage";"Rosewood Log";"1"
"Rynore";"storage";"Soy Milk";"16"
"Rynore";"satchel";"H.Q. Antlion Jaw";"10"
"Rynore";"satchel";"King Truffle";"6"
"Rynore";"satchel";"Slime Oil";"3"
"Rynore";"satchel";"Adamantoise Shell";"5"
"Rynore";"satchel";"Phrygian Ore";"1"
"Rynore";"satchel";"Fire V";"1"
"Rynore";"satchel";"Moko Grass";"1"
"Rynore";"satchel";"Holy Basil";"2"
"Rynore";"satchel";"Adoulinian Kelp";"31"
"Rynore";"satchel";"Bat Fang";"9"
"Rynore";"satchel";"Maple Log";"4"
"Rynore";"satchel";"Antlion Jaw";"4"
"Rynore";"satchel";"Silver Nugget";"2"
"Rynore";"satchel";"Sage";"3"
"Rynore";"satchel";"Eudaemon Blade";"1"
"Rynore";"satchel";"H.Q. Marid Hide";"1"
"Rynore";"satchel";"Tree Cuttings";"5"
"Rynore";"satchel";"Chicken Bone";"1"
"Rynore";"satchel";"Fresh Marjoram";"12"
"Rynore";"satchel";"Senroh Sardine";"20"
"Rynore";"satchel";"Saruta Cotton";"12"
"Rynore";"satchel";"Blue Peas";"2"
"Rynore";"satchel";"Elm Log";"1"
"Rynore";"satchel";"Mackerel";"12"
"Rynore";"satchel";"H.Q. B. Tiger Hide";"1"
"Rynore";"satchel";"Bat Wing";"7"
"Rynore";"satchel";"Saffron";"1"
"Rynore";"satchel";"Insect Wing";"9"
"Rynore";"satchel";"Scorpion Claw";"21"
"Rynore";"satchel";"Eggplant";"11"
"Rynore";"satchel";"Herb Seeds";"12"
"Rynore";"satchel";"Pugil Scales";"10"
"Rynore";"satchel";"Malboro Vine";"1"
"Rynore";"satchel";"Kukuru Bean";"3"
"Rynore";"satchel";"Tomato Juice";"1"
"Rynore";"satchel";"Snow Geode";"1"
"Rynore";"satchel";"Blizzard III";"1"
"Rynore";"satchel";"Flax Flower";"11"
"Rynore";"satchel";"Wyvern Wing";"1"
"Rynore";"satchel";"Scorpion Shell";"3"
"Rynore";"satchel";"Crawler Cocoon";"12"
"Rynore";"satchel";"Stonega III";"1"
"Rynore";"satchel";"Grain Seeds";"12"
"Rynore";"satchel";"Mercury";"10"
"Rynore";"satchel";"Bay Leaves";"5"
"Rynore";"satchel";"Lacquer Tree Log";"4"
"Rynore";"satchel";"Fire III";"1"
"Rynore";"satchel";"Sheep Tooth";"12"
"Rynore";"satchel";"Ladybug Wing";"7"
"Rynore";"satchel";"Rabbit Hide";"4"
"Rynore";"satchel";"Stone IV";"1"
"Rynore";"satchel";"Ash Log";"2"
"Rynore";"satchel";"San d'Or. Flour";"4"
"Rynore";"satchel";"Crab Shell";"1"
"Rynore";"satchel";"Crayfish";"42"
"Rynore";"satchel";"Sacrifice";"1"
"Rynore";"satchel";"Sunflower Seeds";"2"
"Rynore";"satchel";"Tin Ore";"2"
"Rynore";"satchel";"Lizard Tail";"4"
"Rynore";"satchel";"Fresh Mugwort";"3"
"Rynore";"satchel";"King Locust";"3"
"Rynore";"satchel";"Snapping Mole";"12"
"Rynore";"satchel";"Mannequin Body";"1"
"Rynore";"satchel";"Copper Frog";"12"
"Rynore";"satchel";"Ulbukan Lobster";"15"
"Rynore";"satchel";"Spider Web";"21"
"Rynore";"satchel";"Giant Bird Fthr.";"1"
"Rynore";"satchel";"Fish Bones";"5"
"Rynore";"satchel";"Beetle Shell";"4"
"Rynore";"satchel";"Saffron Blossom";"2"
"Rynore";"satchel";"Bee Pollen";"1"
"Rynore";"sack";"Honey Wine";"1"
"Rynore";"sack";"Damselfly Worm";"1"
"Rynore";"sack";"Gold Ore";"2"
"Rynore";"sack";"Crab Sushi";"12"
"Rynore";"sack";"Bone Chip";"30"
"Rynore";"sack";"Maple Lumber";"2"
"Rynore";"sack";"Roasted Corn";"6"
"Rynore";"sack";"Antlion Spirit";"59"
"Rynore";"sack";"Yag. Holy Water";"1"
"Rynore";"sack";"Cactus Arm";"3"
"Rynore";"sack";"Ash Lumber";"7"
"Rynore";"sack";"Silent Oil";"12"
"Rynore";"sack";"Beetle Spirit";"53"
"Rynore";"sack";"Sage";"4"
"Rynore";"sack";"Platinum Ore";"4"
"Rynore";"sack";"Igneous Rock";"8"
"Rynore";"sack";"Marguerite";"2"
"Rynore";"sack";"Graubg. Lettuce";"1"
"Rynore";"sack";"Mola Mola";"2"
"Rynore";"sack";"Frost Turnip";"1"
"Rynore";"sack";"Trail Cookie";"3"
"Rynore";"sack";"Saruta Cotton";"9"
"Rynore";"sack";"Distilled Water";"1"
"Rynore";"sack";"Bkn. Fast. Rod";"1"
"Rynore";"sack";"Silk Cloth";"1"
"Rynore";"sack";"Asphodel";"2"
"Rynore";"sack";"Walnut Log";"1"
"Rynore";"sack";"La Theine Cbg.";"3"
"Rynore";"sack";"Yayla Corbasi";"1"
"Rynore";"sack";"Black Pearl";"1"
"Rynore";"sack";"Gem of the South";"1"
"Rynore";"sack";"Grass Thread";"6"
"Rynore";"sack";"Sole Sushi";"11"
"Rynore";"sack";"San d'Or. Carrot";"8"
"Rynore";"sack";"Mythril Ore";"5"
"Rynore";"sack";"Prism Powder";"12"
"Rynore";"sack";"Cld. Coffer Key";"1"
"Rynore";"sack";"Fruit Seeds";"8"
"Rynore";"sack";"Hard-boiled Egg";"2"
"Rynore";"sack";"Win. Tea Leaves";"12"
"Rynore";"sack";"Beastcoin";"1"
"Rynore";"sack";"Pachy. Spirit";"5"
"Rynore";"sack";"Ladybug Wing";"2"
"Rynore";"sack";"Loc. Elutriator";"1"
"Rynore";"sack";"Vanadium Ore";"1"
"Rynore";"sack";"Popoto";"3"
"Rynore";"sack";"Rancor Tank";"1"
"Rynore";"sack";"O. Bronzepiece";"1"
"Rynore";"sack";"Beastman Blood";"2"
"Rynore";"sack";"Burdock";"1"
"Rynore";"sack";"Deodorizer";"12"
"Rynore";"sack";"Monkey Wine";"1"
"Rynore";"sack";"Twitherym Wing";"1"
"Rynore";"sack";"Beetle Jaw";"8"
"Rynore";"sack";"Rancor Handle";"1"
"Rynore";"sack";"Isleracea";"1"
"Rynore";"sack";"Teak Log";"1"
"Rynore";"sack";"Yagudo Cherry";"3"
"Rynore";"sack";"P. DRK Card";"1"
"Rynore";"sack";"Snowy Cermet";"1"
"Rynore";"sack";"Umbril Ooze";"1"
"Rynore";"sack";"Remedy";"3"
"Rynore";"sack";"Misx. Parsley";"7"
"Rynore";"sack";"Shrimp Lantern";"1"
"Rynore";"sack";"Darksteel Ore";"11"
"Rynore";"sack";"Star Spinel";"1"
"Rynore";"sack";"Vegetable Seeds";"8"
"Rynore";"sack";"Auric Sand";"6"
"Rynore";"sack";"Counterfeit Gil";"3"
"Rynore";"sack";"Beetle Shell";"3"
"Rynore";"sack";"Shall Shell";"16"
"Rynore";"sack";"Meteorite";"2"
"Rynore";"sack";"Ph. Gold Ingot";"1"
"Rynore";"sack";"Obr. Bull. Pouch";"1"
"Rynore";"case";"Prelate Key";"1"
"Rynore";"case";"Beitetsu";"58"
"Rynore";"case";"Kitchen Stove";"1"
"Rynore";"case";"Goblin Helm";"3"
"Rynore";"case";"Stone Arrowhd.";"36"
"Rynore";"case";"Bat Fang";"60"
"Rynore";"case";"Voiddust";"1"
"Rynore";"case";"Ram Skin";"2"
"Rynore";"case";"Tonberry Lantern";"1"
"Rynore";"case";"Ametrine";"1"
"Rynore";"case";"G. Bird Plume";"1"
"Rynore";"case";"Nyumomo Doll";"1"
"Rynore";"case";"Goblin Mail";"2"
"Rynore";"case";"Xhifhut Body";"1"
"Rynore";"case";"Cactus Stems";"1"
"Rynore";"case";"Fluorite";"1"
"Rynore";"case";"Silver Beastcoin";"1"
"Rynore";"case";"Unlit Lantern";"1"
"Rynore";"case";"Breeze Geode";"1"
"Rynore";"case";"Bat Wing";"22"
"Rynore";"case";"Echo Drops";"12"
"Rynore";"case";"Xhifhut Strings";"1"
"Rynore";"case";"Antican Pauldron";"1"
"Rynore";"case";"Gold Beastcoin";"1"
"Rynore";"case";"Roast Mushroom";"12"
"Rynore";"case";"Raptor Skin";"2"
"Rynore";"case";"Cobalt Cell";"1"
"Rynore";"case";"Amemet Skin";"1"
"Rynore";"case";"Blk. Tiger Fang";"3"
"Rynore";"case";"Gigas Socks";"5"
"Rynore";"case";"Prism Powder";"12"
"Rynore";"case";"Mermaid Hands";"1"
"Rynore";"case";"Xhifhut Bow";"1"
"Rynore";"case";"Xanthous Cell";"1"
"Rynore";"case";"Rabbit Hide";"20"
"Rynore";"case";"Percolator";"1"
"Rynore";"case";"Sun Water";"1"
"Rynore";"case";"Diorite";"1"
"Rynore";"case";"P. WHM Card";"1"
"Rynore";"case";"Chocobo Fltchg.";"198"
"Rynore";"case";"Animal Glue";"3"
"Rynore";"case";"Sanguinet";"1"
"Rynore";"case";"Revival Root";"19"
"Rynore";"case";"Seasoning Stone";"24"
"Rynore";"case";"Fish Bones";"6"
"Rynore";"case";"Cotton Thread";"9"
"Rynore";"case";"Samwell's Shank";"1"
"Rynore";"case";"Beetle Jaw";"12"
"Rynore";"case";"Kitchen Brick";"1"
"Rynore";"case";"Jade Cell";"1"
"Rynore";"case";"Tree Sap";"1"
"Rynore";"case";"Flickering Lantern";"1"
"Rynore";"case";"Oak Log";"1"
"Rynore";"case";"Tonberry Coat";"21"
"Rynore";"case";"Tin Ore";"1"
"Rynore";"case";"Cockatrice Meat";"2"
"Rynore";"case";"Arrowwood Lbr.";"15"
"Rynore";"case";"Delkfutt Key";"1"
"Rynore";"case";"Sheepskin";"3"
"Rynore";"case";"Legshard: GEO";"1"
"Rynore";"case";"Indi-Frailty";"1"
"Rynore";"case";"Elm Lumber";"1"
"Rynore";"case";"Lufet Salt";"2"
"Rynore";"case";"Soil Geode";"1"
"Rynore";"case";"Rubicund Cell";"1"
"Rynore";"case";"Beetle Shell";"8"
"Rynore";"case";"Fenrite";"1"
"Rynore";"case";"Wamoura Silk";"1"
"Rynore";"safe2";"Grass Cloth";"2"
"Rynore";"safe2";"Bronze Rose";"1"
"Rynore";"safe2";"Bst. Testimony";"1"
"Rynore";"safe2";"Seasoning Stone";"8"
"Rynore";"safe2";"Tonko: Ni";"1"
"Rynore";"safe2";"Flint Stone";"16"
"Rynore";"safe2";"Argyro Rivet";"1"
"Rynore";"safe2";"Pld. Testimony";"1"
"Rynore";"safe2";"Absorb-INT";"1"
"Rynore";"safe2";"Beeswax";"5"
"Rynore";"safe2";"Absorb-VIT";"1"
"Rynore";"safe2";"Indi-Wilt";"1"
"Rynore";"safe2";"Archer's Prelude";"1"
"Rynore";"safe2";"Rng. Testimony";"1"
"Rynore";"safe2";"E.Abjuration: Hd.";"1"
"Rynore";"safe2";"Mizu-Deppo";"99"
"Rynore";"safe2";"Fossilized Fang";"6"
"Rynore";"safe2";"Drk. Testimony";"1"
"Rynore";"safe2";"P.Abjuration: Ft.";"1"
"Rynore";"safe2";"Raiton: Ni";"1"
"Rynore";"safe2";"Riftborn Boulder";"69"
"Rynore";"safe2";"War. Testimony";"1"
"Rynore";"safe2";"A.Abjuration: Ft.";"1"
"Rynore";"safe2";"Silver Ore";"9"
"Rynore";"safe2";"Mercury";"7"
"Rynore";"safe2";"Mythril Ore";"12"
"Rynore";"safe2";"E.Abjuration: Hn.";"1"
"Rynore";"safe2";"Papaka Grass";"2"
"Rynore";"safe2";"Sciss. Sphere";"8"
"Rynore";"safe2";"Drg. Testimony";"1"
"Rynore";"safe2";"Bird Feather";"14"
"Rynore";"safe2";"Lindwurm Skin";"2"
"Rynore";"safe2";"Iron Ore";"15"
"Rynore";"safe2";"Spect. Goldenrod";"2"
"Rynore";"safe2";"Ancient Blood";"1"
"Rynore";"safe2";"Nin. Testimony";"1"
"Rynore";"safe2";"Saruta Cotton";"7"
"Rynore";"safe2";"Absorb-AGI";"1"
"Rynore";"safe2";"Doton: Ni";"1"
"Rynore";"safe2";"Huton: Ni";"1"
"Rynore";"safe2";"Yagudo Feather";"7"
"Rynore";"safe2";"2Leaf Mandra Bud";"4"
"Rynore";"safe2";"Mnk. Testimony";"1"
"Rynore";"safe2";"Barrage Turbine";"1"
"Rynore";"safe2";"Bkn. Taru. Rod";"1"
"Rynore";"safe2";"Kopparnickel Ore";"7"
"Rynore";"safe2";"Slv. Arrowheads";"1"
"Rynore";"safe2";"Whm. Testimony";"1"
"Rynore";"safe2";"Silk Thread";"9"
"Rynore";"safe2";"Zinc Ore";"23"
"Rynore";"safe2";"Rdm. Testimony";"1"
"Rynore";"safe2";"Pluton";"22"
"Rynore";"safe2";"Steel Nugget";"1"
"Rynore";"safe2";"Dokumori: Ichi";"1"
"Rynore";"safe2";"Blm. Testimony";"1"
"Rynore";"safe2";"Dresser";"1"
"Rynore";"wardrobe2";"Wayfarer Clogs";"1"
"Rynore";"wardrobe2";"Buckler";"1"
"Rynore";"wardrobe2";"Carapace Mask";"1"
"Rynore";"wardrobe2";"Almogavar Bow";"1"
"Rynore";"wardrobe2";"Agile Mantle";"1"
"Rynore";"wardrobe2";"Shinobi Gi";"1"
"Rynore";"wardrobe2";"Velvet Hat";"1"
"Rynore";"wardrobe2";"Iron Mittens";"1"
"Rynore";"wardrobe2";"Gauntlets";"1"
"Rynore";"wardrobe2";"Hoplon";"1"
"Rynore";"wardrobe2";"Channeling Robe";"1"
"Rynore";"wardrobe2";"Shinobi Hakama";"1"
"Rynore";"wardrobe2";"Sahip Helm";"1"
"Rynore";"wardrobe2";"Shinobi Hachigane";"1"
"Rynore";"wardrobe2";"Wayfarer Circlet";"1"
"Rynore";"wardrobe2";"Cuir Trousers";"1"
"Rynore";"wardrobe2";"Wayfarer Slops";"1"
"Rynore";"wardrobe2";"Ogre Trousers";"1"
"Rynore";"wardrobe2";"Padded Armor";"1"
"Rynore";"wardrobe2";"Crow Beret";"1"
"Rynore";"wardrobe2";"Gambison";"1"
"Rynore";"wardrobe2";"Sallet";"1"
"Rynore";"wardrobe2";"Ryl.Sqr. Robe";"1"
"Rynore";"wardrobe2";"Wool Cuffs";"1"
"Rynore";"wardrobe2";"Gnd.T.K. Bangles";"1"
"Rynore";"wardrobe2";"Crow Hose";"1"
"Rynore";"wardrobe2";"Espial Cap";"1"
"Rynore";"wardrobe2";"Ryl.Kgt. Aketon";"1"
"Rynore";"wardrobe2";"Velvet Cuffs";"1"
"Rynore";"wardrobe2";"Gothic Sabatons";"1"
"Rynore";"wardrobe2";"Wayfarer Cuffs";"1"
"Rynore";"wardrobe2";"Wayfarer Robe";"1"
"Rynore";"wardrobe2";"Carapace Harness";"1"
"Rynore";"wardrobe2";"Cuir Highboots";"1"
"Rynore";"wardrobe2";"Carapace Subligar";"1"
"Rynore";"wardrobe2";"Cuisses";"1"
"Rynore";"wardrobe2";"Red Cap";"1"
"Rynore";"wardrobe2";"Crow Gaiters";"1"
"Rynore";"wardrobe2";"Iron Subligar";"1"
"Rynore";"wardrobe2";"Shinobi Tekko";"1"
"Rynore";"wardrobe2";"Garish Pumps";"1"
"Rynore";"wardrobe2";"Breastplate";"1"
"Rynore";"wardrobe2";"Espial Bracers";"1"
"Rynore";"wardrobe2";"Crow Jupon";"1"
"Rynore";"wardrobe2";"Darksteel Axe";"1"
"Rynore";"wardrobe2";"Hose";"1"
"Rynore";"wardrobe2";"Shinobi Kyahan";"1"
"Rynore";"wardrobe2";"Kacura Cap";"1"
"Rynore";"wardrobe2";"Cuir Bouilli";"1"
"Rynore";"wardrobe2";"Alkyoneus's Brc.";"1"
"Rynore";"wardrobe2";"Plain Pick";"1"
"Rynore";"wardrobe2";"Coalrake Sabots";"1"
"Rynore";"wardrobe2";"Banded Mail";"1"
"Rynore";"wardrobe2";"Plate Leggings";"1"
"Rynore";"wardrobe2";"Velvet Robe";"1"
"Rynore";"wardrobe2";"Espial Hose";"1"
"Rynore";"wardrobe2";"Espial Socks";"1"
"Rynore";"wardrobe2";"Socks";"1"
"Rynore";"wardrobe2";"Gothic Gauntlets";"1"
"Rynore";"wardrobe2";"Padded Cap";"1"
"Rynore";"wardrobe2";"Pyro Robe";"1"
"Rynore";"wardrobe2";"Espial Gambison";"1"
"Rynore";"wardrobe2";"Bracers";"1"
"Rynore";"wardrobe2";"Garish Mitts";"1"
"Rynore";"wardrobe2";"Cuir Bandana";"1"
"Rynore";"wardrobe2";"Ebony Sabots";"1"
"Rynore";"wardrobe2";"Brigandine";"1"
"Rynore";"wardrobe2";"Crow Bracers";"1"
"Rynore";"wardrobe2";"Cpc. Leggings";"1"
"Rynore";"wardrobe2";"Leggings";"1"
"Rynore";"wardrobe2";"Velvet Slops";"1"
"Rynore";"wardrobe3";"Deathbringer";"1"
"Rynore";"wardrobe3";"Hagoita";"1"
"Rynore";"wardrobe3";"Holy Mace";"1"
"Rynore";"wardrobe3";"Custodes";"1"
"Rynore";"wardrobe3";"Bomb Arm";"3"
"Rynore";"wardrobe3";"Brass Rod";"1"
"Rynore";"wardrobe3";"Kite Shield";"1"
"Rynore";"wardrobe3";"Spear";"1"
"Rynore";"wardrobe3";"Lilith's Rod";"1"
"Rynore";"wardrobe3";"Willow Wand";"1"
"Rynore";"wardrobe3";"Hoplon";"1"
"Rynore";"wardrobe3";"Em. Baghnakhs";"1"
"Rynore";"wardrobe3";"Yew Wand";"1"
"Rynore";"wardrobe3";"Bronze Axe";"1"
"Rynore";"wardrobe3";"Maple Shield";"1"
"Rynore";"wardrobe3";"Flame Boomerang";"1"
"Rynore";"wardrobe3";"Bronze Knife";"1"
"Rynore";"wardrobe3";"Ceres' Spica";"1"
"Rynore";"wardrobe3";"Flame Degen";"1"
"Rynore";"wardrobe3";"Katayama";"1"
"Rynore";"wardrobe3";"Power Crossbow";"1"
"Rynore";"wardrobe3";"Brass Xiphos";"1"
"Rynore";"wardrobe3";"Nymph Shield";"1"
"Rynore";"wardrobe3";"Rose Wand";"1"
"Rynore";"wardrobe3";"Misery Staff";"1"
"Rynore";"wardrobe3";"Shortbow";"1"
"Rynore";"wardrobe3";"Floral Hagoita";"1"
"Rynore";"wardrobe3";"Broadsword";"1"
"Rynore";"wardrobe3";"Pebble";"13"
"Rynore";"wardrobe3";"Maul";"1"
"Rynore";"wardrobe3";"Ebony Wand";"1"
"Rynore";"wardrobe3";"Spellcaster's Ecu";"1"
"Rynore";"wardrobe3";"Wrapped Bow";"1"
"Rynore";"wardrobe3";"Eyra Baghnakhs";"1"
"Rynore";"wardrobe3";"Shell Shield";"1"
"Rynore";"wardrobe3";"Hard Shield";"1"
"Rynore";"wardrobe3";"Mahogany Shield";"1"
"Rynore";"wardrobe3";"Hunting Sword";"1"
"Rynore";"wardrobe3";"Slime Shield";"1"
"Rynore";"wardrobe3";"Stone Arrow";"450"
"Rynore";"wardrobe3";"Firefly";"1"
"Rynore";"wardrobe3";"Bone Arrow";"1"
"Rynore";"wardrobe3";"Holly Pole";"1"
"Rynore";"wardrobe3";"Targe";"1"
"Rynore";"wardrobe3";"She-Slime Shield";"1"
"Rynore";"wardrobe3";"Fish Scale Shield";"1"
"Rynore";"wardrobe3";"Holy Maul";"1"
"Rynore";"wardrobe3";"Elm Staff";"1"
"Rynore";"wardrobe3";"Oak Pole";"1"
"Rynore";"wardrobe3";"Leather Shield";"1"
"Rynore";"wardrobe3";"Bone Knife";"1"
"Rynore";"wardrobe3";"Flame Blade";"1"
"Rynore";"wardrobe3";"Composite Bow";"1"
"Rynore";"wardrobe3";"San d'Orian Bow";"1"
"Rynore";"wardrobe3";"Small Sword";"1"
"Rynore";"wardrobe3";"Othinus' Bow";"1"
"Rynore";"wardrobe3";"Metal Slime Shield";"1"
"Rynore";"wardrobe3";"Janus Guard";"1"
"Rynore";"wardrobe3";"Kukri";"1"
"Rynore";"wardrobe3";"Bronze Bolt";"190"
"Rynore";"wardrobe3";"Battleaxe";"1"
"Rynore";"wardrobe3";"Beetle Knife";"1"
"Rynore";"wardrobe3";"Aspir Knife";"2"
"Rynore";"wardrobe3";"Light Crossbow";"1"
"Rynore";"wardrobe3";"Lohar";"1"
"Rynore";"wardrobe3";"Gladius";"1"
"Rynore";"wardrobe3";"Spatha";"1"
"Rynore";"wardrobe3";"Mammut";"1"
"Rynore";"wardrobe4";"Brass Ring";"1"
"Rynore";"wardrobe4";"Gyokuto Obi";"1"
"Rynore";"wardrobe4";"Corsette";"1"
"Rynore";"wardrobe4";"Sardonyx Ring";"1"
"Rynore";"wardrobe4";"Desperado Ring";"1"
"Rynore";"wardrobe4";"Grand T.K. Collar";"1"
"Rynore";"wardrobe4";"Brocade Obi";"1"
"Rynore";"wardrobe4";"Ryl. Army Mantle";"1"
"Rynore";"wardrobe4";"Silver Ring";"1"
"Rynore";"wardrobe4";"High Brth. Mantle";"1"
"Rynore";"wardrobe4";"Green Earring";"1"
"Rynore";"wardrobe4";"Qiqirn Sash";"1"
"Rynore";"wardrobe4";"Lleu's Charm";"1"
"Rynore";"wardrobe4";"Torque";"1"
"Rynore";"wardrobe4";"Beast Whistle";"1"
"Rynore";"wardrobe4";"Tiger Stole";"1"
"Rynore";"wardrobe4";"Rabbit Mantle";"1"
"Rynore";"wardrobe4";"Arete del Sol";"1"
"Rynore";"wardrobe4";"Tourmaline Ring";"1"
"Rynore";"wardrobe4";"Vehemence Ring";"1"
"Rynore";"wardrobe4";"Warp Ring";"1"
"Rynore";"wardrobe4";"Sardonyx Earring";"1"
"Rynore";"wardrobe4";"Peiste Mantle";"1"
"Rynore";"wardrobe4";"Rancorous Mantle";"1"
"Rynore";"wardrobe4";"M. Slime Earring";"1"
"Rynore";"wardrobe4";"Swordbelt";"1"
"Rynore";"wardrobe4";"Protect Earring";"1"
"Rynore";"wardrobe4";"Dhalmel Mantle";"1"
"Rynore";"wardrobe4";"Hard Leather Ring";"1"
"Rynore";"wardrobe4";"Little Worm Belt";"1"
"Rynore";"wardrobe4";"Clear Ring";"1"
"Rynore";"wardrobe4";"Gold Earring";"1"
"Rynore";"wardrobe4";"Sun Earring";"1"
"Rynore";"wardrobe4";"Leather Ring";"1"
"Rynore";"wardrobe4";"Beak Necklace";"1"
"Rynore";"wardrobe4";"Ranger's Necklace";"1"
"Rynore";"wardrobe4";"Bushinomimi";"1"
"Rynore";"wardrobe4";"Invisible Mantle";"1"
"Rynore";"wardrobe4";"Chocobo Rope";"1"
"Rynore";"wardrobe4";"Flower Necklace";"1"
"Rynore";"wardrobe4";"Amethyst Earring";"1"
"Rynore";"wardrobe4";"Friar's Rope";"1"
"Rynore";"wardrobe4";"Justice Badge";"1"
"Rynore";"wardrobe4";"Opo-opo Necklace";"1"
"Rynore";"wardrobe4";"Flagellant's Rope";"1"
"Rynore";"wardrobe4";"Black Cape";"1"
"Rynore";"wardrobe4";"Tortoise Earring";"1"
"Rynore";"wardrobe4";"Pinwheel Belt";"1"
"Rynore";"wardrobe4";"Nexus Cape";"1"
"Rynore";"wardrobe4";"Mythril Earring";"1"
"Rynore";"wardrobe4";"Cape";"1"
"Rynore";"wardrobe4";"Coral Gorget";"1"
"Rynore";"wardrobe4";"Purple Earring";"1"
"Rynore";"wardrobe4";"Physical Earring";"1"
"Rynore";"wardrobe4";"Peiste Belt";"1"
"Rynore";"wardrobe4";"Mohbwa Scarf";"1"
"Rynore";"wardrobe4";"Hi-Potion Tank";"1"
"Rynore";"wardrobe4";"She-Slime Earring";"1"
"Rynore";"wardrobe4";"Twinthread Obi";"1"
"Rynore";"wardrobe4";"Jester's Cape";"1"
"Rynore";"wardrobe4";"Pile Chain";"1"
"Rynore";"wardrobe4";"Amber Ring";"1"
"Rynore";"wardrobe4";"Gaia Mantle";"1"
"Rynore";"wardrobe4";"Rainbow Obi";"1"
"Rynore";"wardrobe4";"White Belt";"1"
"Rynore";"wardrobe4";"Tenax Strap";"1"
"Rynore";"wardrobe4";"Medieval Collar";"1"
"Rynore";"wardrobe4";"Hi-Ether Tank";"1"
"Rynore";"wardrobe4";"Cotton Cape";"1"
"Rynore";"wardrobe4";"Homing Ring";"1"
"Rynore";"wardrobe4";"Slime Earring";"1"
"Rynore";"wardrobe4";"Wing Pendant";"1"
"Rynore";"wardrobe4";"Accura Cape";"1"
"Rynore";"wardrobe4";"Focus Collar";"1"
"Rynore";"wardrobe4";"Blind Ring";"1"
"Rynore";"wardrobe4";"Opal Ring";"1"
"Rynore";"wardrobe4";"Echad Ring";"1"
"Rynore";"wardrobe4";"Aquamrne. Earring";"1"
"Rynore";"wardrobe4";"Opal Earring";"1"
"Rynore";"wardrobe4";"Mythril Ring";"1"
"Rynore";"wardrobe5";"Dwarf Pugil";"1"
"Rynore";"wardrobe5";"Drill Calamary";"2"
"Rynore";"wardrobe5";"Pet Food Beta";"12"
"Rynore";"wardrobe5";"Svg. Mole Broth";"1"
"Rynore";"wardrobe5";"Clothespole";"1"
"Rynore";"wardrobe5";"Carrot Broth";"10"
"Rynore";"wardrobe5";"Carrion Broth";"1"
"Rynore";"wardrobe5";"Yew Fishing Rod";"1"
"Rynore";"wardrobe5";"S. Herbal Broth";"5"
"Rynore";"wardrobe5";"Bamboo Fish. Rod";"1"
"Rynore";"wardrobe5";"Sliced Sardine";"90"
"Rynore";"wardrobe5";"Wormy Broth";"4"
"Rynore";"wardrobe5";"Crayfish Ball";"10"
"Rynore";"wardrobe5";"Peeled Crayfish";"6"
"Rynore";"wardrobe6";"Analgesia Torque";"1"
"Rynore";"wardrobe6";"Esse Earring";"1"
"Rynore";"wardrobe6";"Metamorph Ring";"1"
"Rynore";"key items";"job gesture: black mage";"1"
"Rynore";"key items";"crimson orb";"1"
"Rynore";"key items";"map of Pso'Xja";"1"
"Rynore";"key items";"Ancient Melody: O";"1"
"Rynore";"key items";"Holla gate crystal";"1"
"Rynore";"key items";"cerulean crystal";"1"
"Rynore";"key items";"story of an impatient chocobo";"1"
"Rynore";"key items";"ring of supernal disjunction";"1"
"Rynore";"key items";"♪Doll companion";"1"
"Rynore";"key items";"Delkfutt key";"1"
"Rynore";"key items";"clear abyssite";"1"
"Rynore";"key items";"Mog Patio design document";"1"
"Rynore";"key items";"Mea gate crystal";"1"
"Rynore";"key items";"Windurst Trust permit";"1"
"Rynore";"key items";"Shard of Rage";"1"
"Rynore";"key items";"green invitation card";"1"
"Rynore";"key items";"scroll of treasure";"1"
"Rynore";"key items";"map of Bibiki Bay";"1"
"Rynore";"key items";"Vahzl gate crystal";"1"
"Rynore";"key items";"Shard of Apathy";"1"
"Rynore";"key items";"job gesture: dark knight";"1"
"Rynore";"key items";"map of Ru'Hmet";"1"
"Rynore";"key items";"story of a diligent chocobo";"1"
"Rynore";"key items";"map of the Boyahda Tree";"1"
"Rynore";"key items";"map of Cape Riverne";"1"
"Rynore";"key items";"♪Raptor companion";"1"
"Rynore";"key items";"map of the Kuftal Tunnel";"1"
"Rynore";"key items";"Moghancement: Gardening";"1"
"Rynore";"key items";"job gesture: red mage";"1"
"Rynore";"key items";"map of Tavnazia";"1"
"Rynore";"key items";"map of Carpenters' Landing";"1"
"Rynore";"key items";"job gesture: paladin";"1"
"Rynore";"key items";"job gesture: summoner";"1"
"Rynore";"key items";"Magian learner's log";"1"
"Rynore";"key items";"♪Buffalo companion";"1"
"Rynore";"key items";"map of the Elshimo regions";"1"
"Rynore";"key items";"prospector's pan";"1"
"Rynore";"key items";"map of the Toraimarai Canal";"1"
"Rynore";"key items";"Adventurer's Certificate";"1"
"Rynore";"key items";"map of Bhaflau Thickets";"1"
"Rynore";"key items";""Adoulin's Topiary Treasures"";"1"
"Rynore";"key items";"Angelica's autograph";"1"
"Rynore";"key items";"map of the Horutoto Ruins";"1"
"Rynore";"key items";"map of Halvung";"1"
"Rynore";"key items";"map of Ordelle's Caves";"1"
"Rynore";"key items";"map of Oldton Movalpolos";"1"
"Rynore";"key items";"map of Temple of Uggalepih";"1"
"Rynore";"key items";"map of the Gusgen Mines";"1"
"Rynore";"key items";"airship pass";"1"
"Rynore";"key items";""A Farewell to Freshwater"";"1"
"Rynore";"key items";"map of Delkfutt's Tower";"1"
"Rynore";"key items";"Ambuscade Primer Volume Two";"1"
"Rynore";"key items";"map of Wajaom Woodlands";"1"
"Rynore";"key items";"map of Mamook";"1"
"Rynore";"key items";"map of Ve'Lugannon Palace";"1"
"Rynore";"key items";"traverser stone";"1"
"Rynore";"key items";"map of the Vollbow region";"1"
"Rynore";"key items";"map of the Garlaige Citadel";"1"
"Rynore";"key items";"map of Sea Serpent Grotto";"1"
"Rynore";"key items";"blue invitation card";"1"
"Rynore";"key items";"map of Qufim Island";"1"
"Rynore";"key items";"map of Al'Taieu";"1"
"Rynore";"key items";"map of the Crawlers' Nest";"1"
"Rynore";"key items";"geomagnetron";"1"
"Rynore";"key items";"tuning fork of fire";"1"
"Rynore";"key items";"map of Castle Oztroja";"1"
"Rynore";"key items";"map of Labyrinth of Onzozo";"1"
"Rynore";"key items";"synergy crucible";"1"
"Rynore";"key items";"job gesture: beastmaster";"1"
"Rynore";"key items";"map of the Dangruf Wadi";"1"
"Rynore";"key items";"job gesture: monk";"1"
"Rynore";"key items";"black matinee necklace";"1"
"Rynore";"key items";"prototype attuner";"1"
"Rynore";"key items";"Ballista License";"1"
"Rynore";"key items";"map of Castle Zvahl";"1"
"Rynore";"key items";"GPS crystal";"1"
"Rynore";"key items";"seal of banishing";"1"
"Rynore";"key items";"map of the Maze of Shakhrami";"1"
"Rynore";"key items";"map of Fei'Yin";"1"
"Rynore";"key items";"trainer's whistle";"1"
"Rynore";"key items";"map of Bostaunieux Oubliette";"1"
"Rynore";"key items";"lamb memento";"1"
"Rynore";"key items";"vial of shrouded sand";"1"
"Rynore";"key items";"♪Byakko";"1"
"Rynore";"key items";"map of King Ranperre's Tomb";"1"
"Rynore";"key items";"map of the Den of Rancor";"1"
"Rynore";"key items";"♪Iron Giant companion";"1"
"Rynore";"key items";"Tenshodo Member's Card";"1"
"Rynore";"key items";"map of the Kuzotz region";"1"
"Rynore";"key items";"♪Red crab companion";"1"
"Rynore";"key items";"deed to Purgonorgo Isle";"1"
"Rynore";"key items";""Grandiloquent Groves"";"1"
"Rynore";"key items";"map of the Ranguemont Pass";"1"
"Rynore";"key items";"shimmering invitation";"1"
"Rynore";"key items";""Susuroon's Biiig Catch"";"1"
"Rynore";"key items";"map of Vunkerl Inlet";"1"
"Rynore";"key items";"map of Beadeaux";"1"
"Rynore";"key items";"archducal audience permit";"1"
"Rynore";"key items";""Rhapsody in White"";"1"
"Rynore";"key items";""My First Furrow"";"1"
"Rynore";"key items";"Reisenjima Sanctorium orb";"1"
"Rynore";"key items";"map of the Attohwa Chasm";"1"
"Rynore";"key items";"prismatic fragment";"1"
"Rynore";"key items";"map of the Palborough Mines";"1"
"Rynore";"key items";"concordoll";"1"
"Rynore";"key items";"limit breaker";"1"
"Rynore";"key items";"map of the Zeruhn Mines";"1"
"Rynore";"key items";"white handkerchief";"1"
"Rynore";"key items";"San d'Oria Trust permit";"1"
"Rynore";"key items";"map of Giddeus";"1"
"Rynore";"key items";"map of Newton Movalpolos";"1"
"Rynore";"key items";"memorandoll";"1"
"Rynore";"key items";"map of the Quicksand Caves";"1"
"Rynore";"key items";"crab caller";"1"
"Rynore";"key items";"gil repository";"1"
"Rynore";"key items";""20,000 Yalms Under the Sea"";"1"
"Rynore";"key items";"map of the Jeuno area";"1"
"Rynore";"key items";"map of the Eldieme Necropolis";"1"
"Rynore";"key items";"map of Grauberg";"1"
"Rynore";"key items";"map of Ifrit's Cauldron";"1"
"Rynore";"key items";""The Old Men of the Sea"";"1"
"Rynore";"key items";"timber survey checklist";"1"
"Rynore";"key items";"♪Phuabo companion";"1"
"Rynore";"key items";"Shard of Arrogance";"1"
"Rynore";"key items";"Yhoator gate crystal";"1"
"Rynore";"key items";"treasure map";"1"
"Rynore";"key items";""Dredging's No Drudgery"";"1"
"Rynore";"key items";""Varicose Mineral Veins"";"1"
"Rynore";"key items";""Water, Water Everywhere!"";"1"
"Rynore";"key items";""Take A Lode Off"";"1"
"Rynore";"key items";""Mythril Marathon Quarterly"";"1"
"Rynore";"key items";"Altepa gate crystal";"1"
"Rynore";"key items";"Shard of Cowardice";"1"
"Rynore";"key items";""Give My Regards to Reodoan"";"1"
"Rynore";"key items";""Sow★Your★Seed!"";"1"
"Rynore";"key items";"Dem gate crystal";"1"
"Rynore";"key items";"traverser stone";"1"
"Rynore";"key items";"map of the Aqueducts";"1"
"Rynore";"key items";"loadstone";"1"
"Rynore";"key items";"map of Nashmau";"1"
"Rynore";"key items";"mysterious amulet";"1"
"Rynore";"key items";"map of Fort Karugo-Narugo";"1"
"Rynore";"key items";"map of Aydeewa Subterrane";"1"
"Rynore";"key items";""Black Fish of the Family"";"1"
"Rynore";"key items";"map of Alzadaal Ruins";"1"
"Rynore";"key items";"map of Arrapago Reef";"1"
"Rynore";"key items";"map of Mount Zhayolm";"1"
"Rynore";"key items";"map of Caedarva Mire";"1"
"Rynore";"key items";"spirit incense";"1"
"Rynore";"key items";"piece of rugged tree bark";"1"
"Rynore";"key items";""All the Ways to Skin a Carp"";"1"
"Rynore";"key items";"Shard of Envy";"1"
"Rynore";"key items";"crest of Davoi";"1"
"Rynore";"key items";"silver bell";"1"
"Rynore";"key items";"pouch of weighted stones";"1"
"Rynore";"key items";"map of the Li'Telor region";"1"
"Rynore";"key items";"job gesture: ranger";"1"
"Rynore";"key items";"squire certificate";"1"
"Rynore";"key items";"traverser stone";"1"
"Rynore";"key items";"job gesture: thief";"1"
"Rynore";"key items";"job gesture: white mage";"1"
"Rynore";"key items";"chocobo license";"1"
"Rynore";"key items";"map of the Sacrarium";"1"
"Rynore";"key items";"magicked astrolabe";"1"
"Rynore";"key items";"coruscant rosary";"1"
"Rynore";"key items";"baby rabbit memento";"1"
"Rynore";"key items";"rabbit memento";"1"
"Rynore";"key items";"map of Al Zahbi";"1"
"Rynore";"key items";"map of the Windurst area";"1"
"Rynore";"key items";"job gesture: warrior";"1"
"Rynore";"key items";"map of the Uleguerand Range";"1"
"Rynore";"key items";"map of Norg";"1"
"Rynore";"key items";"red invitation card";"1"
"Rynore";"key items";"white invitation card";"1"
"Rynore";"key items";"♪Warmachine companion";"1"
"Rynore";"key items";"map of the Ru'Aun Gardens";"1"
"Rynore";"key items";"map of the Northlands area";"1"
"Rynore";"key items";"corked ampoule";"1"
"Rynore";"key items";"map of Davoi";"1"
"Rynore";"key items";"map of Hu'Xzoi";"1"
"Rynore";"key items";"heart of the bushin";"1"
"Rynore";"key items";"Bastok Trust permit";"1"
"Rynore";"key items";"map of Ghelsba";"1"
"Rynore";"key items";"Yagudo torch";"1"
"Rynore";"key items";"map of the Korroloka Tunnel";"1"
"Rynore";"key items";"map of the San d'Oria area";"1"
"Rynore";"key items";"map of the Bastok area";"1"
Can't render this file because it contains an unexpected character in line 821 and column 23.

View File

@@ -1,16 +1,33 @@
version: "3.9"
services:
api:
build: ./backend
container_name: mogapp-api
environment:
- PSQL_HOST=10.0.0.199
- PSQL_PORT=5432
- PSQL_USER=postgres
- PSQL_PASSWORD=DP3Wv*QM#t8bY*N
- PSQL_DBNAME=ffxi_items
# Database lives on the Docker host, expose via host networking
- PSQL_HOST=${PSQL_HOST:-host.docker.internal}
- PSQL_PORT=${PSQL_PORT:-5432}
- PSQL_USER=${PSQL_USER:-postgres}
- PSQL_PASSWORD=${PSQL_PASSWORD:-postgres}
- PSQL_DBNAME=${PSQL_DBNAME:-ffxi_items}
ports:
- "8000:8000"
volumes:
- ./backend/app:/app/app
networks:
- mognet
volumes:
pgdata:
frontend:
build: ./frontend
container_name: mogapp-web
depends_on:
- api
ports:
- "3050:80"
networks:
- mognet
networks:
mognet:
driver: bridge

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# ---------- Build stage ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN npm ci --ignore-scripts --prefer-offline
COPY . .
RUN npm run build
# ---------- Production stage ----------
FROM nginx:1.25-alpine AS prod
COPY --from=builder /app/dist /usr/share/nginx/html
# Remove default config and add minimal one
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
# Serve static assets
root /usr/share/nginx/html;
index index.html;
# Forward API requests to backend
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# All other routes SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"test": "vitest",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
@@ -18,6 +19,11 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"vitest": "^1.5.0",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/user-event": "^14.4.3",
"jsdom": "^23.0.0",
"@types/react": "^18.2.50",
"@types/react-dom": "^18.2.17",
"typescript": "^5.3.3",

View File

@@ -9,9 +9,15 @@ import InventoryPage from "./pages/Inventory";
import ItemExplorerPage from "./pages/ItemExplorer";
import RecipesPage from "./pages/Recipes";
import Footer from "./components/Footer";
import { inventoryColor, explorerColor, recipesColor } from "./constants/colors";
import MobileNav from "./components/MobileNav";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
export default function App() {
const [page, setPage] = useState(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { data: metadata } = useQuery({
queryKey: ["metadata"],
@@ -29,8 +35,9 @@ export default function App() {
MOG SQUIRE
</Typography>
</Box>
{(() => {
const tabColors = ['#66bb6a', '#42a5f5', '#ffa726'];
{!isMobile && (() => {
const tabColors = [inventoryColor, explorerColor, recipesColor];
const tabLabels = ['Inventory','Item Explorer','Recipes'];
return (
<Tabs
value={page}
@@ -38,17 +45,20 @@ export default function App() {
centered
TabIndicatorProps={{ sx: { backgroundColor: tabColors[page] } }}
>
{['Inventory','Item Explorer','Recipes'].map((label, idx) => (
<Tab key={label} label={label} sx={{
color: tabColors[idx],
'&.Mui-selected': { color: tabColors[idx] }
}} />
{tabLabels.map((label, idx) => (
<Tab
key={label}
label={label}
sx={{ color: tabColors[idx], '&.Mui-selected': { color: tabColors[idx] } }}
/>
))}
</Tabs>
);
})()}
{page === 0 && metadata && (
{/* Main content */}
<Box sx={{ pb: isMobile ? 7 : 0 }}> {/* bottom padding for nav */}
{page === 0 && metadata && (
<InventoryPage storageTypes={metadata.storage_types} />
)}
{page === 1 && metadata && (
@@ -58,7 +68,9 @@ export default function App() {
<RecipesPage crafts={["woodworking", "smithing", "alchemy", "bonecraft", "goldsmithing", "clothcraft", "leathercraft", "cooking"]} />
)}
</Box>
<Footer />
</Box>
<Footer />
{isMobile && <MobileNav value={page} onChange={setPage} />}
</Box>
);
}

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { filterExplorerItems, RawItem } from '../utils/filterExplorerItems';
const sampleData: RawItem[] = [
{ id: 1, name: 'Bronze Sword', type_description: 'WEAPON' },
{ id: 2, name: 'Leather Armor', type_description: 'ARMOR' },
{ id: 3, name: 'Antidote', type_description: 'USABLE_ITEM' },
{ id: 4, name: 'Mystery', type_description: undefined },
];
const baseTypes = ['WEAPON', 'ARMOR'];
describe('filterExplorerItems', () => {
it('excludes baseTypes while on MISC tab with empty search', () => {
const result = filterExplorerItems(sampleData, 'MISC', baseTypes, '');
const names = result?.map((g) => g.name);
expect(names).toEqual(['Antidote', 'Mystery']);
});
it('includes all items when a search term is present', () => {
const result = filterExplorerItems(sampleData, 'MISC', baseTypes, 'ant');
const names = result?.map((g) => g.name);
expect(names).toEqual(['Bronze Sword', 'Leather Armor', 'Antidote', 'Mystery']);
});
});

View File

@@ -3,3 +3,12 @@ import axios from "axios";
export const api = axios.create({
baseURL: "/api",
});
export async function importInventoryCsv(file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await api.post("/inventory/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data as { imported: number };
}

View File

@@ -1,4 +1,6 @@
import Drawer from "@mui/material/Drawer";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
@@ -7,6 +9,7 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api";
import CircularProgress from "@mui/material/CircularProgress";
import { craftColors } from "../constants/colors";
export interface ItemSummary {
id: number;
@@ -14,7 +17,7 @@ export interface ItemSummary {
}
interface Props {
open: boolean;
item: (ItemSummary & { storages?: string[]; storage_type?: string }) | null;
item: (ItemSummary & { storages?: string[]; storage_type?: string; jobs_description?: string[] }) | null;
onClose: () => void;
}
@@ -56,7 +59,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
});
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<Box sx={{ width: 360, p: 3 }}>
{isLoading || !data ? (
<CircularProgress />
@@ -84,6 +87,12 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</IconButton>
</Box>
<Divider sx={{ mb: 1 }} />
{/* Jobs for armor or other items with jobs_description */}
{item?.jobs_description && item.jobs_description.length > 0 && (
<Typography variant="subtitle2" color="primary" gutterBottom>
Jobs: {item.jobs_description.includes('ALL') ? 'All Jobs' : item.jobs_description.join(', ')}
</Typography>
)}
{data?.type_description === 'SCROLL' && data.description && (
(() => {
const m = data.description.match(/Lv\.?\s*(\d+)\s*([A-Z/]+)?/i);
@@ -129,7 +138,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
) : (
<Typography variant="body2">No description.</Typography>
)}
{usage && (
{usage && (usage.crafted.length > 0 || usage.ingredient.length > 0) && (
<>
<Divider sx={{ mt: 2, mb: 1 }} />
<Typography variant="h6" gutterBottom>
@@ -161,7 +170,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</Typography>
{crafts.map(craft => (
<Box key={craft} sx={{ ml: 1, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: craftColors[craft] || 'text.primary' }}>
{craft.charAt(0).toUpperCase() + craft.slice(1)}
</Typography>
<ul style={{ margin: 0, paddingLeft: 16 }}>
@@ -202,7 +211,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</Typography>
{crafts.map(craft => (
<Box key={craft} sx={{ ml: 1, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: craftColors[craft] || 'text.primary' }}>
{craft.charAt(0).toUpperCase() + craft.slice(1)}
</Typography>
<ul style={{ margin: 0, paddingLeft: 16 }}>
@@ -222,6 +231,6 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</>
)}
</Box>
</Drawer>
</Dialog>
);
}

View File

@@ -10,6 +10,7 @@ export interface GridItem {
iconUrl?: string;
icon_id?: string;
quantity?: number;
jobs_description?: string[];
storages?: string[];
storage_type?: string;
isScroll?: boolean;
@@ -24,16 +25,16 @@ interface Props {
}
const IconImg = styled("img")(({ theme }) => ({
width: 32,
height: 32,
width: 40,
height: 40,
[theme.breakpoints.up("sm")]: {
width: 40,
height: 40,
},
[theme.breakpoints.up("md")]: {
width: 48,
height: 48,
},
[theme.breakpoints.up("md")]: {
width: 80,
height: 80,
},
}));
export default function ItemsGrid({ items, onSelect, loading, duplicates, selectedId }: Props) {
@@ -45,15 +46,15 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
display: "grid",
gridTemplateColumns: { xs: 'repeat(4, 1fr)', sm: 'repeat(6, 1fr)', md: 'repeat(8, 1fr)', lg: 'repeat(10, 1fr)' },
gap: 1,
p: 2,
p: 1.5,
}}
>
{cells.map((item, idx) => (
<Box
key={item ? item.id : idx}
key={`${item ? item.id : 'placeholder'}-${idx}`}
sx={{
width: { xs: 40, sm: 48, md: 56 },
height: { xs: 40, sm: 48, md: 56 },
width: { xs: 100, sm: 100, md: 100 },
height: { xs: 100, sm: 100, md: 100 },
borderRadius: 1,
bgcolor: "background.paper",
display: "flex",
@@ -67,7 +68,7 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
boxShadow: 'inset 2px 2px 3px rgba(255,255,255,0.2), inset -2px -2px 3px rgba(0,0,0,0.8)',
}
: {}),
border: duplicates && item && duplicates.has(item.name) ? '2px solid #ffb74d' : 'none',
borderTop: duplicates && item && duplicates.has(item.name) ? '4px solid #ffb74d' : 'none',
'&:hover': { boxShadow: item ? '0 0 6px 2px rgba(255,255,255,0.9), inset 1px 1px 2px rgba(255,255,255,0.1), inset -1px -1px 2px rgba(0,0,0,0.6)' : undefined },
}}
onClick={() => item && onSelect(item)}
@@ -76,7 +77,7 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
{loading || !item ? (
<Skeleton variant="rectangular" sx={{ width: { xs: 32, sm: 40, md: 48 }, height: { xs: 32, sm: 40, md: 48 } }} />
<Skeleton variant="rectangular" sx={{ width: { xs: 40, sm: 48, md: 56 }, height: { xs: 40, sm: 48, md: 56 } }} />
) : item.iconUrl ? (
<Tooltip
title={`${item.name}${item.quantity && item.quantity > 1 ? ` x${item.quantity}` : ""}`}

View File

@@ -0,0 +1,30 @@
import BottomNavigation from "@mui/material/BottomNavigation";
import BottomNavigationAction from "@mui/material/BottomNavigationAction";
import Paper from "@mui/material/Paper";
import InventoryIcon from "@mui/icons-material/Inventory2";
import SearchIcon from "@mui/icons-material/Search";
import RestaurantIcon from "@mui/icons-material/Restaurant";
interface Props {
value: number;
onChange: (value: number) => void;
}
export default function MobileNav({ value, onChange }: Props) {
return (
<Paper
sx={{ position: "fixed", bottom: 0, left: 0, right: 0 }}
elevation={3}
>
<BottomNavigation
value={value}
onChange={(_, v) => onChange(v)}
showLabels
>
<BottomNavigationAction label="Inventory" icon={<InventoryIcon />} />
<BottomNavigationAction label="Explore" icon={<SearchIcon />} />
<BottomNavigationAction label="Recipes" icon={<RestaurantIcon />} />
</BottomNavigation>
</Paper>
);
}

View File

@@ -0,0 +1,27 @@
// Central accent colour palette used across navigation tabs.
// Keep colours distinct and readable on dark background.
export const inventoryColor = '#66bb6a';
export const explorerColor = '#42a5f5';
export const recipesColor = '#ffa726';
export const craftColors: Record<string, string> = {
woodworking: '#ad7e50', // Woodworking warm earthy brown
smithing: '#c94f4f', // Smithing ember red
alchemy: '#b672e8', // Alchemy mystical purple
bonecraft: '#F5F5DC', // Bonecraft off-white
goldsmithing: '#FFD700',// Goldsmithing gold
clothcraft: '#E6C9C9', // Clothcraft soft beige
leathercraft: '#A0522D',// Leathercraft rich tan
cooking: '#E25822', // Cooking orange-red
};
export const accentColors = [
'#ef5350', // red
'#ab47bc', // purple
'#5c6bc0', // indigo
'#29b6f6', // light blue
'#66bb6a', // green
'#ffca28', // amber
'#ffa726', // orange
'#8d6e63', // brown
];

View File

@@ -1,10 +1,16 @@
import { useState, useEffect, useMemo, useRef } from "react";
import React, { useState, useEffect, useMemo, useRef } from "react";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import InputAdornment from "@mui/material/InputAdornment";
import SearchIcon from "@mui/icons-material/Search";
import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useQuery } from "@tanstack/react-query";
import useDebounce from "../hooks/useDebounce";
@@ -17,6 +23,12 @@ import mountIcon from "../icons/mount.png";
import keyIcon from "../icons/key.png";
import noIcon from "../icons/no-icon.png";
import blankIcon from "../icons/blank.png";
import { inventoryColor } from "../constants/colors";
import Button from "@mui/material/Button";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { importInventoryCsv } from "../api";
import Snackbar from "@mui/material/Snackbar";
interface Props {
storageTypes: string[];
@@ -25,6 +37,7 @@ interface Props {
export default function InventoryPage({ storageTypes, character = "Rynore" }: Props) {
const [tab, setTab] = useState<number>(0);
const [sortKey, setSortKey] = useState<'slot' | 'name' | 'type'>('slot');
const prevTabRef = useRef<number>(0);
// Adjust default selection to "Inventory" once storageTypes are available
@@ -98,28 +111,54 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
);
}, [allInv]);
const filtered = (tab === searchTabIndex ? allInv : data)?.filter(d => debouncedSearch ? d.item_name.toLowerCase().includes(debouncedSearch.toLowerCase()) : true);
const baseRows = (tab === searchTabIndex ? allInv : data) ?? [];
const filtered = baseRows.filter(d => debouncedSearch ? d.item_name.toLowerCase().includes(debouncedSearch.toLowerCase()) : true);
const sortedRows = useMemo(()=>{
if(sortKey==='slot') return filtered;
const copy = [...filtered];
if(sortKey==='name') copy.sort((a,b)=>a.item_name.localeCompare(b.item_name));
else if(sortKey==='type') copy.sort((a,b)=>((a as any).type_description ?? '').localeCompare(((b as any).type_description ?? '')) || a.item_name.localeCompare(b.item_name));
return copy;
}, [filtered, sortKey]);
let tempId = -1;
let gridItems: GridItem[] = (filtered ?? []).map((d: any) => ({
isScroll: d.type_description === 'SCROLL',
storages: duplicates.has(d.item_name) ? Array.from((allInv?.filter(i=>i.item_name===d.item_name).map(i=>i.storage_type.toUpperCase())??[])) : undefined,
id: d.id ?? tempId--,
storage_type: d.storage_type,
name: d.item_name,
quantity: 'quantity' in d ? d.quantity : undefined,
// Determine icon URL with prioritized fallback logic when missing icon_id
iconUrl: (d as any).icon_id
? `/api/icon/${d.icon_id}`
: (() => {
const nameLower = d.item_name.toLowerCase();
if (nameLower.includes("map")) return mapIcon;
if (d.item_name.includes("♪")) return mountIcon;
if ((d.type_description ?? "").toUpperCase().includes("KEY")) return keyIcon;
return noIcon;
})(),
icon_id: d.icon_id,
}));
const seenIds = new Set<number>();
let gridItems: GridItem[] = [];
for (const row of sortedRows) {
const realId: number | undefined = (row as any).id;
if (realId !== undefined) {
if (seenIds.has(realId)) {
continue; // skip duplicates
}
seenIds.add(realId);
}
const effectiveId = realId ?? tempId--;
gridItems.push({
isScroll: (row as any).type_description === 'SCROLL',
storages: duplicates.has(row.item_name)
? Array.from(
(allInv?.filter(i => i.item_name === row.item_name).map(i => i.storage_type.toUpperCase()) ?? [])
)
: undefined,
id: effectiveId,
storage_type: (row as any).storage_type,
name: row.item_name,
quantity: 'quantity' in row ? (row as any).quantity : undefined,
iconUrl: (row as any).icon_id
? `/api/icon/${(row as any).icon_id}`
: (() => {
const nameLower = row.item_name.toLowerCase();
if (nameLower.includes('map')) return mapIcon;
if (row.item_name.includes('♪')) return mountIcon;
if (((row as any).type_description ?? '').toUpperCase().includes('KEY')) return keyIcon;
return noIcon;
})(),
icon_id: (row as any).icon_id,
});
}
// Pad to 80 slots with empty placeholders (skip for Search tab)
if (tab !== searchTabIndex && gridItems.length < 80) {
@@ -133,28 +172,93 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
}
}
const queryClient = useQueryClient();
const importMutation = useMutation({
mutationFn: importInventoryCsv,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
setSnack({ open: true, message: "Inventory imported!" });
},
onError: () => {
setSnack({ open: true, message: "Failed to import CSV" });
},
});
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [snack, setSnack] = useState<{ open: boolean; message: string }>({
open: false,
message: "",
});
const theme = useTheme();
const isNarrow = useMediaQuery(theme.breakpoints.down('md'));
return (
<Box sx={{ px: 2 }}>
<Box sx={{ display:'flex', alignItems:'center', gap:2 }}>
{(() => {
const colors = ['#ef5350','#ab47bc','#5c6bc0','#29b6f6','#66bb6a','#ffca28','#ffa726','#8d6e63'];
const getColor = (idx:number)=>colors[idx%colors.length];
<Box sx={{ display:'flex', alignItems:'center', gap:2, flexWrap: 'wrap' }}>
{isNarrow ? (
<FormControl size="small" sx={{ minWidth:120 }}>
<Select
value={tab}
onChange={(e)=>setTab(e.target.value as number)}
>
{storageTypes.map((s,idx)=> (
<MenuItem key={s} value={idx}>{s.toUpperCase()}</MenuItem>
))}
{searchMode && <MenuItem value={searchTabIndex}>Search</MenuItem>}
</Select>
</FormControl>
) : (() => {
const getColor = () => inventoryColor;
return (
<Tabs
value={tab}
onChange={(_,v)=>setTab(v)}
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(tab)} }}
TabIndicatorProps={{ sx:{ backgroundColor:inventoryColor } }}
sx={{ bgcolor:'background.paper' }}
>
{storageTypes.map((s,idx)=>(
<Tab key={s} label={s} sx={{ color:getColor(idx),'&.Mui-selected':{color:getColor(idx)} }} />
<Tab key={s} label={s} sx={{ color:inventoryColor,'&.Mui-selected':{color:inventoryColor} }} />
))}
{searchMode && <Tab label="Search" sx={{ color:'#90a4ae','&.Mui-selected':{color:'#90a4ae'} }} />}
</Tabs>
);
})()}
<TextField
{/* Upload CSV Button */}
<input
type="file"
accept=".csv,text/csv"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
}}
/>
<Button
size="small"
variant="contained"
startIcon={<UploadFileIcon />}
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
sx={{ bgcolor: inventoryColor, '&:hover':{ bgcolor: inventoryColor }, whiteSpace:'nowrap' }}
>
{importMutation.isPending ? "Uploading…" : "Import CSV"}
</Button>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={sortKey}
onChange={(e)=>setSortKey(e.target.value as any)}
>
<MenuItem value="slot">Slot</MenuItem>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="type">Type</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
variant="outlined"
placeholder="Search…"
@@ -187,6 +291,13 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
item={selected}
onClose={() => setSelected(null)}
/>
<Snackbar
open={snack.open}
autoHideDuration={3000}
onClose={() => setSnack({ ...snack, open: false })}
message={snack.message}
/>
</Box>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from "react";
import { RawItem } from "../utils/filterExplorerItems";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import InputAdornment from "@mui/material/InputAdornment";
@@ -7,6 +8,7 @@ import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Pagination from "@mui/material/Pagination";
import { explorerColor } from "../constants/colors";
import { useQuery } from "@tanstack/react-query";
import useDebounce from "../hooks/useDebounce";
import ItemsGrid, { GridItem } from "../components/ItemsGrid";
@@ -20,6 +22,7 @@ interface Props {
export default function ItemExplorerPage({ typeDescriptions }: Props) {
const [tab, setTab] = useState(0);
const [subTab, setSubTab] = useState(0);
const [selectedJobs, setSelectedJobs] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [search, setSearch] = useState("");
@@ -52,15 +55,24 @@ export default function ItemExplorerPage({ typeDescriptions }: Props) {
const currentSubType =
currentType === "USABLE_ITEM" ? subTypeValues[subTab] : currentType;
const armorJobs = useMemo(
() =>
"WAR,MNK,WHM,BLM,RDM,THF,PLD,DRK,BST,BRD,RNG,SAM,NIN,DRG,SMN,BLU,COR,PUP,DNC,SCH,GEO,RUN".split(
","
),
[]
);
// Reset pagination when type changes
useEffect(() => {
setPage(1);
setTotalPages(1);
setSearch("");
setSelectedJobs([]);
}, [currentType, currentSubType]);
const { data, isLoading } = useQuery({
queryKey: ["items", currentType === "MISC" ? "MISC" : currentSubType, page, debouncedSearch],
queryKey: ["items", currentType === "MISC" ? "MISC" : currentSubType, page, debouncedSearch, selectedJobs],
queryFn: async () => {
let url = `/items?page=${page}&page_size=100`;
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
@@ -68,45 +80,92 @@ export default function ItemExplorerPage({ typeDescriptions }: Props) {
url = `/items?type=${encodeURIComponent(currentSubType)}&page=${page}&page_size=100`;
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
}
// Add jobs to the query if we're on the ARMOR tab and jobs are selected
if (currentType === 'ARMOR' && selectedJobs.length > 0) {
const jobsParam = selectedJobs.join(',');
url += `&jobs=${jobsParam}`;
}
const response = await api.get(url);
const totalCountHeader = response.headers['x-total-count'] ?? response.headers['X-Total-Count'];
if (totalCountHeader) {
const total = parseInt(totalCountHeader, 10);
if (!isNaN(total)) {
setTotalPages(Math.max(1, Math.ceil(total / 100)));
}
setTotalPages(Math.ceil(total / 100));
}
return response.data as { id: number; name: string; icon_id?: string; type_description?: string }[];
return response.data;
},
enabled: !!currentType,
});
const gridItems: GridItem[] | undefined = data
?.filter((d) => d.name !== '.' && (currentType !== 'MISC' ? true : !baseTypes.includes(d.type_description ?? '')))
?.filter((d: RawItem) => {
if (d.name === '.') return false;
// When the user is actively searching, don't hide any server results let the backend decide.
if (debouncedSearch.trim() !== '') return true;
// Otherwise, apply the MISC filter rules as before.
if (currentType !== 'MISC') return true;
return !baseTypes.includes(d.type_description ?? '');
})
// Apply client-side filtering for jobs_description if we're on the ARMOR tab
.filter((d: RawItem) => {
if (currentType !== 'ARMOR' || selectedJobs.length === 0) return true;
// Debug: Log the item being filtered when jobs are selected
if (selectedJobs.length > 0) {
console.log('Filtering item:', d.name, 'jobs_description:', d.jobs_description);
}
// If the item has a jobs_description property, check if any selected job is in the array
// or if it contains 'ALL'
if (d.jobs_description) {
const jobsList = Array.isArray(d.jobs_description) ? d.jobs_description : [];
const shouldInclude = jobsList.includes('ALL') || selectedJobs.some(job => jobsList.includes(job));
if (selectedJobs.length > 0) {
console.log(`Item ${d.name}: ${shouldInclude ? 'INCLUDED' : 'EXCLUDED'}, matches: ${selectedJobs.filter(job => jobsList.includes(job))}`);
}
return shouldInclude;
}
// If no jobs_description property exists, we'll still show the item when no specific filter is selected
return true;
})
.map((d) => ({
id: d.id,
name: d.name,
iconUrl: d.icon_id ? `/api/icon/${d.icon_id}` : undefined,
icon_id: d.icon_id,
}));
id: d.id,
name: d.name,
iconUrl: d.icon_id ? `/api/icon/${d.icon_id}` : undefined,
icon_id: d.icon_id,
jobs_description: d.jobs_description,
}));
return (
<Box sx={{ px: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{(() => {
const colors = ['#ff7043','#42a5f5','#66bb6a','#ab47bc','#5c6bc0','#ffa726'];
const getColor=(idx:number)=>colors[idx%colors.length];
const getColor = () => explorerColor;
return (
<Tabs
value={tab}
onChange={(_,v)=>{ setTab(v); setPage(1);} }
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(tab)} }}
TabIndicatorProps={{ sx:{ backgroundColor:explorerColor } }}
sx={{ flexGrow:1 }}
>
{displayTypes.map((t,idx)=>{
const label = t.replace('_ITEM','');
return <Tab key={t} label={label} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
return <Tab key={t} label={label} sx={{ color:explorerColor, '&.Mui-selected':{color:explorerColor} }} />
})}
</Tabs>
);
@@ -127,23 +186,74 @@ export default function ItemExplorerPage({ typeDescriptions }: Props) {
/>
</Box>
{/* Job filters for Armor items */}
{currentType === "ARMOR" && (
<Box sx={{ mt: 1, p: 1, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Box
onClick={() => setSelectedJobs([])}
sx={{
cursor: 'pointer',
py: 0.5,
px: 1,
borderRadius: 1,
bgcolor: selectedJobs.length === 0 ? `${explorerColor}22` : 'transparent',
border: `1px solid ${explorerColor}`,
color: explorerColor,
'&:hover': {
bgcolor: `${explorerColor}11`,
},
}}
>
ALL
</Box>
{armorJobs.map((jobCode) => (
<Box
key={jobCode}
onClick={() => {
if (selectedJobs.includes(jobCode)) {
setSelectedJobs(selectedJobs.filter((j) => j !== jobCode));
} else {
setSelectedJobs([...selectedJobs, jobCode]);
}
}}
sx={{
cursor: 'pointer',
py: 0.5,
px: 1,
borderRadius: 1,
bgcolor: selectedJobs.includes(jobCode) ? `${explorerColor}22` : 'transparent',
border: `1px solid ${explorerColor}`,
color: explorerColor,
'&:hover': {
bgcolor: `${explorerColor}11`,
},
}}
>
{jobCode}
</Box>
))}
</Box>
</Box>
)}
{/* Sub-tabs for usable types */}
{currentType === "USABLE_ITEM" && (
<Box>
{(() => {
const colors = ['#ef5350','#ab47bc','#5c6bc0','#29b6f6','#66bb6a','#ffca28','#ffa726','#8d6e63'];
const getColor=(idx:number)=>colors[idx%colors.length];
const getColor = () => explorerColor;
return (
<Tabs
value={subTab}
onChange={(_,v)=>setSubTab(v)}
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(subTab)} }}
TabIndicatorProps={{ sx:{ backgroundColor:explorerColor } }}
sx={{ bgcolor:'background.paper', mt:1 }}
>
{subTypeValues.map((s,idx)=>{
const label = s === 'ITEM' ? 'MISC' : s.replace('_ITEM','');
return <Tab key={s} label={label} sx={{ color:getColor(idx),'&.Mui-selected':{color:getColor(idx)} }} />
return <Tab key={s} label={label} sx={{ color:explorerColor,'&.Mui-selected':{color:explorerColor} }} />
})}
</Tabs>
);

View File

@@ -5,6 +5,7 @@ import Tab from "@mui/material/Tab";
import CircularProgress from "@mui/material/CircularProgress";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import ToggleButton from "@mui/material/ToggleButton";
import { recipesColor, craftColors } from "../constants/colors";
import { useQuery } from "@tanstack/react-query";
import RecipesDetailTable from "../components/RecipesDetailTable";
import { api } from "../api";
@@ -114,17 +115,15 @@ export default function RecipesPage({ crafts }: Props) {
<Box>
{/* Craft tabs */}
{(() => {
const colors = ['#66bb6a','#42a5f5','#ffa726','#ab47bc','#5c6bc0','#ff7043','#8d6e63'];
const getColor=(idx:number)=>colors[idx%colors.length];
return (
<Tabs
value={craftIdx}
onChange={(_,v)=>{setCraftIdx(v); setCatIdx(0);} }
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(craftIdx)} }}
TabIndicatorProps={{ sx:{ backgroundColor: craftColors[currentCraft] || recipesColor } }}
>
{crafts.map((c,idx)=>(
<Tab key={c} label={c} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
<Tab key={c} label={c} sx={{ color: craftColors[c] || recipesColor, '&.Mui-selected':{color: craftColors[c] || recipesColor} }} />
))}
</Tabs>
);
@@ -145,18 +144,16 @@ export default function RecipesPage({ crafts }: Props) {
{/* Category sub-tabs */}
{(() => {
const colors = ['#ef5350','#ab47bc','#5c6bc0','#29b6f6','#66bb6a','#ffca28','#ffa726','#8d6e63'];
const getColor=(idx:number)=>colors[idx%colors.length];
return (
<Tabs
value={catIdx}
onChange={(_,v)=>setCatIdx(v)}
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(catIdx)} }}
TabIndicatorProps={{ sx:{ backgroundColor:craftColors[currentCraft] || recipesColor } }}
sx={{ bgcolor:'background.paper' }}
>
{categories.map((cat,idx)=>(
<Tab key={cat} label={cat} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
<Tab key={cat} label={cat} sx={{ color:craftColors[currentCraft] || recipesColor, '&.Mui-selected':{color:craftColors[currentCraft] || recipesColor} }} />
))}
</Tabs>
);

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,39 @@
import type { GridItem } from '../components/ItemsGrid';
export interface RawItem {
id: number;
name: string;
icon_id?: string;
type_description?: string;
jobs_description?: string[];
}
/**
* Replicates client-side filtering logic in ItemExplorerPage.
* @param data list returned from `/items` API
* @param currentType current selected high-level type (e.g. "MISC", "USABLE_ITEM")
* @param baseTypes array of base types derived from metadata
* @param search current (debounced) search string
*/
export function filterExplorerItems(
data: RawItem[] | undefined,
currentType: string,
baseTypes: string[],
search: string
): GridItem[] | undefined {
if (!data) return undefined;
return data
.filter((d) => {
if (d.name === '.') return false;
if (search.trim() !== '') return true; // no extra filter while searching
if (currentType !== 'MISC') return true;
return !baseTypes.includes(d.type_description ?? '');
})
.map((d) => ({
id: d.id,
name: d.name,
iconUrl: d.icon_id ? `/api/icon/${d.icon_id}` : undefined,
icon_id: d.icon_id,
}));
}

View File

@@ -14,7 +14,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"types": ["vitest/globals"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

11
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
globals: true,
},
});

View File

@@ -13,7 +13,8 @@ PostgreSQL database.
| **recipes_to_csv_v2.py** | Generic parser. `python recipes_to_csv_v2.py <Craft>` processes one craft; use `python recipes_to_csv_v2.py --all` **or simply omit the argument** to parse every `.txt` file under `datasets/`, producing `datasets/<Craft>_v2.csv` for each. |
| **load_woodworking_to_db.py** | Loader for the legacy CSV (kept for reference). |
| **load_woodworking_v2_to_db.py** | Drops & recreates **recipes_woodworking** table and bulk-loads `Woodworking_v2.csv`. |
| **load_recipes_v2_to_db.py** | Generic loader. `python load_recipes_v2_to_db.py <Craft>` loads one craft; omit the argument to load **all** generated CSVs into their respective `recipes_<craft>` tables. |
| **load_recipes_v2_to_db.py** | Generic loader.
| **load_inventory_to_db.py** | Truncate & load `datasets/inventory.csv` into the `inventory` table. | `python load_recipes_v2_to_db.py <Craft>` loads one craft; omit the argument to load **all** generated CSVs into their respective `recipes_<craft>` tables. |
| **requirements.txt** | Minimal Python dependencies for the scripts. |
| **venv/** | Local virtual-environment created by the setup steps below. |

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Load datasets/inventory.csv into the **inventory** table, replacing any
existing contents.
Usage:
python load_inventory_to_db.py [CSV_PATH]
If ``CSV_PATH`` is omitted the script defaults to ``datasets/inventory.csv``
relative to the project root.
This script is similar in style to the other ETL helpers in ``scripts/``. It is
idempotent it truncates the ``inventory`` table before bulk-inserting the new
rows.
The database connection details are read from the standard ``db.conf`` file
located at the project root. The file must define at least the following keys::
PSQL_HOST
PSQL_PORT
PSQL_USER
PSQL_PASSWORD
PSQL_DBNAME
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import datetime as _dt
import pathlib
import re
from typing import Dict, List, Tuple
import asyncpg
# ---------------------------------------------------------------------------
# Paths & Constants
# ---------------------------------------------------------------------------
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1]
CONF_PATH = PROJECT_ROOT / "db.conf"
DEFAULT_CSV_PATH = PROJECT_ROOT / "datasets" / "inventory.csv"
RE_CONF = re.compile(r"^([A-Z0-9_]+)=(.*)$")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def parse_db_conf(path: pathlib.Path) -> Dict[str, str]:
"""Parse ``db.conf`` (simple KEY=VALUE format) into a dict."""
if not path.exists():
raise FileNotFoundError("db.conf not found at project root required for DB credentials")
conf: Dict[str, str] = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if (m := RE_CONF.match(line)):
key, value = m.group(1), m.group(2).strip().strip("'\"")
conf[key] = value
required = {"PSQL_HOST", "PSQL_PORT", "PSQL_USER", "PSQL_PASSWORD", "PSQL_DBNAME"}
missing = required - conf.keys()
if missing:
raise RuntimeError(f"Missing keys in db.conf: {', '.join(sorted(missing))}")
return conf
async def ensure_inventory_table(conn: asyncpg.Connection) -> None:
"""Create the ``inventory`` table if it doesn't already exist.
The schema mirrors the SQLAlchemy model in ``backend/app/models.py``.
"""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS inventory (
id SERIAL PRIMARY KEY,
character_name TEXT NOT NULL,
storage_type TEXT NOT NULL,
item_name TEXT NOT NULL,
quantity INT NOT NULL,
last_updated TIMESTAMPTZ DEFAULT NOW()
);
"""
)
async def truncate_inventory(conn: asyncpg.Connection) -> None:
"""Remove all rows from the inventory table before re-inserting."""
await conn.execute("TRUNCATE TABLE inventory;")
async def copy_csv_to_db(conn: asyncpg.Connection, rows: List[Tuple[str, str, str, int]]) -> None:
"""Bulk copy the parsed CSV rows into the DB using ``copy_records_to_table``."""
await conn.copy_records_to_table(
"inventory",
records=rows,
columns=[
"character_name",
"storage_type",
"item_name",
"quantity",
"last_updated",
],
)
# ---------------------------------------------------------------------------
# Main logic
# ---------------------------------------------------------------------------
async def load_inventory(csv_path: pathlib.Path) -> None:
if not csv_path.exists():
raise SystemExit(f"CSV file not found: {csv_path}")
conf = parse_db_conf(CONF_PATH)
conn = await asyncpg.connect(
host=conf["PSQL_HOST"],
port=int(conf["PSQL_PORT"]),
user=conf["PSQL_USER"],
password=conf["PSQL_PASSWORD"],
database=conf["PSQL_DBNAME"],
)
try:
await ensure_inventory_table(conn)
await truncate_inventory(conn)
# Parse CSV
rows: List[Tuple[str, str, str, int]] = []
with csv_path.open(newline="", encoding="utf-8") as f:
reader = csv.DictReader(f, delimiter=";", quotechar='"')
for r in reader:
char = r["char"].strip()
storage = r["storage"].strip()
item = r["item"].strip()
qty = int(r["quantity"].strip()) if r["quantity"].strip() else 0
rows.append((char, storage, item, qty, _dt.datetime.utcnow()))
await copy_csv_to_db(conn, rows)
print(f"Inserted {len(rows)} inventory rows.")
finally:
await conn.close()
async def main_async(csv_arg: str | None) -> None:
csv_path = pathlib.Path(csv_arg).expanduser().resolve() if csv_arg else DEFAULT_CSV_PATH
await load_inventory(csv_path)
def main() -> None:
p = argparse.ArgumentParser(description="Load inventory CSV into DB")
p.add_argument("csv", nargs="?", help="Path to CSV; defaults to datasets/inventory.csv")
args = p.parse_args()
asyncio.run(main_async(args.csv))
if __name__ == "__main__":
main()