Initial commit

This commit is contained in:
Aodhan
2025-07-07 13:39:46 +01:00
commit cfa2eff6ef
69 changed files with 70452 additions and 0 deletions

12
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
import axios from "axios";
export const api = axios.create({
baseURL: "/api",
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

35
frontend/src/main.tsx Normal file
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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" }]
}

View 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
View 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",
},
},
});