Lots of stuff
This commit is contained in:
12
README.md
12
README.md
@@ -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
|
||||
|
||||
|
||||
4
TODO.md
4
TODO.md
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
967
datasets/inventory.csv
Executable 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.
|
@@ -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
16
frontend/Dockerfile
Normal 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
22
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
2365
frontend/package-lock.json
generated
2365
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
25
frontend/src/__tests__/filterExplorerItems.test.ts
Normal file
25
frontend/src/__tests__/filterExplorerItems.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}` : ""}`}
|
||||
|
||||
30
frontend/src/components/MobileNav.tsx
Normal file
30
frontend/src/components/MobileNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/constants/colors.ts
Normal file
27
frontend/src/constants/colors.ts
Normal 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
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
1
frontend/src/setupTests.ts
Normal file
1
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
39
frontend/src/utils/filterExplorerItems.ts
Normal file
39
frontend/src/utils/filterExplorerItems.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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
11
frontend/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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. |
|
||||
|
||||
|
||||
163
scripts/load_inventory_to_db.py
Normal file
163
scripts/load_inventory_to_db.py
Normal 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()
|
||||
Reference in New Issue
Block a user