Desynth page and improved item info api. Added string substitution to utils.
This commit is contained in:
@@ -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 />
|
||||
|
||||
130
frontend/src/components/ItemDisplay.tsx
Normal file
130
frontend/src/components/ItemDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
166
frontend/src/pages/DesynthRecipes.tsx
Normal file
166
frontend/src/pages/DesynthRecipes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
34
frontend/src/utils/nameSubstitutions.ts
Normal file
34
frontend/src/utils/nameSubstitutions.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user