Lots of stuff

This commit is contained in:
Aodhan
2025-07-08 23:04:43 +01:00
parent cfa2eff6ef
commit 65c1972c49
26 changed files with 4094 additions and 104 deletions

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# ---------- Build stage ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN npm ci --ignore-scripts --prefer-offline
COPY . .
RUN npm run build
# ---------- Production stage ----------
FROM nginx:1.25-alpine AS prod
COPY --from=builder /app/dist /usr/share/nginx/html
# Remove default config and add minimal one
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
# Serve static assets
root /usr/share/nginx/html;
index index.html;
# Forward API requests to backend
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# All other routes SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"test": "vitest",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
@@ -18,6 +19,11 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"vitest": "^1.5.0",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/user-event": "^14.4.3",
"jsdom": "^23.0.0",
"@types/react": "^18.2.50",
"@types/react-dom": "^18.2.17",
"typescript": "^5.3.3",

View File

@@ -9,9 +9,15 @@ import InventoryPage from "./pages/Inventory";
import ItemExplorerPage from "./pages/ItemExplorer";
import RecipesPage from "./pages/Recipes";
import Footer from "./components/Footer";
import { inventoryColor, explorerColor, recipesColor } from "./constants/colors";
import MobileNav from "./components/MobileNav";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
export default function App() {
const [page, setPage] = useState(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { data: metadata } = useQuery({
queryKey: ["metadata"],
@@ -29,8 +35,9 @@ export default function App() {
MOG SQUIRE
</Typography>
</Box>
{(() => {
const tabColors = ['#66bb6a', '#42a5f5', '#ffa726'];
{!isMobile && (() => {
const tabColors = [inventoryColor, explorerColor, recipesColor];
const tabLabels = ['Inventory','Item Explorer','Recipes'];
return (
<Tabs
value={page}
@@ -38,17 +45,20 @@ export default function App() {
centered
TabIndicatorProps={{ sx: { backgroundColor: tabColors[page] } }}
>
{['Inventory','Item Explorer','Recipes'].map((label, idx) => (
<Tab key={label} label={label} sx={{
color: tabColors[idx],
'&.Mui-selected': { color: tabColors[idx] }
}} />
{tabLabels.map((label, idx) => (
<Tab
key={label}
label={label}
sx={{ color: tabColors[idx], '&.Mui-selected': { color: tabColors[idx] } }}
/>
))}
</Tabs>
);
})()}
{page === 0 && metadata && (
{/* Main content */}
<Box sx={{ pb: isMobile ? 7 : 0 }}> {/* bottom padding for nav */}
{page === 0 && metadata && (
<InventoryPage storageTypes={metadata.storage_types} />
)}
{page === 1 && metadata && (
@@ -58,7 +68,9 @@ export default function App() {
<RecipesPage crafts={["woodworking", "smithing", "alchemy", "bonecraft", "goldsmithing", "clothcraft", "leathercraft", "cooking"]} />
)}
</Box>
<Footer />
</Box>
<Footer />
{isMobile && <MobileNav value={page} onChange={setPage} />}
</Box>
);
}

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { filterExplorerItems, RawItem } from '../utils/filterExplorerItems';
const sampleData: RawItem[] = [
{ id: 1, name: 'Bronze Sword', type_description: 'WEAPON' },
{ id: 2, name: 'Leather Armor', type_description: 'ARMOR' },
{ id: 3, name: 'Antidote', type_description: 'USABLE_ITEM' },
{ id: 4, name: 'Mystery', type_description: undefined },
];
const baseTypes = ['WEAPON', 'ARMOR'];
describe('filterExplorerItems', () => {
it('excludes baseTypes while on MISC tab with empty search', () => {
const result = filterExplorerItems(sampleData, 'MISC', baseTypes, '');
const names = result?.map((g) => g.name);
expect(names).toEqual(['Antidote', 'Mystery']);
});
it('includes all items when a search term is present', () => {
const result = filterExplorerItems(sampleData, 'MISC', baseTypes, 'ant');
const names = result?.map((g) => g.name);
expect(names).toEqual(['Bronze Sword', 'Leather Armor', 'Antidote', 'Mystery']);
});
});

View File

@@ -3,3 +3,12 @@ import axios from "axios";
export const api = axios.create({
baseURL: "/api",
});
export async function importInventoryCsv(file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await api.post("/inventory/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data as { imported: number };
}

View File

@@ -1,4 +1,6 @@
import Drawer from "@mui/material/Drawer";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
@@ -7,6 +9,7 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api";
import CircularProgress from "@mui/material/CircularProgress";
import { craftColors } from "../constants/colors";
export interface ItemSummary {
id: number;
@@ -14,7 +17,7 @@ export interface ItemSummary {
}
interface Props {
open: boolean;
item: (ItemSummary & { storages?: string[]; storage_type?: string }) | null;
item: (ItemSummary & { storages?: string[]; storage_type?: string; jobs_description?: string[] }) | null;
onClose: () => void;
}
@@ -56,7 +59,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
});
return (
<Drawer anchor="right" open={open} onClose={onClose}>
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<Box sx={{ width: 360, p: 3 }}>
{isLoading || !data ? (
<CircularProgress />
@@ -84,6 +87,12 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</IconButton>
</Box>
<Divider sx={{ mb: 1 }} />
{/* Jobs for armor or other items with jobs_description */}
{item?.jobs_description && item.jobs_description.length > 0 && (
<Typography variant="subtitle2" color="primary" gutterBottom>
Jobs: {item.jobs_description.includes('ALL') ? 'All Jobs' : item.jobs_description.join(', ')}
</Typography>
)}
{data?.type_description === 'SCROLL' && data.description && (
(() => {
const m = data.description.match(/Lv\.?\s*(\d+)\s*([A-Z/]+)?/i);
@@ -129,7 +138,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
) : (
<Typography variant="body2">No description.</Typography>
)}
{usage && (
{usage && (usage.crafted.length > 0 || usage.ingredient.length > 0) && (
<>
<Divider sx={{ mt: 2, mb: 1 }} />
<Typography variant="h6" gutterBottom>
@@ -161,7 +170,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</Typography>
{crafts.map(craft => (
<Box key={craft} sx={{ ml: 1, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: craftColors[craft] || 'text.primary' }}>
{craft.charAt(0).toUpperCase() + craft.slice(1)}
</Typography>
<ul style={{ margin: 0, paddingLeft: 16 }}>
@@ -202,7 +211,7 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</Typography>
{crafts.map(craft => (
<Box key={craft} sx={{ ml: 1, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: craftColors[craft] || 'text.primary' }}>
{craft.charAt(0).toUpperCase() + craft.slice(1)}
</Typography>
<ul style={{ margin: 0, paddingLeft: 16 }}>
@@ -222,6 +231,6 @@ export default function ItemDetailPanel({ open, item, onClose }: Props) {
</>
)}
</Box>
</Drawer>
</Dialog>
);
}

View File

@@ -10,6 +10,7 @@ export interface GridItem {
iconUrl?: string;
icon_id?: string;
quantity?: number;
jobs_description?: string[];
storages?: string[];
storage_type?: string;
isScroll?: boolean;
@@ -24,16 +25,16 @@ interface Props {
}
const IconImg = styled("img")(({ theme }) => ({
width: 32,
height: 32,
width: 40,
height: 40,
[theme.breakpoints.up("sm")]: {
width: 40,
height: 40,
},
[theme.breakpoints.up("md")]: {
width: 48,
height: 48,
},
[theme.breakpoints.up("md")]: {
width: 80,
height: 80,
},
}));
export default function ItemsGrid({ items, onSelect, loading, duplicates, selectedId }: Props) {
@@ -45,15 +46,15 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
display: "grid",
gridTemplateColumns: { xs: 'repeat(4, 1fr)', sm: 'repeat(6, 1fr)', md: 'repeat(8, 1fr)', lg: 'repeat(10, 1fr)' },
gap: 1,
p: 2,
p: 1.5,
}}
>
{cells.map((item, idx) => (
<Box
key={item ? item.id : idx}
key={`${item ? item.id : 'placeholder'}-${idx}`}
sx={{
width: { xs: 40, sm: 48, md: 56 },
height: { xs: 40, sm: 48, md: 56 },
width: { xs: 100, sm: 100, md: 100 },
height: { xs: 100, sm: 100, md: 100 },
borderRadius: 1,
bgcolor: "background.paper",
display: "flex",
@@ -67,7 +68,7 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
boxShadow: 'inset 2px 2px 3px rgba(255,255,255,0.2), inset -2px -2px 3px rgba(0,0,0,0.8)',
}
: {}),
border: duplicates && item && duplicates.has(item.name) ? '2px solid #ffb74d' : 'none',
borderTop: duplicates && item && duplicates.has(item.name) ? '4px solid #ffb74d' : 'none',
'&:hover': { boxShadow: item ? '0 0 6px 2px rgba(255,255,255,0.9), inset 1px 1px 2px rgba(255,255,255,0.1), inset -1px -1px 2px rgba(0,0,0,0.6)' : undefined },
}}
onClick={() => item && onSelect(item)}
@@ -76,7 +77,7 @@ export default function ItemsGrid({ items, onSelect, loading, duplicates, select
{loading || !item ? (
<Skeleton variant="rectangular" sx={{ width: { xs: 32, sm: 40, md: 48 }, height: { xs: 32, sm: 40, md: 48 } }} />
<Skeleton variant="rectangular" sx={{ width: { xs: 40, sm: 48, md: 56 }, height: { xs: 40, sm: 48, md: 56 } }} />
) : item.iconUrl ? (
<Tooltip
title={`${item.name}${item.quantity && item.quantity > 1 ? ` x${item.quantity}` : ""}`}

View File

@@ -0,0 +1,30 @@
import BottomNavigation from "@mui/material/BottomNavigation";
import BottomNavigationAction from "@mui/material/BottomNavigationAction";
import Paper from "@mui/material/Paper";
import InventoryIcon from "@mui/icons-material/Inventory2";
import SearchIcon from "@mui/icons-material/Search";
import RestaurantIcon from "@mui/icons-material/Restaurant";
interface Props {
value: number;
onChange: (value: number) => void;
}
export default function MobileNav({ value, onChange }: Props) {
return (
<Paper
sx={{ position: "fixed", bottom: 0, left: 0, right: 0 }}
elevation={3}
>
<BottomNavigation
value={value}
onChange={(_, v) => onChange(v)}
showLabels
>
<BottomNavigationAction label="Inventory" icon={<InventoryIcon />} />
<BottomNavigationAction label="Explore" icon={<SearchIcon />} />
<BottomNavigationAction label="Recipes" icon={<RestaurantIcon />} />
</BottomNavigation>
</Paper>
);
}

View File

@@ -0,0 +1,27 @@
// Central accent colour palette used across navigation tabs.
// Keep colours distinct and readable on dark background.
export const inventoryColor = '#66bb6a';
export const explorerColor = '#42a5f5';
export const recipesColor = '#ffa726';
export const craftColors: Record<string, string> = {
woodworking: '#ad7e50', // Woodworking warm earthy brown
smithing: '#c94f4f', // Smithing ember red
alchemy: '#b672e8', // Alchemy mystical purple
bonecraft: '#F5F5DC', // Bonecraft off-white
goldsmithing: '#FFD700',// Goldsmithing gold
clothcraft: '#E6C9C9', // Clothcraft soft beige
leathercraft: '#A0522D',// Leathercraft rich tan
cooking: '#E25822', // Cooking orange-red
};
export const accentColors = [
'#ef5350', // red
'#ab47bc', // purple
'#5c6bc0', // indigo
'#29b6f6', // light blue
'#66bb6a', // green
'#ffca28', // amber
'#ffa726', // orange
'#8d6e63', // brown
];

View File

@@ -1,10 +1,16 @@
import { useState, useEffect, useMemo, useRef } from "react";
import React, { useState, useEffect, useMemo, useRef } from "react";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import InputAdornment from "@mui/material/InputAdornment";
import SearchIcon from "@mui/icons-material/Search";
import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useQuery } from "@tanstack/react-query";
import useDebounce from "../hooks/useDebounce";
@@ -17,6 +23,12 @@ import mountIcon from "../icons/mount.png";
import keyIcon from "../icons/key.png";
import noIcon from "../icons/no-icon.png";
import blankIcon from "../icons/blank.png";
import { inventoryColor } from "../constants/colors";
import Button from "@mui/material/Button";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { importInventoryCsv } from "../api";
import Snackbar from "@mui/material/Snackbar";
interface Props {
storageTypes: string[];
@@ -25,6 +37,7 @@ interface Props {
export default function InventoryPage({ storageTypes, character = "Rynore" }: Props) {
const [tab, setTab] = useState<number>(0);
const [sortKey, setSortKey] = useState<'slot' | 'name' | 'type'>('slot');
const prevTabRef = useRef<number>(0);
// Adjust default selection to "Inventory" once storageTypes are available
@@ -98,28 +111,54 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
);
}, [allInv]);
const filtered = (tab === searchTabIndex ? allInv : data)?.filter(d => debouncedSearch ? d.item_name.toLowerCase().includes(debouncedSearch.toLowerCase()) : true);
const baseRows = (tab === searchTabIndex ? allInv : data) ?? [];
const filtered = baseRows.filter(d => debouncedSearch ? d.item_name.toLowerCase().includes(debouncedSearch.toLowerCase()) : true);
const sortedRows = useMemo(()=>{
if(sortKey==='slot') return filtered;
const copy = [...filtered];
if(sortKey==='name') copy.sort((a,b)=>a.item_name.localeCompare(b.item_name));
else if(sortKey==='type') copy.sort((a,b)=>((a as any).type_description ?? '').localeCompare(((b as any).type_description ?? '')) || a.item_name.localeCompare(b.item_name));
return copy;
}, [filtered, sortKey]);
let tempId = -1;
let gridItems: GridItem[] = (filtered ?? []).map((d: any) => ({
isScroll: d.type_description === 'SCROLL',
storages: duplicates.has(d.item_name) ? Array.from((allInv?.filter(i=>i.item_name===d.item_name).map(i=>i.storage_type.toUpperCase())??[])) : undefined,
id: d.id ?? tempId--,
storage_type: d.storage_type,
name: d.item_name,
quantity: 'quantity' in d ? d.quantity : undefined,
// Determine icon URL with prioritized fallback logic when missing icon_id
iconUrl: (d as any).icon_id
? `/api/icon/${d.icon_id}`
: (() => {
const nameLower = d.item_name.toLowerCase();
if (nameLower.includes("map")) return mapIcon;
if (d.item_name.includes("♪")) return mountIcon;
if ((d.type_description ?? "").toUpperCase().includes("KEY")) return keyIcon;
return noIcon;
})(),
icon_id: d.icon_id,
}));
const seenIds = new Set<number>();
let gridItems: GridItem[] = [];
for (const row of sortedRows) {
const realId: number | undefined = (row as any).id;
if (realId !== undefined) {
if (seenIds.has(realId)) {
continue; // skip duplicates
}
seenIds.add(realId);
}
const effectiveId = realId ?? tempId--;
gridItems.push({
isScroll: (row as any).type_description === 'SCROLL',
storages: duplicates.has(row.item_name)
? Array.from(
(allInv?.filter(i => i.item_name === row.item_name).map(i => i.storage_type.toUpperCase()) ?? [])
)
: undefined,
id: effectiveId,
storage_type: (row as any).storage_type,
name: row.item_name,
quantity: 'quantity' in row ? (row as any).quantity : undefined,
iconUrl: (row as any).icon_id
? `/api/icon/${(row as any).icon_id}`
: (() => {
const nameLower = row.item_name.toLowerCase();
if (nameLower.includes('map')) return mapIcon;
if (row.item_name.includes('♪')) return mountIcon;
if (((row as any).type_description ?? '').toUpperCase().includes('KEY')) return keyIcon;
return noIcon;
})(),
icon_id: (row as any).icon_id,
});
}
// Pad to 80 slots with empty placeholders (skip for Search tab)
if (tab !== searchTabIndex && gridItems.length < 80) {
@@ -133,28 +172,93 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
}
}
const queryClient = useQueryClient();
const importMutation = useMutation({
mutationFn: importInventoryCsv,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
setSnack({ open: true, message: "Inventory imported!" });
},
onError: () => {
setSnack({ open: true, message: "Failed to import CSV" });
},
});
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [snack, setSnack] = useState<{ open: boolean; message: string }>({
open: false,
message: "",
});
const theme = useTheme();
const isNarrow = useMediaQuery(theme.breakpoints.down('md'));
return (
<Box sx={{ px: 2 }}>
<Box sx={{ display:'flex', alignItems:'center', gap:2 }}>
{(() => {
const colors = ['#ef5350','#ab47bc','#5c6bc0','#29b6f6','#66bb6a','#ffca28','#ffa726','#8d6e63'];
const getColor = (idx:number)=>colors[idx%colors.length];
<Box sx={{ display:'flex', alignItems:'center', gap:2, flexWrap: 'wrap' }}>
{isNarrow ? (
<FormControl size="small" sx={{ minWidth:120 }}>
<Select
value={tab}
onChange={(e)=>setTab(e.target.value as number)}
>
{storageTypes.map((s,idx)=> (
<MenuItem key={s} value={idx}>{s.toUpperCase()}</MenuItem>
))}
{searchMode && <MenuItem value={searchTabIndex}>Search</MenuItem>}
</Select>
</FormControl>
) : (() => {
const getColor = () => inventoryColor;
return (
<Tabs
value={tab}
onChange={(_,v)=>setTab(v)}
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(tab)} }}
TabIndicatorProps={{ sx:{ backgroundColor:inventoryColor } }}
sx={{ bgcolor:'background.paper' }}
>
{storageTypes.map((s,idx)=>(
<Tab key={s} label={s} sx={{ color:getColor(idx),'&.Mui-selected':{color:getColor(idx)} }} />
<Tab key={s} label={s} sx={{ color:inventoryColor,'&.Mui-selected':{color:inventoryColor} }} />
))}
{searchMode && <Tab label="Search" sx={{ color:'#90a4ae','&.Mui-selected':{color:'#90a4ae'} }} />}
</Tabs>
);
})()}
<TextField
{/* Upload CSV Button */}
<input
type="file"
accept=".csv,text/csv"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
}}
/>
<Button
size="small"
variant="contained"
startIcon={<UploadFileIcon />}
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
sx={{ bgcolor: inventoryColor, '&:hover':{ bgcolor: inventoryColor }, whiteSpace:'nowrap' }}
>
{importMutation.isPending ? "Uploading…" : "Import CSV"}
</Button>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={sortKey}
onChange={(e)=>setSortKey(e.target.value as any)}
>
<MenuItem value="slot">Slot</MenuItem>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="type">Type</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
variant="outlined"
placeholder="Search…"
@@ -187,6 +291,13 @@ export default function InventoryPage({ storageTypes, character = "Rynore" }: Pr
item={selected}
onClose={() => setSelected(null)}
/>
<Snackbar
open={snack.open}
autoHideDuration={3000}
onClose={() => setSnack({ ...snack, open: false })}
message={snack.message}
/>
</Box>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from "react";
import { RawItem } from "../utils/filterExplorerItems";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import InputAdornment from "@mui/material/InputAdornment";
@@ -7,6 +8,7 @@ import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Pagination from "@mui/material/Pagination";
import { explorerColor } from "../constants/colors";
import { useQuery } from "@tanstack/react-query";
import useDebounce from "../hooks/useDebounce";
import ItemsGrid, { GridItem } from "../components/ItemsGrid";
@@ -20,6 +22,7 @@ interface Props {
export default function ItemExplorerPage({ typeDescriptions }: Props) {
const [tab, setTab] = useState(0);
const [subTab, setSubTab] = useState(0);
const [selectedJobs, setSelectedJobs] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [search, setSearch] = useState("");
@@ -52,15 +55,24 @@ export default function ItemExplorerPage({ typeDescriptions }: Props) {
const currentSubType =
currentType === "USABLE_ITEM" ? subTypeValues[subTab] : currentType;
const armorJobs = useMemo(
() =>
"WAR,MNK,WHM,BLM,RDM,THF,PLD,DRK,BST,BRD,RNG,SAM,NIN,DRG,SMN,BLU,COR,PUP,DNC,SCH,GEO,RUN".split(
","
),
[]
);
// Reset pagination when type changes
useEffect(() => {
setPage(1);
setTotalPages(1);
setSearch("");
setSelectedJobs([]);
}, [currentType, currentSubType]);
const { data, isLoading } = useQuery({
queryKey: ["items", currentType === "MISC" ? "MISC" : currentSubType, page, debouncedSearch],
queryKey: ["items", currentType === "MISC" ? "MISC" : currentSubType, page, debouncedSearch, selectedJobs],
queryFn: async () => {
let url = `/items?page=${page}&page_size=100`;
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
@@ -68,45 +80,92 @@ export default function ItemExplorerPage({ typeDescriptions }: Props) {
url = `/items?type=${encodeURIComponent(currentSubType)}&page=${page}&page_size=100`;
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`;
}
// Add jobs to the query if we're on the ARMOR tab and jobs are selected
if (currentType === 'ARMOR' && selectedJobs.length > 0) {
const jobsParam = selectedJobs.join(',');
url += `&jobs=${jobsParam}`;
}
const response = await api.get(url);
const totalCountHeader = response.headers['x-total-count'] ?? response.headers['X-Total-Count'];
if (totalCountHeader) {
const total = parseInt(totalCountHeader, 10);
if (!isNaN(total)) {
setTotalPages(Math.max(1, Math.ceil(total / 100)));
}
setTotalPages(Math.ceil(total / 100));
}
return response.data as { id: number; name: string; icon_id?: string; type_description?: string }[];
return response.data;
},
enabled: !!currentType,
});
const gridItems: GridItem[] | undefined = data
?.filter((d) => d.name !== '.' && (currentType !== 'MISC' ? true : !baseTypes.includes(d.type_description ?? '')))
?.filter((d: RawItem) => {
if (d.name === '.') return false;
// When the user is actively searching, don't hide any server results let the backend decide.
if (debouncedSearch.trim() !== '') return true;
// Otherwise, apply the MISC filter rules as before.
if (currentType !== 'MISC') return true;
return !baseTypes.includes(d.type_description ?? '');
})
// Apply client-side filtering for jobs_description if we're on the ARMOR tab
.filter((d: RawItem) => {
if (currentType !== 'ARMOR' || selectedJobs.length === 0) return true;
// Debug: Log the item being filtered when jobs are selected
if (selectedJobs.length > 0) {
console.log('Filtering item:', d.name, 'jobs_description:', d.jobs_description);
}
// If the item has a jobs_description property, check if any selected job is in the array
// or if it contains 'ALL'
if (d.jobs_description) {
const jobsList = Array.isArray(d.jobs_description) ? d.jobs_description : [];
const shouldInclude = jobsList.includes('ALL') || selectedJobs.some(job => jobsList.includes(job));
if (selectedJobs.length > 0) {
console.log(`Item ${d.name}: ${shouldInclude ? 'INCLUDED' : 'EXCLUDED'}, matches: ${selectedJobs.filter(job => jobsList.includes(job))}`);
}
return shouldInclude;
}
// If no jobs_description property exists, we'll still show the item when no specific filter is selected
return true;
})
.map((d) => ({
id: d.id,
name: d.name,
iconUrl: d.icon_id ? `/api/icon/${d.icon_id}` : undefined,
icon_id: d.icon_id,
}));
id: d.id,
name: d.name,
iconUrl: d.icon_id ? `/api/icon/${d.icon_id}` : undefined,
icon_id: d.icon_id,
jobs_description: d.jobs_description,
}));
return (
<Box sx={{ px: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{(() => {
const colors = ['#ff7043','#42a5f5','#66bb6a','#ab47bc','#5c6bc0','#ffa726'];
const getColor=(idx:number)=>colors[idx%colors.length];
const getColor = () => explorerColor;
return (
<Tabs
value={tab}
onChange={(_,v)=>{ setTab(v); setPage(1);} }
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(tab)} }}
TabIndicatorProps={{ sx:{ backgroundColor:explorerColor } }}
sx={{ flexGrow:1 }}
>
{displayTypes.map((t,idx)=>{
const label = t.replace('_ITEM','');
return <Tab key={t} label={label} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
return <Tab key={t} label={label} sx={{ color:explorerColor, '&.Mui-selected':{color:explorerColor} }} />
})}
</Tabs>
);
@@ -127,23 +186,74 @@ export default function ItemExplorerPage({ typeDescriptions }: Props) {
/>
</Box>
{/* Job filters for Armor items */}
{currentType === "ARMOR" && (
<Box sx={{ mt: 1, p: 1, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Box
onClick={() => setSelectedJobs([])}
sx={{
cursor: 'pointer',
py: 0.5,
px: 1,
borderRadius: 1,
bgcolor: selectedJobs.length === 0 ? `${explorerColor}22` : 'transparent',
border: `1px solid ${explorerColor}`,
color: explorerColor,
'&:hover': {
bgcolor: `${explorerColor}11`,
},
}}
>
ALL
</Box>
{armorJobs.map((jobCode) => (
<Box
key={jobCode}
onClick={() => {
if (selectedJobs.includes(jobCode)) {
setSelectedJobs(selectedJobs.filter((j) => j !== jobCode));
} else {
setSelectedJobs([...selectedJobs, jobCode]);
}
}}
sx={{
cursor: 'pointer',
py: 0.5,
px: 1,
borderRadius: 1,
bgcolor: selectedJobs.includes(jobCode) ? `${explorerColor}22` : 'transparent',
border: `1px solid ${explorerColor}`,
color: explorerColor,
'&:hover': {
bgcolor: `${explorerColor}11`,
},
}}
>
{jobCode}
</Box>
))}
</Box>
</Box>
)}
{/* Sub-tabs for usable types */}
{currentType === "USABLE_ITEM" && (
<Box>
{(() => {
const colors = ['#ef5350','#ab47bc','#5c6bc0','#29b6f6','#66bb6a','#ffca28','#ffa726','#8d6e63'];
const getColor=(idx:number)=>colors[idx%colors.length];
const getColor = () => explorerColor;
return (
<Tabs
value={subTab}
onChange={(_,v)=>setSubTab(v)}
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(subTab)} }}
TabIndicatorProps={{ sx:{ backgroundColor:explorerColor } }}
sx={{ bgcolor:'background.paper', mt:1 }}
>
{subTypeValues.map((s,idx)=>{
const label = s === 'ITEM' ? 'MISC' : s.replace('_ITEM','');
return <Tab key={s} label={label} sx={{ color:getColor(idx),'&.Mui-selected':{color:getColor(idx)} }} />
return <Tab key={s} label={label} sx={{ color:explorerColor,'&.Mui-selected':{color:explorerColor} }} />
})}
</Tabs>
);

View File

@@ -5,6 +5,7 @@ import Tab from "@mui/material/Tab";
import CircularProgress from "@mui/material/CircularProgress";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import ToggleButton from "@mui/material/ToggleButton";
import { recipesColor, craftColors } from "../constants/colors";
import { useQuery } from "@tanstack/react-query";
import RecipesDetailTable from "../components/RecipesDetailTable";
import { api } from "../api";
@@ -114,17 +115,15 @@ export default function RecipesPage({ crafts }: Props) {
<Box>
{/* Craft tabs */}
{(() => {
const colors = ['#66bb6a','#42a5f5','#ffa726','#ab47bc','#5c6bc0','#ff7043','#8d6e63'];
const getColor=(idx:number)=>colors[idx%colors.length];
return (
<Tabs
value={craftIdx}
onChange={(_,v)=>{setCraftIdx(v); setCatIdx(0);} }
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(craftIdx)} }}
TabIndicatorProps={{ sx:{ backgroundColor: craftColors[currentCraft] || recipesColor } }}
>
{crafts.map((c,idx)=>(
<Tab key={c} label={c} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
<Tab key={c} label={c} sx={{ color: craftColors[c] || recipesColor, '&.Mui-selected':{color: craftColors[c] || recipesColor} }} />
))}
</Tabs>
);
@@ -145,18 +144,16 @@ export default function RecipesPage({ crafts }: Props) {
{/* Category sub-tabs */}
{(() => {
const colors = ['#ef5350','#ab47bc','#5c6bc0','#29b6f6','#66bb6a','#ffca28','#ffa726','#8d6e63'];
const getColor=(idx:number)=>colors[idx%colors.length];
return (
<Tabs
value={catIdx}
onChange={(_,v)=>setCatIdx(v)}
variant="scrollable"
TabIndicatorProps={{ sx:{ backgroundColor:getColor(catIdx)} }}
TabIndicatorProps={{ sx:{ backgroundColor:craftColors[currentCraft] || recipesColor } }}
sx={{ bgcolor:'background.paper' }}
>
{categories.map((cat,idx)=>(
<Tab key={cat} label={cat} sx={{ color:getColor(idx), '&.Mui-selected':{color:getColor(idx)} }} />
<Tab key={cat} label={cat} sx={{ color:craftColors[currentCraft] || recipesColor, '&.Mui-selected':{color:craftColors[currentCraft] || recipesColor} }} />
))}
</Tabs>
);

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,39 @@
import type { GridItem } from '../components/ItemsGrid';
export interface RawItem {
id: number;
name: string;
icon_id?: string;
type_description?: string;
jobs_description?: string[];
}
/**
* Replicates client-side filtering logic in ItemExplorerPage.
* @param data list returned from `/items` API
* @param currentType current selected high-level type (e.g. "MISC", "USABLE_ITEM")
* @param baseTypes array of base types derived from metadata
* @param search current (debounced) search string
*/
export function filterExplorerItems(
data: RawItem[] | undefined,
currentType: string,
baseTypes: string[],
search: string
): GridItem[] | undefined {
if (!data) return undefined;
return data
.filter((d) => {
if (d.name === '.') return false;
if (search.trim() !== '') return true; // no extra filter while searching
if (currentType !== 'MISC') return true;
return !baseTypes.includes(d.type_description ?? '');
})
.map((d) => ({
id: d.id,
name: d.name,
iconUrl: d.icon_id ? `/api/icon/${d.icon_id}` : undefined,
icon_id: d.icon_id,
}));
}

View File

@@ -14,7 +14,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"types": ["vitest/globals"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

11
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
globals: true,
},
});