Files
popa-launcher/src/renderer/pages/Inventory.tsx
2025-12-17 13:16:59 +05:00

814 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { Box, Typography, Grid, Button, Paper, FormControl, Select, MenuItem, InputLabel } from '@mui/material';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { translateServer } from '../utils/serverTranslator';
import {
fetchInventoryItems,
withdrawInventoryItem,
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',
'minecraft.survival.popa-popa.ru',
'minecraft.minigames.popa-popa.ru',
];
const STORAGE_KEY = 'inventory_layout';
function stripMinecraftColors(text?: string | null): string {
if (!text) return '';
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
}
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 readInventoryLayout(): any {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function writeInventoryLayout(next: any) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
function getLayout(username: string, serverIp: string): Record<string, number> {
const inv = readInventoryLayout();
return inv?.[username]?.[serverIp] ?? {};
}
function setLayout(username: string, serverIp: string, layout: Record<string, number>) {
const inv = readInventoryLayout();
inv[username] ??= {};
inv[username][serverIp] = layout;
writeInventoryLayout(inv);
}
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('');
const [loading, setLoading] = useState(true);
const [availableServers, setAvailableServers] = useState<string[]>([]);
const [selectedServerIp, setSelectedServerIp] = useState<string>('');
const [items, setItems] = useState<InventoryRawItem[]>([]);
const [page, setPage] = useState(1);
const limit = 28;
const [total, setTotal] = useState(0);
const [pages, setPages] = useState(1);
const [withdrawingIds, setWithdrawingIds] = useState<string[]>([]);
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');
if (!savedConfig) {
setLoading(false);
return;
}
const config = JSON.parse(savedConfig);
if (config?.username) setUsername(config.username);
setLoading(false);
}, []);
const detectServersWithItems = async (u: string) => {
const checks = await Promise.allSettled(
KNOWN_SERVER_IPS.map(async (ip) => {
const res = await fetchInventoryItems(u, ip, 1, 1);
return { ip, has: (res.items || []).length > 0 || (res.total ?? 0) > 0 };
}),
);
return checks
.filter(
(r): r is PromiseFulfilledResult<{ ip: string; has: boolean }> => r.status === 'fulfilled',
)
.filter((r) => r.value.has)
.map((r) => r.value.ip);
};
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(() => {
if (!username) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
const servers = await detectServersWithItems(username);
if (cancelled) return;
setAvailableServers(servers);
const defaultIp = servers[0] || '';
setSelectedServerIp(defaultIp);
setPage(1);
if (defaultIp) {
await loadInventory(username, defaultIp, 1);
} else {
setItems([]);
setTotal(0);
setPages(1);
}
} catch (e) {
console.error(e);
setAvailableServers([]);
setSelectedServerIp('');
setItems([]);
setTotal(0);
setPages(1);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [username]);
useEffect(() => {
if (!username || !selectedServerIp) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
setPage(1);
await loadInventory(username, selectedServerIp, 1);
} catch (e) {
console.error(e);
setItems([]);
setTotal(0);
setPages(1);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [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('Ошибка! Вы не на том сервере для выдачи этого предмета.');
return;
}
await withWithdrawing(item.id, async () => {
try {
await withdrawInventoryItem({
username,
item_id: item.id,
server_ip: selectedServerIp,
});
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);
}
});
};
const checkPlayerStatus = async () => {
if (!username) return;
setCheckingOnline(true);
try {
const res = await getPlayerServer(username);
setIsOnline(!!res?.online);
setPlayerServer(res?.server?.ip ?? null);
} catch (e) {
console.error('Ошибка проверки онлайна:', e);
setIsOnline(false);
setPlayerServer(null);
} finally {
setCheckingOnline(false);
}
};
useEffect(() => {
if (!username) return;
checkPlayerStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username]);
const headerServerName = selectedServerIp ? translateServer(`Server ${selectedServerIp}`) : '';
if (!username) {
return (
<Box sx={{ p: '2vw' }}>
<Typography sx={{ color: 'rgba(255,255,255,0.75)' }}>
Не найдено имя игрока. Авторизуйтесь в лаунчере.
</Typography>
</Box>
);
}
const canPrev = page > 1;
const canNext = page < pages;
const handlePrev = async () => {
if (!canPrev || !username || !selectedServerIp) return;
const nextPage = page - 1;
setPage(nextPage);
setLoading(true);
try {
await loadInventory(username, selectedServerIp, nextPage);
} finally {
setLoading(false);
}
};
const handleNext = async () => {
if (!canNext || !username || !selectedServerIp) return;
const nextPage = page + 1;
setPage(nextPage);
setLoading(true);
try {
await loadInventory(username, selectedServerIp, nextPage);
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
overflow: 'auto',
px: '2.5vw',
py: '2vw',
gap: 2,
mt: '12vh',
}}
>
{/* ШАПКА + ПАГИНАЦИЯ */}
<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 }}>
<Button
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}
</Typography>
<Button
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>
)}
{availableServers.length > 0 && (
<FormControl size="small" sx={{ minWidth: 260 }}>
<InputLabel
id="inventory-server-label"
sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.75)' }}
>
Сервер
</InputLabel>
<Select
labelId="inventory-server-label"
label="Сервер"
value={selectedServerIp}
onChange={(e) => setSelectedServerIp(String(e.target.value))}
MenuProps={{
PaperProps: {
sx: {
bgcolor: 'rgba(10,10,20,0.96)',
border: '1px solid rgba(255,255,255,0.10)',
borderRadius: '1vw',
backdropFilter: 'blur(14px)',
'& .MuiMenuItem-root': {
color: 'rgba(255,255,255,0.9)',
fontFamily: 'Benzin-Bold',
},
'& .MuiMenuItem-root.Mui-selected': {
backgroundColor: 'rgba(242,113,33,0.16)',
},
'& .MuiMenuItem-root:hover': {
backgroundColor: 'rgba(233,64,205,0.14)',
},
},
},
}}
sx={{
borderRadius: '999px',
bgcolor: 'rgba(255,255,255,0.04)',
color: 'rgba(255,255,255,0.92)',
fontFamily: 'Benzin-Bold',
'& .MuiSelect-select': {
py: '0.9vw',
px: '1.2vw',
},
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(255,255,255,0.14)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(242,113,33,0.55)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgba(233,64,205,0.65)',
borderWidth: '2px',
},
'& .MuiSelect-icon': {
color: 'rgba(255,255,255,0.75)',
},
}}
>
{availableServers.map((ip) => (
<MenuItem key={ip} value={ip}>
{translateServer(`Server ${ip}`)}
</MenuItem>
))}
</Select>
</FormControl>
)}
<Typography
variant="h6"
sx={{
fontFamily: 'Benzin-Bold',
backgroundImage:
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Инвентарь {headerServerName ? `${headerServerName}` : ''}
</Typography>
</Box>
{/* GRID */}
{loading ? (
<FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />
) : (
<Grid container spacing={2}>
{Array.from({ length: 28 }).map((_, 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={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={{
position: 'relative',
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)',
}}
>
{/* 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',
}}
>
{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);
}}
sx={{
fontSize: '0.8vw',
borderRadius: '2vw',
fontFamily: 'Benzin-Bold',
border: '1px solid rgba(255,255,255,0.15)',
color: 'white',
}}
>
{isWithdrawing ? 'Выдача...' : 'Выдать'}
</Button>
)}
</Paper>
</Box>
</Box>
</Grid>
);
})}
</Grid>
)}
{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>
);
}