Desynth page and improved item info api. Added string substitution to utils.

This commit is contained in:
Aodhan
2025-07-10 03:20:33 +01:00
parent b9b47c96f6
commit ef9b64adfe
38 changed files with 5703 additions and 4489 deletions

View File

@@ -8,8 +8,9 @@ import Typography from "@mui/material/Typography";
import InventoryPage from "./pages/Inventory";
import ItemExplorerPage from "./pages/ItemExplorer";
import RecipesPage from "./pages/Recipes";
import DesynthRecipesPage from "./pages/DesynthRecipes";
import Footer from "./components/Footer";
import { inventoryColor, explorerColor, recipesColor } from "./constants/colors";
import { inventoryColor, explorerColor, recipesColor, desynthColor } from "./constants/colors";
import MobileNav from "./components/MobileNav";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
@@ -35,21 +36,23 @@ export default function App() {
MOG SQUIRE
</Typography>
</Box>
{!isMobile && (() => {
const tabColors = [inventoryColor, explorerColor, recipesColor];
const tabLabels = ['Inventory','Item Explorer','Recipes'];
{(() => {
const tabColors = [inventoryColor, explorerColor, recipesColor, desynthColor];
const tabLabels = ['Inventory','Item Explorer','Recipes','Desynth'];
return (
<Tabs
value={page}
onChange={(_, v) => setPage(v)}
centered
variant={isMobile ? 'fullWidth' : 'standard'}
centered={!isMobile}
sx={isMobile ? { minHeight: 32 } : {}}
TabIndicatorProps={{ sx: { backgroundColor: tabColors[page] } }}
>
{tabLabels.map((label, idx) => (
<Tab
key={label}
label={label}
sx={{ color: tabColors[idx], '&.Mui-selected': { color: tabColors[idx] } }}
sx={{ color: tabColors[idx], '&.Mui-selected': { color: tabColors[idx] }, minHeight: isMobile ? 32 : 48, fontSize: isMobile ? '0.7rem' : '0.875rem' }}
/>
))}
</Tabs>
@@ -67,6 +70,9 @@ export default function App() {
{page === 2 && metadata && (
<RecipesPage crafts={["woodworking", "smithing", "alchemy", "bonecraft", "goldsmithing", "clothcraft", "leathercraft", "cooking"]} />
)}
{page === 3 && (
<DesynthRecipesPage />
)}
</Box>
</Box>
<Footer />

View File

@@ -0,0 +1,130 @@
import React from "react";
import Tooltip from "@mui/material/Tooltip";
import { useQuery } from "@tanstack/react-query";
import { applySubstitutions } from "../utils/nameSubstitutions";
import { api } from "../api";
export interface OwnedInfo {
qty: number;
storages: string[];
icon?: string;
description?: string;
}
interface ItemDisplayProps {
name: string;
/** Overrides text shown (defaults to `name`) */
displayName?: string;
/** Quantity requirement to colour-code */
qty?: number;
/** Map of owned items keyed by item name */
ownedInfo?: Record<string, OwnedInfo>;
/** Disable colour highlighting */
noHighlight?: boolean;
/** Extra label shown in tooltip when description missing (crystal element, etc.) */
label?: string;
/** Direct item-id look-up instead of name search */
itemId?: number;
/** When true, skip automatic removal of the word "Crystal" */
exactName?: boolean;
}
export default function ItemDisplay({
name,
displayName,
qty,
ownedInfo,
noHighlight = false,
label,
itemId,
exactName = false,
}: ItemDisplayProps) {
/* ------------------------------------------------------------------ */
/* 1. Local inventory lookup */
/* ------------------------------------------------------------------ */
const local = ownedInfo?.[name];
const ownedQty = local?.qty ?? 0;
/* ------------------------------------------------------------------ */
/* 2. Clean search key */
/* ------------------------------------------------------------------ */
const baseNameRaw = exactName
? name.replace(/ x\d+$/i, "").trim()
: name
.replace(/ x\d+$/i, "") // strip qty suffix e.g. "Iron Ingot x2"
.replace(/\s+Crystal$/i, "") // strip the word "Crystal"
.trim();
const searchName = applySubstitutions(baseNameRaw);
/* ------------------------------------------------------------------ */
/* 3. Remote meta fetch (if not locally available) */
/* ------------------------------------------------------------------ */
const { data } = useQuery<{ icon_id?: string; icon_b64?: string; description?: string } | null>({
queryKey: ["item-meta", itemId ?? searchName],
queryFn: async () => {
// If inventory already has description/icon, skip remote call
if (local?.icon || local?.description) return null;
try {
const res = await api.get(
itemId ? `/items/${itemId}` : `/items/by-name/${encodeURIComponent(searchName)}`
);
return res.data as { icon_id?: string; icon_b64?: string; description?: string };
} catch {
return null;
}
},
staleTime: 1_800_000, // 30m cache
});
/* ------------------------------------------------------------------ */
/* 4. Icon & tooltip */
/* ------------------------------------------------------------------ */
const icon =
local?.icon ||
(data?.icon_b64
? `data:image/png;base64,${data.icon_b64}`
: data?.icon_id
? `/api/icon/${data.icon_id}`
: undefined);
const desc = local?.description || data?.description;
const locs = local ? ` | ${local.storages.join(", ")}` : "";
const tooltip = `${desc ?? label ?? displayName ?? name}${locs}`;
/* ------------------------------------------------------------------ */
/* 5. Text colour logic */
/* ------------------------------------------------------------------ */
const colorStyle: React.CSSProperties = (() => {
if (noHighlight) return {};
if (qty !== undefined) {
if (ownedQty >= qty && qty > 0) return { color: "green" };
if (ownedQty > 0 && ownedQty < qty) return { color: "yellow" };
}
return {};
})();
const wikiUrl = `https://www.bg-wiki.com/ffxi/${encodeURIComponent(baseNameRaw)}`;
return (
<Tooltip title={tooltip} arrow>
<a
href={wikiUrl}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "none", color: "inherit", ...colorStyle }}
>
{icon && (
<img
src={icon}
alt={searchName}
style={{ width: 16, verticalAlign: "text-bottom", marginRight: 4 }}
/>
)}
{qty !== undefined
? `${displayName ?? label ?? name} x${qty}`
: displayName ?? label ?? name}
</a>
</Tooltip>
);
}

View File

@@ -10,6 +10,7 @@ export interface GridItem {
iconUrl?: string;
icon_id?: string;
quantity?: number;
stack_size?: number;
jobs_description?: string[];
storages?: string[];
storage_type?: string;
@@ -25,15 +26,15 @@ interface Props {
}
const IconImg = styled("img")(({ theme }) => ({
width: 40,
height: 40,
width: 32,
height: 32,
[theme.breakpoints.up("sm")]: {
width: 48,
height: 48,
width: 40,
height: 40,
},
[theme.breakpoints.up("md")]: {
width: 80,
height: 80,
width: 64,
height: 64,
},
}));
@@ -44,7 +45,7 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
sx={{
position: 'relative',
display: "grid",
gridTemplateColumns: { xs: 'repeat(4, 1fr)', sm: 'repeat(6, 1fr)', md: 'repeat(8, 1fr)', lg: 'repeat(10, 1fr)' },
gridTemplateColumns: { xs: 'repeat(5, 1fr)', sm: 'repeat(6, 1fr)', md: 'repeat(8, 1fr)', lg: 'repeat(10, 1fr)' },
gap: 1,
p: 1.5,
}}
@@ -53,8 +54,8 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
<Box
key={`${item ? item.id : 'placeholder'}-${idx}`}
sx={{
width: { xs: 100, sm: 100, md: 100 },
height: { xs: 100, sm: 100, md: 100 },
width: { xs: 72, sm: 88, md: 100 },
height: { xs: 72, sm: 88, md: 100 },
borderRadius: 1,
bgcolor: "background.paper",
display: "flex",
@@ -77,7 +78,7 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
{loading || !item ? (
<Skeleton variant="rectangular" sx={{ width: { xs: 40, sm: 48, md: 56 }, height: { xs: 40, sm: 48, md: 56 } }} />
<Skeleton variant="rectangular" sx={{ width: { xs: 32, sm: 40, md: 48 }, height: { xs: 32, sm: 40, md: 48 } }} />
) : item.iconUrl ? (
<Tooltip
title={`${item.name}${item.quantity && item.quantity > 1 ? ` x${item.quantity}` : ""}`}
@@ -85,7 +86,7 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
>
<Badge
badgeContent={item.quantity && item.quantity > 1 ? item.quantity : undefined}
color="secondary"
color={item.quantity && item.stack_size && item.quantity === item.stack_size ? 'info' : 'secondary'}
overlap="circular"
>
<IconImg src={item.iconUrl} alt={item.name} />

View File

@@ -7,23 +7,38 @@ import TableCell from "@mui/material/TableCell";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
import { applySubstitutions } from "../utils/nameSubstitutions";
import type { RecipeDetail } from "../types";
import ItemDisplay from "./ItemDisplay";
interface Props {
recipes: RecipeDetail[] | undefined;
recipes?: RecipeDetail[];
loading?: boolean;
owned?: Set<string>;
ownedInfo?: Record<string, { storages: string[]; icon?: string; description?: string }>;
ownedInfo?: Record<string, { storages: string[]; qty: number; icon?: string; description?: string }>;
}
function ItemDisplay({ name, qty, ownedInfo, owned, label }: { name: string; qty?: number; ownedInfo?: Record<string, { storages: string[]; icon?: string; description?: string }>; owned?: Set<string>; label?: string; }) {
/*
interface ItemDisplayProps {
name: string;
displayName?: string;
qty?: number;
ownedInfo?: Record<string, { storages: string[]; qty: number; icon?: string; description?: string }>;
noHighlight?: boolean;
label?: string;
itemId?: number;
}
function ItemDisplay({ name, displayName, qty, ownedInfo, noHighlight=false, label, itemId }: ItemDisplayProps) {
const local = ownedInfo?.[name];
const { data } = useQuery<{ icon_id?: string; description?: string } | null>({
queryKey: ["item-meta", name],
const ownedQty = local?.qty ?? 0;
const baseName = name;
const searchName = applySubstitutions(baseName);
const queryName = itemId ? undefined : searchName;
const { data } = useQuery<{ icon_id?: string; icon_b64?: string; description?: string } | null>({
queryKey: ["item-meta", itemId ?? searchName],
queryFn: async () => {
if (local) return null;
try {
const res = await fetch(`/api/items/by-name/${encodeURIComponent(name)}`);
const res = await fetch(itemId ? `/api/items/${itemId}` : `/api/items/by-name/${encodeURIComponent(queryName as string)}`);
if (!res.ok) return null;
return (await res.json()) as { icon_id?: string; description?: string };
} catch {
@@ -32,12 +47,22 @@ function ItemDisplay({ name, qty, ownedInfo, owned, label }: { name: string; qty
},
staleTime: 1000 * 60 * 30,
});
const icon = local?.icon || (data?.icon_id ? `/api/icon/${data.icon_id}` : undefined);
const icon = local?.icon || (data?.icon_b64 ? `data:image/png;base64,${data.icon_b64}` : (data?.icon_id ? `/api/icon/${data.icon_id}` : undefined));
const desc = local?.description || data?.description;
const tooltip = `${desc ?? label ?? name}`;
const locs = local ? ` | ${local.storages.join(', ')}` : '';
const tooltip = `${desc ?? label ?? displayName ?? name}${locs}`;
const wikiUrl = `https://www.bg-wiki.com/ffxi/${encodeURIComponent(name)}`;
const colorStyle = (()=>{
if(noHighlight) return {};
if(qty!==undefined){
if(ownedQty>=qty && qty>0) return {color:'green'};
if(ownedQty>0 && ownedQty<qty) return {color:'yellow'};
}
return {};
})();
return (
<Tooltip title={tooltip} arrow>
<span style={owned?.has(name) ? { color: "green" } : {}}>
<a href={wikiUrl} target="_blank" rel="noopener noreferrer" style={{textDecoration:'none', color:'inherit', ...colorStyle}}>
{icon && (
<img
src={icon}
@@ -45,13 +70,14 @@ function ItemDisplay({ name, qty, ownedInfo, owned, label }: { name: string; qty
style={{ width: 16, verticalAlign: "text-bottom", marginRight: 4 }}
/>
)}
{qty !== undefined ? `${label ?? name} x${qty}` : (label ?? name)}
</span>
{qty !== undefined ? `${displayName ?? label ?? name} x${qty}` : (displayName ?? label ?? name)}
</a>
</Tooltip>
);
}
*/
export default function RecipesDetailTable({ recipes, loading, owned, ownedInfo }: Props) {
export default function RecipesDetailTable({ recipes, loading, ownedInfo }: Props) {
if (loading) return <CircularProgress sx={{ m: 2 }} />;
if (!recipes || recipes.length === 0)
return (
@@ -86,12 +112,12 @@ export default function RecipesDetailTable({ recipes, loading, owned, ownedInfo
</TableCell>
<TableCell>{r.level}</TableCell>
<TableCell>
<ItemDisplay name={`${r.crystal} Crystal`} label={r.crystal} ownedInfo={ownedInfo} />
<ItemDisplay name={`${r.crystal} Crystal`} label={r.crystal} ownedInfo={ownedInfo} exactName={true} />
</TableCell>
<TableCell>
{r.ingredients.map(([n, q], idx) => (
<span key={idx} style={owned?.has(n) ? { color: 'green' } : {}}>
<ItemDisplay name={n} qty={q} ownedInfo={ownedInfo} owned={owned} />
<span key={idx}>
<ItemDisplay name={n} qty={q} ownedInfo={ownedInfo} />
{idx < r.ingredients.length - 1 ? ', ' : ''}
</span>
))}
@@ -100,7 +126,7 @@ export default function RecipesDetailTable({ recipes, loading, owned, ownedInfo
<TableCell key={idx}>
{hq ? (() => {
const [n, q] = hq;
return <ItemDisplay name={n} qty={q} ownedInfo={ownedInfo} owned={owned} />
return <ItemDisplay name={n} qty={q} ownedInfo={ownedInfo} noHighlight={true} />
})() : "—"}
</TableCell>
))}

View File

@@ -3,6 +3,7 @@
export const inventoryColor = '#66bb6a';
export const explorerColor = '#42a5f5';
export const recipesColor = '#ffa726';
export const desynthColor = '#26c6da'; // cyan for desynthesis
export const craftColors: Record<string, string> = {
woodworking: '#ad7e50', // Woodworking warm earthy brown

View File

@@ -0,0 +1,166 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import { craftColors } from "../constants/colors";
import { api } from "../api";
import { desynthColor } from "../constants/colors";
import ItemDisplay from "../components/ItemDisplay";
interface DesynthRecipe {
id: number;
craft: string;
cap?: number | null;
item: string;
crystal: string;
ingredients: string;
hq1?: string | null;
hq2?: string | null;
hq3?: string | null;
}
export default function DesynthRecipesPage() {
const [filter, setFilter] = useState<'all' | 'partial' | 'full'>('all');
const [craftIdx, setCraftIdx] = useState(0);
// inventory owned for default character
const { data: inv } = useQuery({
queryKey: ["inventory", "Rynore"],
queryFn: async () => {
const { data } = await api.get(`/inventory/Rynore`);
return data as { item_name: string; quantity: number; storage_type: string }[];
},
});
const ownedInfoMap = useMemo(() => {
const map: Record<string, { qty: number; storages: string[] }> = {};
inv?.forEach((i) => {
if (!map[i.item_name]) map[i.item_name] = { qty: 0, storages: [] };
map[i.item_name].qty += i.quantity;
if (!map[i.item_name].storages.includes(i.storage_type)) {
map[i.item_name].storages.push(i.storage_type);
}
});
return map;
}, [inv]);
const { data, isLoading } = useQuery({
queryKey: ["desynth-recipes"],
queryFn: async () => {
const { data } = await api.get(`/recipes/desynthesis`);
return data as DesynthRecipe[];
},
});
const crafts = useMemo(()=> {
if(!data) return [] as string[];
return Array.from(new Set(data.map(r=>r.craft))).sort();
}, [data]);
const currentCraft = crafts[craftIdx];
const filtered = useMemo(() => {
if (!data) return [] as DesynthRecipe[];
let arr = data;
if(currentCraft) arr = arr.filter(r=>r.craft === currentCraft);
if (filter === 'all') return arr;
const hasAllIngredients = (recipe: DesynthRecipe) => {
return recipe.ingredients.split(',').every((raw) => {
const trimmed = raw.trim();
const match = trimmed.match(/^(.*?)(?:\s+x(\d+))?$/i);
const baseName = match?.[1]?.trim() ?? trimmed;
const qtyNeeded = match?.[2] ? parseInt(match[2]) : 1;
const qtyOwned = ownedInfoMap[baseName]?.qty ?? 0;
return qtyOwned >= qtyNeeded;
});
};
const ownsAnyIngredient = (recipe: DesynthRecipe) => {
return recipe.ingredients.split(',').some((raw) => {
const baseName = raw.trim().replace(/\s+x\d+$/i, '').trim();
return (ownedInfoMap[baseName]?.qty ?? 0) > 0;
});
};
return arr.filter((r) => {
if (filter === 'full') return hasAllIngredients(r);
if (filter === 'partial') return ownsAnyIngredient(r) && !hasAllIngredients(r);
return true;
});
}, [data, filter, ownedInfoMap, currentCraft]);
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6" sx={{ color: desynthColor, fontWeight: 'bold' }}>
Desynthesis Recipes
</Typography>
<ToggleButtonGroup size="small" value={filter} exclusive onChange={(_, v) => v && setFilter(v)}>
<ToggleButton value="all">All</ToggleButton>
<ToggleButton value="full">Owned</ToggleButton>
</ToggleButtonGroup>
</Box>
{isLoading && <CircularProgress sx={{ m: 2 }} />}
{!isLoading && (
<>
{/* Craft tabs */}
{crafts.length>1 && (
<Tabs
value={craftIdx}
onChange={(_,v)=>setCraftIdx(v)}
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor: craftColors[currentCraft] || desynthColor } }}
sx={{ mb:1, bgcolor:'background.paper' }}
>
{crafts.map((c,idx)=> (
<Tab key={c} label={c} sx={{ color: craftColors[c] || desynthColor,'&.Mui-selected':{color: craftColors[c] || desynthColor} }} />
))}
</Tabs>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Item</TableCell>
<TableCell>Skill Cap</TableCell>
<TableCell>Crystal</TableCell>
<TableCell>Ingredients</TableCell>
<TableCell>HQ1</TableCell>
<TableCell>HQ2</TableCell>
<TableCell>HQ3</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.sort((a,b)=>(a.cap??999)-(b.cap??999)).map((r) => (
<TableRow key={r.id}>
<TableCell><ItemDisplay name={r.item} ownedInfo={ownedInfoMap} /></TableCell>
<TableCell>{r.cap ?? '-'}</TableCell>
<TableCell><ItemDisplay name={`${r.crystal} Crystal`} displayName={r.crystal} exactName={true} ownedInfo={{}} noHighlight label={r.crystal} /></TableCell>
<TableCell>
{r.ingredients.split(',').map((ing,idx)=>{
const trimmed = ing.trim();
const match = trimmed.match(/^(.*?)(?:\s+x(\d+))?$/i);
const baseName = match?.[1]?.trim() ?? trimmed;
const qtyNeeded = match?.[2] ? parseInt(match[2]) : 1;
return <span key={idx}><ItemDisplay name={baseName} qty={qtyNeeded} ownedInfo={ownedInfoMap} />{idx< r.ingredients.split(',').length-1?', ':''}</span>
})}
</TableCell>
<TableCell>{r.hq1? <ItemDisplay name={r.hq1} ownedInfo={{}} noHighlight /> : '—'}</TableCell>
<TableCell>{r.hq2? <ItemDisplay name={r.hq2} ownedInfo={{}} noHighlight /> : '—'}</TableCell>
<TableCell>{r.hq3? <ItemDisplay name={r.hq3} ownedInfo={{}} noHighlight /> : '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</Box>
);
}

View File

@@ -25,6 +25,11 @@ 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 Table from "@mui/material/Table";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import TableCell from "@mui/material/TableCell";
import TableBody from "@mui/material/TableBody";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { importInventoryCsv } from "../api";
@@ -35,27 +40,62 @@ interface Props {
character?: string;
}
const STORAGE_ORDER = [
"inventory",
"safe",
"safe 2",
"storage",
"locker",
"satchel",
"sack",
"case",
"wardrobe", // wardrobe 1 treated as base label
];
function displayLabel(type: string): string {
const canon = type.toLowerCase();
if (canon === "inventory") return "Backpack";
if (canon.startsWith("wardrobe")) {
// ensure space before number if missing
return type.replace(/wardrobe\s*/i, "Wardrobe ").replace(/\s+/g, " ").trim();
}
// Capitalise first letter each word
return type.replace(/\b\w/g, (ch) => ch.toUpperCase());
}
function orderIndex(type: string): number {
const canon = type.toLowerCase();
const idx = STORAGE_ORDER.findIndex((o) => canon.startsWith(o));
return idx === -1 ? 999 : idx;
}
export default function InventoryPage({ storageTypes, character = "Rynore" }: Props) {
const orderedTypes = useMemo(() => {
return [...storageTypes].sort((a, b) => orderIndex(a) - orderIndex(b));
}, [storageTypes]);
// Build full tab label list (storage tabs + Duplicates + optional Search)
const baseTabLabels = useMemo(() => [...orderedTypes, "Duplicates"], [orderedTypes]);
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
useEffect(() => {
const idx = storageTypes.indexOf("Inventory");
const idx = orderedTypes.indexOf("Inventory");
if (idx >= 0) {
setTab(idx);
}
}, [storageTypes]);
}, [orderedTypes]);
const [selected, setSelected] = useState<GridItem | null>(null);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const searchMode = debouncedSearch.trim() !== "";
const searchTabIndex = storageTypes.length;
const searchTabIndex = baseTabLabels.length + (searchMode ? 0 : 0); // will push later
const tabLabels = useMemo(() => {
return searchMode ? [...baseTabLabels, "Search"] : baseTabLabels;
}, [baseTabLabels, searchMode]);
// Switch automatically to the search tab when a query is entered
useEffect(() => {
@@ -70,9 +110,8 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
}
}
}, [searchMode, searchTabIndex]);
const currentType = tab === searchTabIndex ? undefined : storageTypes[tab];
const currentType = tab < orderedTypes.length ? orderedTypes[tab] : undefined;
// Clear selected item when tab changes
useEffect(()=> setSelected(null), [currentType]);
@@ -147,6 +186,7 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
storage_type: (row as any).storage_type,
name: row.item_name,
quantity: 'quantity' in row ? (row as any).quantity : undefined,
stack_size: (row as any).stack_size,
iconUrl: (row as any).icon_id
? `/api/icon/${(row as any).icon_id}`
: (() => {
@@ -199,13 +239,12 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
{isNarrow ? (
<FormControl size="small" sx={{ minWidth:120 }}>
<Select
value={tab}
onChange={(e)=>setTab(e.target.value as number)}
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>}
{tabLabels.map((label, idx) => (
<MenuItem key={label} value={idx}>{label.toUpperCase()}</MenuItem>
))}
</Select>
</FormControl>
) : (() => {
@@ -218,10 +257,9 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
TabIndicatorProps={{ sx:{ backgroundColor:inventoryColor } }}
sx={{ bgcolor:'background.paper' }}
>
{storageTypes.map((s,idx)=>(
<Tab key={s} label={s} sx={{ color:inventoryColor,'&.Mui-selected':{color:inventoryColor} }} />
{tabLabels.map((label) => (
<Tab key={label} label={displayLabel(label)} sx={{ color:inventoryColor,'&.Mui-selected':{color:inventoryColor} }} />
))}
{searchMode && <Tab label="Search" sx={{ color:'#90a4ae','&.Mui-selected':{color:'#90a4ae'} }} />}
</Tabs>
);
})()}
@@ -275,22 +313,62 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
</Box>
{isLoading && <CircularProgress sx={{ m: 2 }} />}
{(() => {
const isDup = tabLabels[tab] === "Duplicates";
if (isDup) {
if (!allInv) return <CircularProgress sx={{ m:2 }} />;
const dupMap: Record<string, { qty: number; storages: {type:string; qty:number}[] }> = {};
(allInv as any[]).forEach((row:any) => {
const name = row.item_name;
dupMap[name] ??= { qty: 0, storages: [] };
dupMap[name].qty += row.quantity ?? 0;
const bucket = dupMap[name].storages.find(s=>s.type===row.storage_type);
if(bucket){ bucket.qty += row.quantity ?? 0; }
else { dupMap[name].storages.push({ type: row.storage_type, qty: row.quantity ?? 0 }); }
});
const rows = Object.entries(dupMap).filter(([_,v])=>v.storages.length>1).sort(([a],[b])=>a.localeCompare(b));
return (
<Table size="small" sx={{ mt:2 }}>
<TableHead>
<TableRow>
<TableCell>Item</TableCell>
<TableCell>Total Qty</TableCell>
<TableCell>Storages</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map(([name,info])=> (
<TableRow key={name}>
<TableCell>{name}</TableCell>
<TableCell>{info.qty}</TableCell>
<TableCell>{info.storages.map(s=>`${displayLabel(s.type)} (${s.qty})`).join(', ')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
<ItemsGrid
key={tab}
items={isLoading ? undefined : gridItems}
loading={isLoading}
onSelect={(item) => setSelected(item)}
duplicates={duplicates}
selectedId={selected?.id}
/>
<ItemDetailPanel
open={!!selected}
item={selected}
onClose={() => setSelected(null)}
/>
// Normal grid view
return (
<>
{isLoading && <CircularProgress sx={{ m: 2 }} />}
<ItemsGrid
key={tab}
items={isLoading ? undefined : gridItems}
loading={isLoading}
onSelect={(item) => setSelected(item)}
duplicates={duplicates}
selectedId={selected?.id}
/>
<ItemDetailPanel
open={!!selected}
item={selected}
onClose={() => setSelected(null)}
/>
</>
);
})()}
<Snackbar
open={snack.open}

View File

@@ -30,14 +30,15 @@ export default function RecipesPage({ crafts }: Props) {
return data as { item_name: string }[];
},
});
type InvItem = { item_name: string; storage_type: string; icon_id?: string; description?: string };
type InvItem = { item_name: string; quantity: number; storage_type: string; icon_id?: string; description?: string };
const ownedInfo = useMemo(() => {
const map: Record<string, { storages: string[]; icon?: string; description?: string }> = {};
const map: Record<string, { storages: string[]; qty: number; icon?: string; description?: string }> = {};
(inv as InvItem[] | undefined)?.forEach((i) => {
if (!map[i.item_name]) {
map[i.item_name] = { storages: [], icon: undefined, description: i.description };
map[i.item_name] = { storages: [], qty: 0, icon: undefined, description: i.description };
}
map[i.item_name].storages.push(i.storage_type.toUpperCase());
map[i.item_name].qty += i.quantity;
if (i.icon_id) {
map[i.item_name].icon = `/api/icon/${i.icon_id}`;
}
@@ -100,13 +101,20 @@ export default function RecipesPage({ crafts }: Props) {
if (filter === 'all') return catFiltered;
return catFiltered.filter((r) => {
const total = r.ingredients.length;
const ownedCount = r.ingredients.reduce(
(acc, [name]) => acc + (ownedSet.has(name) ? 1 : 0),
0
);
if (filter === 'full') return ownedCount === total && total > 0;
let sufficient = 0;
let some = 0;
for (const [name, reqQty] of r.ingredients) {
const info = ownedInfo[name];
if (info) {
if (info.qty >= reqQty) {
sufficient += 1; // green
}
if (info.qty > 0) some += 1; // any amount
}
}
if (filter === 'full') return sufficient === total && total > 0;
if (filter === 'partial')
return ownedCount > 0 && ownedCount < total;
return some > 0 && sufficient < total;
return true;
});
}, [catFiltered, filter, ownedSet]);
@@ -162,7 +170,7 @@ export default function RecipesPage({ crafts }: Props) {
{isLoading ? (
<CircularProgress sx={{ m: 2 }} />
) : (
<RecipesDetailTable recipes={filtered} owned={ownedSet} ownedInfo={ownedInfo} />
<RecipesDetailTable recipes={filtered} ownedInfo={ownedInfo} />
)}
</Box>
);

View File

@@ -0,0 +1,34 @@
// Utility for applying custom string substitutions before fetching item metadata.
// The mapping is kept in a simple array so it can be edited in one place.
// The first entry that matches (case-sensitive) will be applied.
// Extend this list as needed.
export const SUBSTITUTE_MAP: Array<[string, string]> = [
// [searchFor, replaceWith]
["Lightning", "Lightng."],
["Woodworking Kit", "Wood. Kit"],
["Arrowwood Lumber","Arrowwood Lbr."],
["Banquet Table ", "B. Table "],
["Copy of Melodious Plans", "Melodious Plans"],
["Fishing Rod", "Fish. Rod"],
["Black Bubble-Eye", "Blk. Bubble-Eye"],
["Broken","Bkn."],
["Bewitched","Bwtch."],
["Dogwood Lumber","Dogwd. Lumber"],
["Fishing Rod","Fish. Rod"],
["Black Bolt Heads","Blk. Bolt HeadsBlk. Bolt Heads"],
["Ethereal Oak Lumber","Ether. Oak Lbr."],
// add more substitutions here
];
export function applySubstitutions(name: string): string {
let result = name;
for (const [from, to] of SUBSTITUTE_MAP) {
if (result.includes(from)) {
result = result.replace(from, to);
}
}
return result;
}