Initial commit
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MOG SQUIRE</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2462
frontend/package-lock.json
generated
Normal file
2462
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "ffxi-item-browser",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.0",
|
||||
"@mui/material": "^5.15.0",
|
||||
"@tanstack/react-query": "^5.28.5",
|
||||
"axios": "^1.6.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.50",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.3"
|
||||
}
|
||||
}
|
||||
64
frontend/src/App.tsx
Normal file
64
frontend/src/App.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "./api";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import InventoryPage from "./pages/Inventory";
|
||||
import ItemExplorerPage from "./pages/ItemExplorer";
|
||||
import RecipesPage from "./pages/Recipes";
|
||||
import Footer from "./components/Footer";
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const { data: metadata } = useQuery({
|
||||
queryKey: ["metadata"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/metadata");
|
||||
return data as { storage_types: string[]; type_descriptions: string[] };
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ display:'flex', flexDirection:'column', minHeight:'100vh', bgcolor:'background.default' }}>
|
||||
<Box sx={{ width:'100%', maxWidth:1200, mx:'auto', flexGrow:1 }}>
|
||||
<Box sx={{ display:'flex', alignItems:'center', mb:1 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight:'bold', flexGrow:0, mr:2 }}>
|
||||
MOG SQUIRE
|
||||
</Typography>
|
||||
</Box>
|
||||
{(() => {
|
||||
const tabColors = ['#66bb6a', '#42a5f5', '#ffa726'];
|
||||
return (
|
||||
<Tabs
|
||||
value={page}
|
||||
onChange={(_, v) => setPage(v)}
|
||||
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] }
|
||||
}} />
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
})()}
|
||||
|
||||
{page === 0 && metadata && (
|
||||
<InventoryPage storageTypes={metadata.storage_types} />
|
||||
)}
|
||||
{page === 1 && metadata && (
|
||||
<ItemExplorerPage typeDescriptions={metadata.type_descriptions} />
|
||||
)}
|
||||
{page === 2 && metadata && (
|
||||
<RecipesPage crafts={["woodworking", "smithing", "alchemy", "bonecraft", "goldsmithing", "clothcraft", "leathercraft", "cooking"]} />
|
||||
)}
|
||||
</Box>
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
5
frontend/src/api.ts
Normal file
5
frontend/src/api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import axios from "axios";
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: "/api",
|
||||
});
|
||||
21
frontend/src/components/Footer.tsx
Normal file
21
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: 2,
|
||||
textAlign: "center",
|
||||
bgcolor: "background.paper",
|
||||
borderTop: "1px solid",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
© {new Date().getFullYear()} MOG App – Unofficial FFXI crafting & inventory tool
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
227
frontend/src/components/ItemDetailPanel.tsx
Normal file
227
frontend/src/components/ItemDetailPanel.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
export interface ItemSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
interface Props {
|
||||
open: boolean;
|
||||
item: (ItemSummary & { storages?: string[]; storage_type?: string }) | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ItemDetailPanel({ open, item, onClose }: Props) {
|
||||
interface Detail {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon_id?: string;
|
||||
type_description?: string;
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery<Detail | null>({
|
||||
queryKey: ["item", item?.name],
|
||||
queryFn: async () => {
|
||||
if (!item) return null;
|
||||
const { data } = await api.get(`/items/by-name/${encodeURIComponent(item.name)}`);
|
||||
return data as { id: number; name: string; description?: string };
|
||||
},
|
||||
enabled: !!item,
|
||||
});
|
||||
|
||||
interface UsageRec { craft: string; id: number; name: string; level: number }
|
||||
interface Usage { crafted: UsageRec[]; ingredient: UsageRec[] }
|
||||
const { data: usage } = useQuery<Usage | null>({
|
||||
queryKey: ['usage', item?.name],
|
||||
queryFn: async () => {
|
||||
if (!item) return null;
|
||||
try {
|
||||
const res = await api.get(`/recipes/usage/${encodeURIComponent(item.name)}`);
|
||||
return res.data as Usage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!item,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
|
||||
});
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={onClose}>
|
||||
<Box sx={{ width: 360, p: 3 }}>
|
||||
{isLoading || !data ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<>
|
||||
{data?.icon_id && (
|
||||
<img
|
||||
src={`/api/icon/${data.icon_id}`}
|
||||
alt={data?.name ?? ''}
|
||||
style={{ width: 64, height: 64, float: 'right' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
{data?.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
component="a"
|
||||
href={`https://ffxiclopedia.fandom.com/wiki/${encodeURIComponent((data?.name ?? '').replace(/ /g, '_'))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
{data?.type_description === 'SCROLL' && data.description && (
|
||||
(() => {
|
||||
const m = data.description.match(/Lv\.?\s*(\d+)\s*([A-Z/]+)?/i);
|
||||
if (!m) return null;
|
||||
const level = m[1];
|
||||
const jobs = m[2]?.replace(/\//g, ', ');
|
||||
return (
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
Scroll: Lv. {level} {jobs && `| Jobs: ${jobs}`}
|
||||
</Typography>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
{item?.storage_type && (
|
||||
<Typography variant="caption" color="secondary" gutterBottom>
|
||||
IN: {item.storage_type.toUpperCase()}
|
||||
</Typography>
|
||||
)}
|
||||
{item?.storages && item.storages.length > 1 && (
|
||||
(() => {
|
||||
const alsoIn = item.storages.filter(s => s !== (item.storage_type ?? '').toUpperCase());
|
||||
if (alsoIn.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<br />
|
||||
<Typography variant="caption" color="secondary" gutterBottom>
|
||||
Also in: {alsoIn.join(', ')}
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
{data?.description ? (
|
||||
data.description.split(/\n|<br\s*\/?>/i).map((line: string, idx: number) => (
|
||||
<Typography
|
||||
key={idx}
|
||||
variant="body2"
|
||||
sx={{ whiteSpace: 'pre-line', color: line.startsWith('+') || /(DMG|DEF|Delay|STR|DEX|VIT|AGI|INT|MND|CHR)/i.test(line) ? 'primary.main' : 'text.primary' }}
|
||||
>
|
||||
{line}
|
||||
</Typography>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2">No description.</Typography>
|
||||
)}
|
||||
{usage && (
|
||||
<>
|
||||
<Divider sx={{ mt: 2, mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Recipe Usage
|
||||
</Typography>
|
||||
{usage.crafted.length > 0 && (() => {
|
||||
const craftOrder = [
|
||||
'woodworking',
|
||||
'smithing',
|
||||
'alchemy',
|
||||
'bonecraft',
|
||||
'goldsmithing',
|
||||
'clothcraft',
|
||||
'leathercraft',
|
||||
'cooking',
|
||||
];
|
||||
const grouped: Record<string, UsageRec[]> = {};
|
||||
usage.crafted.forEach(r => {
|
||||
(grouped[r.craft] ||= []).push(r);
|
||||
});
|
||||
craftOrder.forEach(c => {
|
||||
if (grouped[c]) grouped[c].sort((a, b) => a.level - b.level);
|
||||
});
|
||||
const crafts = craftOrder.filter(c => grouped[c]?.length);
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Crafted in:
|
||||
</Typography>
|
||||
{crafts.map(craft => (
|
||||
<Box key={craft} sx={{ ml: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{craft.charAt(0).toUpperCase() + craft.slice(1)}
|
||||
</Typography>
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
{grouped[craft].map(r => (
|
||||
<li key={r.id}>
|
||||
<Typography variant="body2">{r.name} (Lv. {r.level})</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
{usage.ingredient.length > 0 && (() => {
|
||||
const craftOrder = [
|
||||
'woodworking',
|
||||
'smithing',
|
||||
'alchemy',
|
||||
'bonecraft',
|
||||
'goldsmithing',
|
||||
'clothcraft',
|
||||
'leathercraft',
|
||||
'cooking',
|
||||
];
|
||||
const grouped: Record<string, UsageRec[]> = {};
|
||||
usage.ingredient.forEach(r => {
|
||||
(grouped[r.craft] ||= []).push(r);
|
||||
});
|
||||
craftOrder.forEach(c => {
|
||||
if (grouped[c]) grouped[c].sort((a, b) => a.level - b.level);
|
||||
});
|
||||
const crafts = craftOrder.filter(c => grouped[c]?.length);
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Ingredient in:
|
||||
</Typography>
|
||||
{crafts.map(craft => (
|
||||
<Box key={craft} sx={{ ml: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
||||
{craft.charAt(0).toUpperCase() + craft.slice(1)}
|
||||
</Typography>
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
{grouped[craft].map(r => (
|
||||
<li key={r.id}>
|
||||
<Typography variant="body2">{r.name} (Lv. {r.level})</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/ItemsGrid.tsx
Normal file
101
frontend/src/components/ItemsGrid.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Badge from "@mui/material/Badge";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { styled } from "@mui/material/styles";
|
||||
|
||||
export interface GridItem {
|
||||
id: number;
|
||||
name: string;
|
||||
iconUrl?: string;
|
||||
icon_id?: string;
|
||||
quantity?: number;
|
||||
storages?: string[];
|
||||
storage_type?: string;
|
||||
isScroll?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: GridItem[] | undefined;
|
||||
onSelect: (item: GridItem) => void;
|
||||
loading?: boolean;
|
||||
duplicates?: Set<string>;
|
||||
selectedId?: number;
|
||||
}
|
||||
|
||||
const IconImg = styled("img")(({ theme }) => ({
|
||||
width: 32,
|
||||
height: 32,
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
[theme.breakpoints.up("md")]: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function ItemsGrid({ items, onSelect, loading, duplicates, selectedId }: Props) {
|
||||
const cells = items ?? Array.from({ length: 100 });
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: 'repeat(4, 1fr)', sm: 'repeat(6, 1fr)', md: 'repeat(8, 1fr)', lg: 'repeat(10, 1fr)' },
|
||||
gap: 1,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
{cells.map((item, idx) => (
|
||||
<Box
|
||||
key={item ? item.id : idx}
|
||||
sx={{
|
||||
width: { xs: 40, sm: 48, md: 56 },
|
||||
height: { xs: 40, sm: 48, md: 56 },
|
||||
borderRadius: 1,
|
||||
bgcolor: "background.paper",
|
||||
display: "flex",
|
||||
boxShadow: 'inset 1px 1px 2px rgba(255,255,255,0.1), inset -1px -1px 2px rgba(0,0,0,0.6)',
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: item ? "pointer" : "default",
|
||||
...(item && item.id === selectedId
|
||||
? {
|
||||
bgcolor: 'grey.700',
|
||||
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',
|
||||
'&: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)}
|
||||
>
|
||||
|
||||
|
||||
|
||||
{loading || !item ? (
|
||||
<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}` : ""}`}
|
||||
arrow
|
||||
>
|
||||
<Badge
|
||||
badgeContent={item.quantity && item.quantity > 1 ? item.quantity : undefined}
|
||||
color="secondary"
|
||||
overlap="circular"
|
||||
>
|
||||
<IconImg src={item.iconUrl} alt={item.name} />
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Box sx={{ width: 40, height: 40, fontSize: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>?
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/RecipeDetailDialog.tsx
Normal file
78
frontend/src/components/RecipeDetailDialog.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import type { RecipeDetail } from "../types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
recipe: RecipeDetail | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RecipeDetailDialog({ open, recipe, onClose }: Props) {
|
||||
if (!recipe) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{recipe.name}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Level {recipe.level} – {recipe.category} – {recipe.crystal} Crystal
|
||||
</Typography>
|
||||
{recipe.key_item && (
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Key Item: {recipe.key_item}
|
||||
</Typography>
|
||||
)}
|
||||
{recipe.subcrafts.length > 0 && (
|
||||
<Typography variant="body2" gutterBottom>
|
||||
Subcrafts: {recipe.subcrafts
|
||||
.map(([c, lvl]: [string, number]) => `${c} (${lvl})`)
|
||||
.join(", ")}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack direction="row" spacing={4} sx={{ mt: 2 }}>
|
||||
<Stack>
|
||||
<Typography variant="h6">Ingredients</Typography>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
{recipe.ingredients.map(([name, qty]: [string, number]) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell>{name}</TableCell>
|
||||
<TableCell align="right">{qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Typography variant="h6">HQ Yields</Typography>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
{recipe.hq_yields.map((hq: [string, number] | null, idx: number) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>HQ{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
{hq ? `${hq[0]} x${hq[1]}` : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
124
frontend/src/components/RecipesDetailTable.tsx
Normal file
124
frontend/src/components/RecipesDetailTable.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import Table from "@mui/material/Table";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
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 type { RecipeDetail } from "../types";
|
||||
|
||||
interface Props {
|
||||
recipes: RecipeDetail[] | undefined;
|
||||
loading?: boolean;
|
||||
owned?: Set<string>;
|
||||
ownedInfo?: Record<string, { storages: string[]; 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; }) {
|
||||
const local = ownedInfo?.[name];
|
||||
const { data } = useQuery<{ icon_id?: string; description?: string } | null>({
|
||||
queryKey: ["item-meta", name],
|
||||
queryFn: async () => {
|
||||
if (local) return null;
|
||||
try {
|
||||
const res = await fetch(`/api/items/by-name/${encodeURIComponent(name)}`);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as { icon_id?: string; description?: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 1000 * 60 * 30,
|
||||
});
|
||||
const icon = local?.icon || (data?.icon_id ? `/api/icon/${data.icon_id}` : undefined);
|
||||
const desc = local?.description || data?.description;
|
||||
const tooltip = `${desc ?? label ?? name}`;
|
||||
return (
|
||||
<Tooltip title={tooltip} arrow>
|
||||
<span style={owned?.has(name) ? { color: "green" } : {}}>
|
||||
{icon && (
|
||||
<img
|
||||
src={icon}
|
||||
alt={name}
|
||||
style={{ width: 16, verticalAlign: "text-bottom", marginRight: 4 }}
|
||||
/>
|
||||
)}
|
||||
{qty !== undefined ? `${label ?? name} x${qty}` : (label ?? name)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RecipesDetailTable({ recipes, loading, owned, ownedInfo }: Props) {
|
||||
if (loading) return <CircularProgress sx={{ m: 2 }} />;
|
||||
if (!recipes || recipes.length === 0)
|
||||
return (
|
||||
<Typography sx={{ m: 2 }}>No recipes found for this category.</Typography>
|
||||
);
|
||||
|
||||
const formatSubcrafts = (arr: [string, number][]) =>
|
||||
arr.map(([n, q]) => `${n} [${q}]`).join(", ");
|
||||
|
||||
return (
|
||||
<Table size="small" sx={{ mt: 1 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Lvl</TableCell>
|
||||
<TableCell>Crystal</TableCell>
|
||||
<TableCell>Ingredients</TableCell>
|
||||
<TableCell>HQ1</TableCell>
|
||||
<TableCell>HQ2</TableCell>
|
||||
<TableCell>HQ3</TableCell>
|
||||
<TableCell>Subcrafts</TableCell>
|
||||
<TableCell>Key Item</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{recipes.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
return <ItemDisplay name={r.name} ownedInfo={ownedInfo} />
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>{r.level}</TableCell>
|
||||
<TableCell>
|
||||
<ItemDisplay name={`${r.crystal} Crystal`} label={r.crystal} ownedInfo={ownedInfo} />
|
||||
</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} />
|
||||
{idx < r.ingredients.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</TableCell>
|
||||
{r.hq_yields.map((hq, idx) => (
|
||||
<TableCell key={idx}>
|
||||
{hq ? (() => {
|
||||
const [n, q] = hq;
|
||||
return <ItemDisplay name={n} qty={q} ownedInfo={ownedInfo} owned={owned} />
|
||||
})() : "—"}
|
||||
</TableCell>
|
||||
))}
|
||||
{/* Ensure always 3 HQ columns */}
|
||||
{Array.from({ length: 3 - r.hq_yields.length }).map((_, i) => (
|
||||
<TableCell key={`pad-${i}`}>—</TableCell>
|
||||
))}
|
||||
<TableCell>{formatSubcrafts(r.subcrafts)}</TableCell>
|
||||
<TableCell>
|
||||
{r.key_item ? <ItemDisplay name={r.key_item} ownedInfo={ownedInfo} /> : ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* spacer row */}
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} sx={{ height: 48 }} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/RecipesTable.tsx
Normal file
92
frontend/src/components/RecipesTable.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import Accordion from "@mui/material/Accordion";
|
||||
import AccordionSummary from "@mui/material/AccordionSummary";
|
||||
import AccordionDetails from "@mui/material/AccordionDetails";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import type { RecipeSummary } from "../types";
|
||||
|
||||
interface Props {
|
||||
recipes: RecipeSummary[] | undefined;
|
||||
loading?: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function RecipesTable({ recipes, loading, onSelect }: Props) {
|
||||
if (loading) return <CircularProgress sx={{ m: 2 }} />;
|
||||
if (!recipes || recipes.length === 0) return <Typography sx={{ m: 2 }}>No recipes</Typography>;
|
||||
|
||||
// Group by category
|
||||
const grouped: Record<string, RecipeSummary[]> = {};
|
||||
for (const r of recipes) {
|
||||
grouped[r.category] ??= [];
|
||||
grouped[r.category].push(r);
|
||||
}
|
||||
|
||||
// Preserve original order by craft level category typical ordering using list
|
||||
const categoryOrder = [
|
||||
"Amateur",
|
||||
"Recruit",
|
||||
"Initiate",
|
||||
"Novice",
|
||||
"Apprentice",
|
||||
"Journeyman",
|
||||
"Craftsman",
|
||||
"Artisan",
|
||||
"Adept",
|
||||
"Expert",
|
||||
"Veteran",
|
||||
"Legend"
|
||||
];
|
||||
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const ia = categoryOrder.indexOf(a);
|
||||
const ib = categoryOrder.indexOf(b);
|
||||
if (ia === -1 && ib === -1) return a.localeCompare(b);
|
||||
if (ia === -1) return 1;
|
||||
if (ib === -1) return -1;
|
||||
return ia - ib;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{sortedCategories.map((cat) => (
|
||||
<Accordion key={cat} defaultExpanded>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography variant="h6">{cat}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Level</TableCell>
|
||||
<TableCell>Crystal</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{grouped[cat].map((r) => (
|
||||
<TableRow
|
||||
key={r.id}
|
||||
hover
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => onSelect(r.id)}
|
||||
>
|
||||
<TableCell>{r.name}</TableCell>
|
||||
<TableCell>{r.level}</TableCell>
|
||||
<TableCell>{r.crystal}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/src/hooks/useDebounce.ts
Normal file
17
frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Debounce a value. Returns the debounced value that only updates after the specified delay.
|
||||
* @param value The input value.
|
||||
* @param delay Delay in milliseconds.
|
||||
*/
|
||||
export default function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
BIN
frontend/src/icons/blank.png
Normal file
BIN
frontend/src/icons/blank.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/src/icons/key.png
Normal file
BIN
frontend/src/icons/key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/src/icons/map.png
Normal file
BIN
frontend/src/icons/map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/src/icons/mount.png
Normal file
BIN
frontend/src/icons/mount.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
BIN
frontend/src/icons/no-icon.png
Normal file
BIN
frontend/src/icons/no-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
35
frontend/src/main.tsx
Normal file
35
frontend/src/main.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import App from "./App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: "dark",
|
||||
background: {
|
||||
default: "#212121",
|
||||
paper: "#424242",
|
||||
},
|
||||
primary: {
|
||||
main: "#90caf9",
|
||||
},
|
||||
secondary: {
|
||||
main: "#f48fb1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
192
frontend/src/pages/Inventory.tsx
Normal file
192
frontend/src/pages/Inventory.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { 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 CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import ItemsGrid, { GridItem } from "../components/ItemsGrid";
|
||||
import ItemDetailPanel from "../components/ItemDetailPanel";
|
||||
import { api } from "../api";
|
||||
// Placeholder icons for items missing specific icons
|
||||
import mapIcon from "../icons/map.png";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
storageTypes: string[];
|
||||
character?: string;
|
||||
}
|
||||
|
||||
export default function InventoryPage({ storageTypes, character = "Rynore" }: Props) {
|
||||
const [tab, setTab] = useState<number>(0);
|
||||
const prevTabRef = useRef<number>(0);
|
||||
|
||||
// Adjust default selection to "Inventory" once storageTypes are available
|
||||
useEffect(() => {
|
||||
const idx = storageTypes.indexOf("Inventory");
|
||||
if (idx >= 0) {
|
||||
setTab(idx);
|
||||
}
|
||||
}, [storageTypes]);
|
||||
|
||||
|
||||
|
||||
|
||||
const [selected, setSelected] = useState<GridItem | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const searchMode = debouncedSearch.trim() !== "";
|
||||
const searchTabIndex = storageTypes.length;
|
||||
|
||||
// Switch automatically to the search tab when a query is entered
|
||||
useEffect(() => {
|
||||
if (searchMode) {
|
||||
if (tab !== searchTabIndex) {
|
||||
prevTabRef.current = tab;
|
||||
setTab(searchTabIndex);
|
||||
}
|
||||
} else {
|
||||
if (tab === searchTabIndex) {
|
||||
setTab(prevTabRef.current);
|
||||
}
|
||||
}
|
||||
}, [searchMode, searchTabIndex]);
|
||||
|
||||
|
||||
const currentType = tab === searchTabIndex ? undefined : storageTypes[tab];
|
||||
|
||||
// Clear selected item when tab changes
|
||||
useEffect(()=> setSelected(null), [currentType]);
|
||||
|
||||
// Fetch items for current storage type
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["inventory", character, currentType],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(
|
||||
`/inventory/${character}?storage_type=${encodeURIComponent(currentType ?? "")}`
|
||||
);
|
||||
return data as { id: number; item_name: string; quantity: number; icon_id?: string; type_description?: string }[];
|
||||
},
|
||||
enabled: !!currentType,
|
||||
});
|
||||
|
||||
// Fetch full inventory once to detect duplicates across tabs
|
||||
const { data: allInv } = useQuery({
|
||||
queryKey: ["inventory", character, "all"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/inventory/${character}`);
|
||||
return data as { item_name: string; storage_type: string }[];
|
||||
},
|
||||
});
|
||||
|
||||
const duplicates = useMemo(() => {
|
||||
const map: Record<string, Set<string>> = {};
|
||||
allInv?.forEach((i) => {
|
||||
if (!map[i.item_name]) map[i.item_name] = new Set();
|
||||
map[i.item_name].add(i.storage_type);
|
||||
});
|
||||
return new Set(
|
||||
Object.entries(map)
|
||||
.filter(([_, set]) => set.size > 1)
|
||||
.map(([name]) => name)
|
||||
);
|
||||
}, [allInv]);
|
||||
|
||||
const filtered = (tab === searchTabIndex ? allInv : data)?.filter(d => debouncedSearch ? d.item_name.toLowerCase().includes(debouncedSearch.toLowerCase()) : true);
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Pad to 80 slots with empty placeholders (skip for Search tab)
|
||||
if (tab !== searchTabIndex && gridItems.length < 80) {
|
||||
const missing = 80 - gridItems.length;
|
||||
for (let i = 0; i < missing; i++) {
|
||||
gridItems.push({
|
||||
id: -1 - i,
|
||||
name: "EMPTY",
|
||||
iconUrl: blankIcon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_,v)=>setTab(v)}
|
||||
variant="scrollable"
|
||||
TabIndicatorProps={{ sx:{ backgroundColor:getColor(tab)} }}
|
||||
sx={{ bgcolor:'background.paper' }}
|
||||
>
|
||||
{storageTypes.map((s,idx)=>(
|
||||
<Tab key={s} label={s} sx={{ color:getColor(idx),'&.Mui-selected':{color:getColor(idx)} }} />
|
||||
))}
|
||||
{searchMode && <Tab label="Search" sx={{ color:'#90a4ae','&.Mui-selected':{color:'#90a4ae'} }} />}
|
||||
</Tabs>
|
||||
);
|
||||
})()}
|
||||
<TextField
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e)=>setSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{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)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
177
frontend/src/pages/ItemExplorer.tsx
Normal file
177
frontend/src/pages/ItemExplorer.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useMemo, useEffect } 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 CircularProgress from "@mui/material/CircularProgress";
|
||||
import Pagination from "@mui/material/Pagination";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import ItemsGrid, { GridItem } from "../components/ItemsGrid";
|
||||
import ItemDetailPanel from "../components/ItemDetailPanel";
|
||||
import { api } from "../api";
|
||||
|
||||
interface Props {
|
||||
typeDescriptions: string[];
|
||||
}
|
||||
|
||||
export default function ItemExplorerPage({ typeDescriptions }: Props) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [subTab, setSubTab] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [selected, setSelected] = useState<GridItem | null>(null);
|
||||
|
||||
const baseTypes = typeDescriptions
|
||||
.map((t) => (t === "NOTHING" || t === "UNKNOWN" ? "MISC" : t))
|
||||
.filter((t) => t !== "ITEM");
|
||||
|
||||
const displayTypes = Array.from(new Set([...baseTypes, "MISC"]));
|
||||
|
||||
const currentType = displayTypes[tab];
|
||||
|
||||
// Define sub types for usable items
|
||||
const usableSubTypes = useMemo(() => [
|
||||
"SCROLL",
|
||||
"QUEST_ITEM",
|
||||
"CRYSTAL",
|
||||
"USABLE_ITEM",
|
||||
"BOOK",
|
||||
"FISH",
|
||||
], []);
|
||||
|
||||
const subTypeValues = useMemo(() => {
|
||||
if (currentType !== "USABLE_ITEM") return [] as string[];
|
||||
return usableSubTypes;
|
||||
}, [currentType]);
|
||||
|
||||
const currentSubType =
|
||||
currentType === "USABLE_ITEM" ? subTypeValues[subTab] : currentType;
|
||||
|
||||
// Reset pagination when type changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setTotalPages(1);
|
||||
setSearch("");
|
||||
}, [currentType, currentSubType]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["items", currentType === "MISC" ? "MISC" : currentSubType, page, debouncedSearch],
|
||||
queryFn: async () => {
|
||||
let url = `/items?page=${page}&page_size=100`;
|
||||
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
|
||||
if (currentType !== "MISC") {
|
||||
url = `/items?type=${encodeURIComponent(currentSubType)}&page=${page}&page_size=100`;
|
||||
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
return response.data as { id: number; name: string; icon_id?: string; type_description?: string }[];
|
||||
},
|
||||
enabled: !!currentType,
|
||||
});
|
||||
|
||||
const gridItems: GridItem[] | undefined = data
|
||||
?.filter((d) => d.name !== '.' && (currentType !== 'MISC' ? true : !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,
|
||||
}));
|
||||
|
||||
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];
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_,v)=>{ setTab(v); setPage(1);} }
|
||||
variant="scrollable"
|
||||
TabIndicatorProps={{ sx:{ backgroundColor:getColor(tab)} }}
|
||||
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)} }} />
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
})()}
|
||||
<TextField
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder="Search items…"
|
||||
value={search}
|
||||
onChange={(e)=>{setSearch(e.target.value); setPage(1);}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</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];
|
||||
return (
|
||||
<Tabs
|
||||
value={subTab}
|
||||
onChange={(_,v)=>setSubTab(v)}
|
||||
variant="scrollable"
|
||||
TabIndicatorProps={{ sx:{ backgroundColor:getColor(subTab)} }}
|
||||
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)} }} />
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isLoading && <CircularProgress sx={{ m: 2 }} />}
|
||||
|
||||
<ItemsGrid
|
||||
items={gridItems}
|
||||
loading={isLoading}
|
||||
onSelect={(item) => setSelected(item)}
|
||||
selectedId={selected?.id}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page}
|
||||
onChange={(_, p) => setPage(p)}
|
||||
sx={{ display: "flex", justifyContent: "center", mt: 2 }}
|
||||
/>
|
||||
|
||||
<ItemDetailPanel
|
||||
open={!!selected}
|
||||
item={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
172
frontend/src/pages/Recipes.tsx
Normal file
172
frontend/src/pages/Recipes.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
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 { useQuery } from "@tanstack/react-query";
|
||||
import RecipesDetailTable from "../components/RecipesDetailTable";
|
||||
import { api } from "../api";
|
||||
import type { RecipeDetail } from "../types";
|
||||
|
||||
interface Props {
|
||||
crafts: string[];
|
||||
}
|
||||
|
||||
export default function RecipesPage({ crafts }: Props) {
|
||||
const [craftIdx, setCraftIdx] = useState(0);
|
||||
const [catIdx, setCatIdx] = useState(0);
|
||||
const [filter, setFilter] = useState<'all' | 'partial' | 'full'>('all');
|
||||
|
||||
const currentCraft = crafts[craftIdx];
|
||||
|
||||
// Inventory query for owned items (default character Rynore for now)
|
||||
const { data: inv } = useQuery({
|
||||
queryKey: ["inventory", "Rynore"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/inventory/Rynore`);
|
||||
return data as { item_name: string }[];
|
||||
},
|
||||
});
|
||||
type InvItem = { item_name: string; storage_type: string; icon_id?: string; description?: string };
|
||||
const ownedInfo = useMemo(() => {
|
||||
const map: Record<string, { storages: string[]; 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.push(i.storage_type.toUpperCase());
|
||||
if (i.icon_id) {
|
||||
map[i.item_name].icon = `/api/icon/${i.icon_id}`;
|
||||
}
|
||||
if (i.description && !map[i.item_name].description) {
|
||||
map[i.item_name].description = i.description;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [inv]);
|
||||
const ownedSet = useMemo(() => new Set(Object.keys(ownedInfo)), [ownedInfo]);
|
||||
|
||||
// Fetch all recipes for current craft
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["recipes", currentCraft],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/recipes/${currentCraft}?page_size=1000`);
|
||||
return data as RecipeDetail[];
|
||||
},
|
||||
});
|
||||
|
||||
// Derive unique ordered categories
|
||||
const categories = useMemo(() => {
|
||||
if (!data) return [] as string[];
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => set.add(r.category));
|
||||
const order = [
|
||||
"Amateur",
|
||||
"Recruit",
|
||||
"Initiate",
|
||||
"Novice",
|
||||
"Apprentice",
|
||||
"Journeyman",
|
||||
"Craftsman",
|
||||
"Artisan",
|
||||
"Adept",
|
||||
"Veteran",
|
||||
"Expert",
|
||||
"Authority",
|
||||
];
|
||||
return Array.from(set).sort((a, b) => {
|
||||
const ia = order.indexOf(a);
|
||||
const ib = order.indexOf(b);
|
||||
if (ia === -1 && ib === -1) return a.localeCompare(b);
|
||||
if (ia === -1) return 1;
|
||||
if (ib === -1) return -1;
|
||||
return ia - ib;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
// Filter by current category first
|
||||
const currentCategory = categories[catIdx];
|
||||
const catFiltered = useMemo(() => {
|
||||
return currentCategory && data
|
||||
? data.filter((r) => r.category === currentCategory)
|
||||
: data ?? [];
|
||||
}, [data, currentCategory]);
|
||||
|
||||
// Apply owned filter
|
||||
const filtered = useMemo(() => {
|
||||
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;
|
||||
if (filter === 'partial')
|
||||
return ownedCount > 0 && ownedCount < total;
|
||||
return true;
|
||||
});
|
||||
}, [catFiltered, filter, ownedSet]);
|
||||
|
||||
return (
|
||||
<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)} }}
|
||||
>
|
||||
{crafts.map((c,idx)=>(
|
||||
<Tab key={c} label={c} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Owned filter toggle */}
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={filter}
|
||||
onChange={(_, v) => v && setFilter(v)}
|
||||
sx={{ mt: 1, mb: 1, ml: 1 }}
|
||||
>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
<ToggleButton value="partial">Partially owned</ToggleButton>
|
||||
<ToggleButton value="full">Fully owned</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{/* 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)} }}
|
||||
sx={{ bgcolor:'background.paper' }}
|
||||
>
|
||||
{categories.map((cat,idx)=>(
|
||||
<Tab key={cat} label={cat} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
})()}
|
||||
|
||||
{isLoading ? (
|
||||
<CircularProgress sx={{ m: 2 }} />
|
||||
) : (
|
||||
<RecipesDetailTable recipes={filtered} owned={ownedSet} ownedInfo={ownedInfo} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
101
frontend/src/pages/Recipes_old.tsx
Normal file
101
frontend/src/pages/Recipes_old.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import Tab from "@mui/material/Tab";
|
||||
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import RecipesDetailTable from "../components/RecipesDetailTable";
|
||||
|
||||
import { api } from "../api";
|
||||
import type { RecipeDetail } from "../types";
|
||||
|
||||
interface Props {
|
||||
crafts: string[];
|
||||
}
|
||||
|
||||
export default function RecipesPage({ crafts }: Props) {
|
||||
const [craftIdx, setCraftIdx] = useState(0);
|
||||
|
||||
const [catIdx, setCatIdx] = useState(0);
|
||||
|
||||
const currentCraft = crafts[craftIdx];
|
||||
|
||||
// Fetch all recipes for craft
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["recipes", currentCraft],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/recipes/${currentCraft}?page_size=1000`);
|
||||
return data as RecipeDetail[];
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Detail query
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: ["recipe", selectedId],
|
||||
queryFn: async () => {
|
||||
if (!selectedId) return null;
|
||||
const { data } = await api.get(`/recipes/${currentCraft}/${selectedId}`);
|
||||
return data as Recipe;
|
||||
},
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
// categories from data
|
||||
const categories = useMemo(() => {
|
||||
if (!data) return [] as string[];
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => set.add(r.category));
|
||||
const order = [
|
||||
"Amateur","Recruit","Initiate","Novice","Apprentice","Journeyman","Craftsman","Artisan","Adept","Veteran","Expert","Authority"
|
||||
];
|
||||
return Array.from(set).sort((a,b)=>{
|
||||
const ia=order.indexOf(a); const ib=order.indexOf(b);
|
||||
if(ia===-1&&ib===-1) return a.localeCompare(b);
|
||||
if(ia===-1) return 1; if(ib===-1) return -1; return ia-ib;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const currentCategory = categories[catIdx];
|
||||
const filtered = currentCategory ? data?.filter(r=>r.category===currentCategory) : data;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs
|
||||
value={craftIdx}
|
||||
onChange={(_, v) => {
|
||||
setCraftIdx(v);
|
||||
setCatIdx(0);
|
||||
}}
|
||||
variant="scrollable"
|
||||
>
|
||||
{crafts.map((c: string) => (
|
||||
<Tab key={c} label={c} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{isLoading && <CircularProgress sx={{ m: 2 }} />}
|
||||
|
||||
<RecipesTable
|
||||
recipes={data}
|
||||
loading={isLoading}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
/>
|
||||
|
||||
<Pagination
|
||||
count={10 /* placeholder */}
|
||||
page={page}
|
||||
onChange={(_, p) => setPage(p)}
|
||||
sx={{ display: "flex", justifyContent: "center", mt: 2 }}
|
||||
/>
|
||||
|
||||
<RecipeDetailDialog
|
||||
open={!!detail}
|
||||
recipe={detail}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
19
frontend/src/types.ts
Normal file
19
frontend/src/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface RecipeSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
category: string;
|
||||
crystal: string;
|
||||
}
|
||||
|
||||
export interface RecipeDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
category: string;
|
||||
crystal: string;
|
||||
key_item?: string | null;
|
||||
ingredients: [string, number][];
|
||||
hq_yields: ([string, number] | null)[];
|
||||
subcrafts: [string, number][];
|
||||
}
|
||||
27
frontend/src/vite-env.d.ts
vendored
Normal file
27
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Asset module declarations so TypeScript can import image files
|
||||
declare module "*.png" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.jpeg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.gif" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user