Lots of stuff
This commit is contained in:
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal 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
22
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
2365
frontend/package-lock.json
generated
2365
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
25
frontend/src/__tests__/filterExplorerItems.test.ts
Normal file
25
frontend/src/__tests__/filterExplorerItems.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}` : ""}`}
|
||||
|
||||
30
frontend/src/components/MobileNav.tsx
Normal file
30
frontend/src/components/MobileNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/constants/colors.ts
Normal file
27
frontend/src/constants/colors.ts
Normal 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
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
1
frontend/src/setupTests.ts
Normal file
1
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
39
frontend/src/utils/filterExplorerItems.ts
Normal file
39
frontend/src/utils/filterExplorerItems.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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
11
frontend/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user