minor redesign inventory + add functions

This commit is contained in:
aurinex
2025-12-17 00:12:23 +05:00
parent 70ec57d6fb
commit 24423173a6

View File

@ -1,16 +1,5 @@
import { useEffect, useState } from 'react';
import {
Box,
Typography,
Grid,
Button,
FormControl,
Select,
MenuItem,
InputLabel,
Tooltip,
Paper,
} from '@mui/material';
import { Box, Typography, Grid, Button, Paper } from '@mui/material';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { translateServer } from '../utils/serverTranslator';
@ -20,6 +9,8 @@ import {
type InventoryRawItem,
type InventoryItemsResponse,
} from '../api';
import CustomTooltip from '../components/Notifications/CustomTooltip';
import { getPlayerServer } from '../utils/playerOnlineCheck';
const KNOWN_SERVER_IPS = [
'minecraft.hub.popa-popa.ru',
@ -32,22 +23,75 @@ function stripMinecraftColors(text?: string | null): string {
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
}
const Glass = ({ children }: { children: React.ReactNode }) => (
<Paper
elevation={0}
sx={{
borderRadius: '1.2vw',
overflow: 'hidden',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
color: 'white',
}}
>
<Box sx={{ p: '1.8vw' }}>{children}</Box>
</Paper>
);
const CARD_BG =
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)';
const CardFacePaperSx = {
borderRadius: '1.2vw',
background: CARD_BG,
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
color: 'white',
} as const;
function readLauncherConfig(): any {
try {
const raw = localStorage.getItem('launcher_config');
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function writeLauncherConfig(next: any) {
localStorage.setItem('launcher_config', JSON.stringify(next));
}
function getLayout(username: string, serverIp: string): Record<string, number> {
const cfg = readLauncherConfig();
return cfg?.inventory_layout?.[username]?.[serverIp] ?? {};
}
function setLayout(username: string, serverIp: string, layout: Record<string, number>) {
const cfg = readLauncherConfig();
cfg.inventory_layout ??= {};
cfg.inventory_layout[username] ??= {};
cfg.inventory_layout[username][serverIp] = layout;
writeLauncherConfig(cfg);
}
function buildSlots(
items: InventoryRawItem[],
layout: Record<string, number>,
size = 28,
): (InventoryRawItem | null)[] {
const slots: (InventoryRawItem | null)[] = Array.from({ length: size }, () => null);
const byId = new Map(items.map((it) => [it.id, it]));
const used = new Set<string>();
// 1) ставим туда, куда сохранено
for (const [id, idx] of Object.entries(layout)) {
const i = Number(idx);
const it = byId.get(id);
if (!it) continue;
if (Number.isFinite(i) && i >= 0 && i < size && !slots[i]) {
slots[i] = it;
used.add(id);
}
}
// 2) остальные — в первые пустые
for (const it of items) {
if (used.has(it.id)) continue;
const empty = slots.findIndex((x) => x === null);
if (empty === -1) break;
slots[empty] = it;
used.add(it.id);
}
return slots;
}
export default function Inventory() {
const [username, setUsername] = useState('');
@ -60,7 +104,72 @@ export default function Inventory() {
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [withdrawingIds, setWithdrawingIds] = useState<string[]>([]);
const isServerSelectVisible = availableServers.length > 1;
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [isOnline, setIsOnline] = useState(false);
const [playerServer, setPlayerServer] = useState<string | null>(null);
const [checkingOnline, setCheckingOnline] = useState(false);
const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
type SlotItem = InventoryRawItem | null;
const [slots, setSlots] = useState<SlotItem[]>(() => Array.from({ length: 28 }, () => null));
const [draggedFromIndex, setDraggedFromIndex] = useState<number | null>(null);
useEffect(() => {
const handleMove = (e: MouseEvent) => {
if (!draggedItemId) return;
setDragPos({ x: e.clientX, y: e.clientY });
};
const handleUp = () => {
if (
draggedItemId &&
draggedFromIndex !== null &&
dragOverIndex !== null &&
draggedFromIndex !== dragOverIndex
) {
setSlots((prev) => {
const next = [...prev];
const moving = next[draggedFromIndex];
if (!moving) return prev;
// ✅ swap или move в пустоту
const target = next[dragOverIndex];
next[dragOverIndex] = moving;
next[draggedFromIndex] = target ?? null;
// ✅ сохраняем layout (позиции предметов)
const layout: Record<string, number> = {};
next.forEach((it, idx) => {
if (it) layout[it.id] = idx;
});
if (username && selectedServerIp) {
setLayout(username, selectedServerIp, layout);
}
return next;
});
}
setDraggedItemId(null);
setDraggedFromIndex(null);
setDragOverIndex(null);
setDragPos(null);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
}, [draggedItemId, dragOverIndex]);
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
@ -82,19 +191,24 @@ export default function Inventory() {
}),
);
const servers = checks
.filter((r): r is PromiseFulfilledResult<{ ip: string; has: boolean }> => r.status === 'fulfilled')
return checks
.filter(
(r): r is PromiseFulfilledResult<{ ip: string; has: boolean }> => r.status === 'fulfilled',
)
.filter((r) => r.value.has)
.map((r) => r.value.ip);
return servers;
};
const loadInventory = async (u: string, ip: string, p: number) => {
const res: InventoryItemsResponse = await fetchInventoryItems(u, ip, p, limit);
const list = res.items || [];
setItems(res.items || []);
setTotal(res.total ?? 0);
setPages(Math.max(1, Math.ceil((res.total ?? 0) / (res.limit ?? limit))));
const layout = getLayout(u, ip);
setSlots(buildSlots(list, layout, 28));
};
useEffect(() => {
@ -164,11 +278,21 @@ export default function Inventory() {
};
}, [selectedServerIp]);
const withWithdrawing = async (id: string, fn: () => Promise<void>) => {
setWithdrawingIds((prev) => [...prev, id]);
try {
await fn();
} finally {
setWithdrawingIds((prev) => prev.filter((x) => x !== id));
}
};
const handleWithdraw = async (item: InventoryRawItem) => {
if (!username || !selectedServerIp) return;
// сервер в UI может не совпадать — оставим защиту
if (selectedServerIp !== item.server_ip) {
alert("Ошибка! Вы не на том сервере для выдачи этого предмета.");
alert('Ошибка! Вы не на том сервере для выдачи этого предмета.');
return;
}
@ -181,24 +305,48 @@ export default function Inventory() {
});
setItems((prevItems) => prevItems.filter((prevItem) => prevItem.id !== item.id));
setSlots((prev) => {
const next = prev.map((x) => (x?.id === item.id ? null : x));
const layout: Record<string, number> = {};
next.forEach((it, idx) => {
if (it) layout[it.id] = idx;
});
setLayout(username, selectedServerIp, layout);
return next;
});
} catch (e) {
console.error("Ошибка при выводе предмета:", e);
console.error('Ошибка при выводе предмета:', e);
}
});
};
const withWithdrawing = async (id: string, fn: () => Promise<void>) => {
setWithdrawingIds((prev) => [...prev, id]);
const checkPlayerStatus = async () => {
if (!username) return;
setCheckingOnline(true);
try {
await fn();
const res = await getPlayerServer(username);
setIsOnline(!!res?.online);
setPlayerServer(res?.server?.ip ?? null);
} catch (e) {
console.error('Ошибка проверки онлайна:', e);
setIsOnline(false);
setPlayerServer(null);
} finally {
setWithdrawingIds((prev) => prev.filter((x) => x !== id));
setCheckingOnline(false);
}
};
const headerServerName = selectedServerIp
? translateServer(`Server ${selectedServerIp}`)
: '';
useEffect(() => {
if (!username) return;
checkPlayerStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username]);
const headerServerName = selectedServerIp ? translateServer(`Server ${selectedServerIp}`) : '';
if (!username) {
return (
@ -253,42 +401,51 @@ export default function Inventory() {
>
{loading && <FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', justifyContent: 'space-evenly', flexDirection: 'row-reverse' }}>
{/* Пагинация */}
{/* ШАПКА + ПАГИНАЦИЯ */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: 'wrap',
justifyContent: 'space-evenly',
flexDirection: 'row-reverse',
}}
>
{!!selectedServerIp && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
variant="outlined"
disabled={!canPrev || loading}
onClick={handlePrev}
sx={{
variant="outlined"
disabled={!canPrev || loading}
onClick={handlePrev}
sx={{
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
color: 'white',
border: '1px solid rgba(255,255,255,0.15)',
}}
}}
>
Назад
Назад
</Button>
<Typography sx={{ color: 'rgba(255,255,255,0.7)' }}>
Страница {page} / {pages} Всего: {total}
Страница {page} / {pages} Всего: {total}
</Typography>
<Button
variant="outlined"
disabled={!canNext || loading}
onClick={handleNext}
sx={{
variant="outlined"
disabled={!canNext || loading}
onClick={handleNext}
sx={{
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
color: 'white',
border: '1px solid rgba(255,255,255,0.15)',
}}
}}
>
Вперёд
Вперёд
</Button>
</Box>
</Box>
)}
<Typography
@ -303,57 +460,284 @@ export default function Inventory() {
>
Инвентарь {headerServerName ? `${headerServerName}` : ''}
</Typography>
</Box>
{/* GRID */}
<Grid container spacing={2}>
{Array.from({ length: 28 }).map((_, index) => {
const item = items[index];
const item = slots[index];
// ПУСТАЯ ЯЧЕЙКА
if (!item) {
return (
<Grid
item
xs={3}
key={index}
onMouseEnter={() => {
if (draggedItemId) setDragOverIndex(index);
}}
sx={{
outline:
draggedItemId && dragOverIndex === index
? '2px dashed rgba(255,255,255,0.4)'
: 'none',
}}
>
<Paper
elevation={0}
sx={{
...CardFacePaperSx,
overflow: 'hidden',
width: '12vw',
height: '12vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.5)',
}}
>
<Typography>Пусто</Typography>
</Paper>
</Grid>
);
}
const displayNameRaw =
item.item_data?.meta?.display_name ?? item.item_data?.material ?? 'Предмет';
const displayName = stripMinecraftColors(displayNameRaw);
const amount =
(item as any)?.amount ??
(item as any)?.item_data?.amount ??
(item as any)?.item_data?.meta?.amount ??
1;
const isHovered = hoveredId === item.id;
const isWithdrawing = withdrawingIds.includes(item.id);
// ✅ проверка: игрок реально онлайн на нужном сервере
const isOnRightServer = isOnline && playerServer === item.server_ip;
const canWithdraw = isOnRightServer && !loading && !checkingOnline && !isWithdrawing;
const texture = item.item_data?.material
? `https://cdn.minecraft.popa-popa.ru/textures/${item.item_data.material.toLowerCase()}.png`
: '';
return (
<Grid item xs={3} key={index}>
<Glass>
<Grid
item
xs={3}
key={item.id}
onMouseEnter={() => {
if (draggedItemId) setDragOverIndex(index);
}}
sx={{
outline:
draggedItemId && dragOverIndex === index
? '2px dashed rgba(255,255,255,0.4)'
: 'none',
}}
>
<Box
sx={{
width: '100%',
perspective: '1200px',
cursor: draggedItemId === item.id ? 'grabbing' : 'grab',
opacity: draggedItemId === item.id ? 0.4 : 1,
}}
onMouseEnter={() => setHoveredId(item.id)}
onMouseLeave={() => setHoveredId(null)}
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (target.closest('button')) return;
e.preventDefault();
setDraggedItemId(item.id);
setDraggedFromIndex(index);
setDragPos({ x: e.clientX, y: e.clientY });
}}
>
<Box
sx={{
width: '8vw',
height: '8vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
borderRadius: '5px',
boxShadow: item ? '0 10px 40px rgba(0,0,0,0.3)' : 'none',
'&:hover': {
transform: 'scale(1.1)',
cursor: item ? 'pointer' : 'default',
},
transition: 'all 0.25s ease',
width: '12vw',
height: '12vw', // фиксированная высота = Grid не дергается
transformStyle: 'preserve-3d',
transition: 'transform 0.5s cubic-bezier(0.4, 0.2, 0.2, 1)',
transform: isHovered ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
onClick={() => item && handleWithdraw(item)}
>
{item ? (
<Tooltip
title={`${item.item_data?.meta?.display_name}\n${item.item_data?.material}`}
placement="top"
{/* FRONT */}
<Paper
elevation={0}
sx={{
...CardFacePaperSx,
position: 'absolute',
inset: 0,
backfaceVisibility: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<Box
component="img"
src={texture}
sx={{
width: '5vw',
height: '5vw',
objectFit: 'contain',
imageRendering: 'pixelated',
userSelect: 'none',
transition: 'transform 0.25s ease',
transform: isHovered ? 'scale(1.05)' : 'scale(1)',
}}
draggable={false}
alt={displayName}
/>
</Paper>
{/* BACK */}
<Paper
elevation={0}
sx={{
...CardFacePaperSx,
position: 'absolute',
inset: 0,
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '0.8vw',
px: '1.1vw',
overflow: 'hidden',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.8vw',
lineHeight: 1.2,
textAlign: 'center',
wordBreak: 'break-word',
}}
>
<Box
component="img"
src={`https://cdn.minecraft.popa-popa.ru/textures/${item.item_data?.material.toLowerCase()}.png`}
sx={{
width: '60%',
height: '60%',
objectFit: 'contain',
imageRendering: 'pixelated',
{displayName}
</Typography>
<Typography
sx={{
fontSize: '0.7vw',
textAlign: 'center',
color: 'rgba(255,255,255,0.7)',
}}
>
Кол-во: {amount}
</Typography>
{!isOnRightServer ? (
<CustomTooltip
essential
title={
!isOnline
? 'Вы должны быть онлайн на сервере'
: `Перейдите на сервер ${item.server_ip}`
}
placement="top"
arrow
>
<span>
<Button
disabled
fullWidth
variant="outlined"
sx={{
fontSize: '0.8vw',
borderRadius: '2vw',
fontFamily: 'Benzin-Bold',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.5)',
}}
>
Выдать
</Button>
</span>
</CustomTooltip>
) : (
<Button
fullWidth
variant="outlined"
disabled={!canWithdraw}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleWithdraw(item);
}}
/>
</Tooltip>
) : (
<Typography sx={{ color: 'rgba(255,255,255,0.5)' }}>Пусто</Typography>
)}
sx={{
fontSize: '0.8vw',
borderRadius: '2vw',
fontFamily: 'Benzin-Bold',
border: '1px solid rgba(255,255,255,0.15)',
color: 'white',
}}
>
{isWithdrawing ? 'Выдача...' : 'Выдать'}
</Button>
)}
</Paper>
</Box>
</Glass>
</Box>
</Grid>
);
})}
</Grid>
</Box>
{draggedItemId && dragPos && (() => {
const draggedItem = items.find(i => i.id === draggedItemId);
if (!draggedItem) return null;
const texture = `https://cdn.minecraft.popa-popa.ru/textures/${draggedItem.item_data.material.toLowerCase()}.png`;
return (
<Box
sx={{
position: 'fixed',
left: dragPos.x,
top: dragPos.y,
transform: 'translate(-50%, -50%)',
//width: '12vw',
//height: '12vw',
pointerEvents: 'none',
zIndex: 9999,
opacity: 0.9,
}}
>
<Paper
elevation={0}
sx={{
...CardFacePaperSx,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '12vw',
height: '12vw',
}}
>
<Box
component="img"
src={texture}
sx={{
width: '5vw',
height: '5vw',
imageRendering: 'pixelated',
}}
/>
</Paper>
</Box>
);
})()}
</Box>
);
}