From c15c36891e9ed3f1facf010812a0335f2cddaecd Mon Sep 17 00:00:00 2001 From: aurinex Date: Tue, 16 Dec 2025 15:30:40 +0500 Subject: [PATCH] add inventory and change cases --- src/main/main.ts | 2 +- src/renderer/App.css | 4 +- src/renderer/App.tsx | 9 + src/renderer/api.ts | 125 +++++++-- src/renderer/components/PageHeader.tsx | 1 + src/renderer/components/TopBar.tsx | 15 ++ src/renderer/pages/Inventory.tsx | 359 +++++++++++++++++++++++++ src/renderer/pages/Profile.tsx | 4 +- src/renderer/pages/Settings.tsx | 1 + src/renderer/pages/Shop.tsx | 216 ++++++++++----- 10 files changed, 640 insertions(+), 96 deletions(-) create mode 100644 src/renderer/pages/Inventory.tsx diff --git a/src/main/main.ts b/src/main/main.ts index 7f57f11..f9be41d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -247,7 +247,7 @@ const createWindow = async () => { width: 1024, height: 850, autoHideMenuBar: true, - resizable: true, + resizable: false, frame: false, icon: getAssetPath('popa-popa.png'), webPreferences: { diff --git a/src/renderer/App.css b/src/renderer/App.css index 353cd2b..b3a9f49 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -34,13 +34,13 @@ body.no-blur .glass-ui { /* SETTINGS NO-BLUR */ /* SETTINGS REDUCE-MOTION */ -@media (prefers-reduced-motion: reduce) { +/* @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } -} +} */ body.reduce-motion *, body.reduce-motion *::before, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 95d68d1..a215023 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -25,6 +25,7 @@ import { useLocation } from 'react-router-dom'; import DailyReward from './pages/DailyReward'; import DailyQuests from './pages/DailyQuests'; import Settings from './pages/Settings'; +import Inventory from './pages/Inventory'; import { TrayBridge } from './utils/TrayBridge'; const AuthCheck = ({ children }: { children: ReactNode }) => { @@ -287,6 +288,14 @@ const AppLayout = () => { } /> + + + + } + /> | null; + durability?: number | null; } export interface CaseItem { @@ -443,7 +445,7 @@ export interface Case { description?: string; price: number; image_url?: string; - server_ids?: string[]; + server_ips?: string[]; items_count?: number; items?: CaseItem[]; } @@ -457,52 +459,34 @@ export interface OpenCaseResponse { } export async function fetchCases(): Promise { - const response = await fetch(`${API_BASE_URL}/cases`); - if (!response.ok) { - throw new Error('Не удалось получить список кейсов'); - } + const response = await fetch(`${API_BASE_URL}/cases/`); + if (!response.ok) throw new Error('Не удалось получить список кейсов'); return await response.json(); } -// Если у тебя есть отдельный эндпоинт деталей кейса, можно использовать это: export async function fetchCase(case_id: string): Promise { const response = await fetch(`${API_BASE_URL}/cases/${case_id}`); - if (!response.ok) { - throw new Error('Не удалось получить информацию о кейсе'); - } + if (!response.ok) throw new Error('Не удалось получить информацию о кейсе'); return await response.json(); } export async function openCase( case_id: string, username: string, - server_id: string, + server_ip: string, ): Promise { - // Формируем URL с query-параметрами, как любит текущий бэкенд const url = new URL(`${API_BASE_URL}/cases/${case_id}/open`); - url.searchParams.append('username', username); - url.searchParams.append('server_id', server_id); + url.searchParams.set('username', username); + url.searchParams.set('server_ip', server_ip); - const response = await fetch(url.toString(), { - method: 'POST', - }); + const response = await fetch(url.toString(), { method: 'POST' }); if (!response.ok) { let msg = 'Не удалось открыть кейс'; - try { const errorData = await response.json(); - if (errorData.message) { - msg = errorData.message; - } else if (Array.isArray(errorData.detail)) { - msg = errorData.detail.map((d: any) => d.msg).join(', '); - } else if (typeof errorData.detail === 'string') { - msg = errorData.detail; - } - } catch { - // если бэкенд вернул не-JSON, оставляем дефолтное сообщение - } - + msg = errorData.message || errorData.detail || msg; + } catch {} throw new Error(msg); } @@ -511,6 +495,91 @@ export async function openCase( // ===== КЕЙСЫ ===== \\ +// ===== Инвентарь ===== + +export interface InventoryRawItem { + _id: string; + id: string; // item_id для withdraw + username: string; + server_ip: string; + item_data: { + material: string; + amount: number; + meta?: { + display_name?: string | null; + lore?: string[] | null; + enchants?: Record | null; + durability?: number | null; + }; + }; + source?: { + type: string; + case_id?: string; + case_name?: string; + }; + status: 'stored' | 'delivered' | string; + created_at: string; + delivered_at?: string | null; + withdraw_operation_id?: string | null; +} + +export interface InventoryItemsResponse { + items: InventoryRawItem[]; + page: number; + limit: number; + total: number; +} + +export async function fetchInventoryItems( + username: string, + server_ip: string, + page = 1, + limit = 28, +): Promise { + const url = new URL(`${API_BASE_URL}/inventory/items`); + url.searchParams.set('username', username); + url.searchParams.set('server_ip', server_ip); + url.searchParams.set('page', String(page)); + url.searchParams.set('limit', String(limit)); + + const response = await fetch(url.toString()); + if (!response.ok) { + let msg = 'Не удалось получить инвентарь'; + try { + const err = await response.json(); + msg = err.message || err.detail || msg; + } catch {} + throw new Error(msg); + } + + return await response.json(); +} + +export async function withdrawInventoryItem(payload: { + username: string; + item_id: string; + server_ip: string; +}): Promise<{ status: string; message?: string }> { + const response = await fetch(`${API_BASE_URL}/inventory/withdraw`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + let msg = 'Не удалось выдать предмет'; + try { + const err = await response.json(); + msg = err.message || err.detail || msg; + } catch {} + throw new Error(msg); + } + + return await response.json(); +} + +// ===== ИНВЕНТАРЬ ===== \\ + // ===== Ежедневная награда ===== export interface DailyStatusResponse { ok: boolean; diff --git a/src/renderer/components/PageHeader.tsx b/src/renderer/components/PageHeader.tsx index 71aeedc..b689b27 100644 --- a/src/renderer/components/PageHeader.tsx +++ b/src/renderer/components/PageHeader.tsx @@ -33,6 +33,7 @@ export default function PageHeader() { path === '/registration' || path === '/marketplace' || path === '/profile' || + path === '/inventory' || path.startsWith('/launch') ) { return { title: '', subtitle: '', hidden: true }; diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index fc6479d..a9fe18c 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -21,6 +21,8 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import PersonIcon from '@mui/icons-material/Person'; import SettingsIcon from '@mui/icons-material/Settings'; import { useTheme } from '@mui/material/styles'; +import CategoryIcon from '@mui/icons-material/Category'; + declare global { interface Window { electron: { @@ -600,6 +602,19 @@ export default function TopBar({ onRegister, username }: TopBarProps) { Ежедневная награда + { + handleAvatarMenuClose(); + navigate('/inventory'); + }} + sx={[ + { fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' }, + theme.launcher.topbar.menuItem, + ]} + > + Инвентарь + + { handleAvatarMenuClose(); diff --git a/src/renderer/pages/Inventory.tsx b/src/renderer/pages/Inventory.tsx new file mode 100644 index 0000000..5018b25 --- /dev/null +++ b/src/renderer/pages/Inventory.tsx @@ -0,0 +1,359 @@ +import { useEffect, useState } from 'react'; +import { + Box, + Typography, + Grid, + Button, + FormControl, + Select, + MenuItem, + InputLabel, + Tooltip, + Paper, +} from '@mui/material'; + +import { FullScreenLoader } from '../components/FullScreenLoader'; +import { translateServer } from '../utils/serverTranslator'; +import { + fetchInventoryItems, + withdrawInventoryItem, + type InventoryRawItem, + type InventoryItemsResponse, +} from '../api'; + +const KNOWN_SERVER_IPS = [ + 'minecraft.hub.popa-popa.ru', + 'minecraft.survival.popa-popa.ru', + 'minecraft.minigames.popa-popa.ru', +]; + +function stripMinecraftColors(text?: string | null): string { + if (!text) return ''; + return text.replace(/§[0-9A-FK-ORa-fk-or]/g, ''); +} + +const Glass = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export default function Inventory() { + const [username, setUsername] = useState(''); + const [loading, setLoading] = useState(true); + const [availableServers, setAvailableServers] = useState([]); + const [selectedServerIp, setSelectedServerIp] = useState(''); + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const limit = 28; + const [total, setTotal] = useState(0); + const [pages, setPages] = useState(1); + const [withdrawingIds, setWithdrawingIds] = useState([]); + const isServerSelectVisible = availableServers.length > 1; + + 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 }; + }), + ); + + const servers = 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); + setItems(res.items || []); + setTotal(res.total ?? 0); + setPages(Math.max(1, Math.ceil((res.total ?? 0) / (res.limit ?? limit)))); + }; + + 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 handleWithdraw = async (item: InventoryRawItem) => { + if (!username || !selectedServerIp) return; + + 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)); + } catch (e) { + console.error("Ошибка при выводе предмета:", e); + } + }); + }; + + const withWithdrawing = async (id: string, fn: () => Promise) => { + setWithdrawingIds((prev) => [...prev, id]); + try { + await fn(); + } finally { + setWithdrawingIds((prev) => prev.filter((x) => x !== id)); + } + }; + + const headerServerName = selectedServerIp + ? translateServer(`Server ${selectedServerIp}`) + : ''; + + if (!username) { + return ( + + + Не найдено имя игрока. Авторизуйтесь в лаунчере. + + + ); + } + + 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 ( + + {loading && } + + + {/* Пагинация */} + {!!selectedServerIp && ( + + + + + Страница {page} / {pages} • Всего: {total} + + + + + )} + + + Инвентарь {headerServerName ? `— ${headerServerName}` : ''} + + + + {Array.from({ length: 28 }).map((_, index) => { + const item = items[index]; + return ( + + + item && handleWithdraw(item)} + > + {item ? ( + + + + ) : ( + Пусто + )} + + + + ); + })} + + + + ); +} diff --git a/src/renderer/pages/Profile.tsx b/src/renderer/pages/Profile.tsx index 311ebb8..9c27cec 100644 --- a/src/renderer/pages/Profile.tsx +++ b/src/renderer/pages/Profile.tsx @@ -349,8 +349,8 @@ export default function Profile() { }} > { pb: '2vw', width: '95%', boxSizing: 'border-box', + overflowY: 'auto', }} > (''); + const [selectedCaseServerIp, setSelectedCaseServerIp] = useState(''); + // Уведомления const [notifOpen, setNotifOpen] = useState(false); @@ -345,6 +351,28 @@ export default function Shop() { }); }; + const caseServers = Array.from( + new Set( + (cases || []) + .flatMap((c) => c.server_ips || []) + .filter(Boolean), + ), +); + +useEffect(() => { + if (caseServers.length > 0) { + // если игрок онлайн — по умолчанию его сервер, если он есть в кейсах + const preferred = + playerServer?.ip && caseServers.includes(playerServer.ip) + ? playerServer.ip + : caseServers[0]; + + setSelectedCaseServerIp(preferred); + } +}, [caseServers.length, playerServer?.ip]); + +const filteredCases = cases; + // Фильтруем плащи, которые уже куплены пользователем const availableCapes = storeCapes.filter( (storeCape) => @@ -352,62 +380,72 @@ export default function Shop() { ); const handleOpenCase = async (caseData: Case) => { - if (!username) { - if (!isNotificationsEnabled()) return; - setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!'); - setNotifSeverity('error'); - setNotifPos({ vertical: 'top', horizontal: 'center' }); - setNotifOpen(true); - } + if (!username) { + if (!isNotificationsEnabled()) return; + setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!'); + setNotifSeverity('error'); + setNotifPos({ vertical: 'top', horizontal: 'center' }); + setNotifOpen(true); + return; + } - if (!isOnline || !playerServer) { - if (!isNotificationsEnabled()) return; - setNotifMsg('Для открытия кейсов необходимо находиться на сервере в игре!'); - setNotifSeverity('error'); - setNotifPos({ vertical: 'top', horizontal: 'center' }); - setNotifOpen(true); - return; - } + if (!selectedCaseServerIp) { + if (!isNotificationsEnabled()) return; + setNotifMsg('Выберите сервер для открытия кейса!'); + setNotifSeverity('warning'); + setNotifPos({ vertical: 'top', horizontal: 'center' }); + setNotifOpen(true); + return; + } - if (isOpening) return; + const allowedIps = caseData.server_ips || []; + if (allowedIps.length > 0 && !allowedIps.includes(selectedCaseServerIp)) { + if (!isNotificationsEnabled()) return; + setNotifMsg( + `Этот кейс доступен на: ${allowedIps + .map((ip) => translateServer(`Server ${ip}`)) + .join(', ')}`, + ); + setNotifSeverity('warning'); + setNotifPos({ vertical: 'top', horizontal: 'center' }); + setNotifOpen(true); + return; + } - try { - setIsOpening(true); + if (isOpening) return; - // 1. получаем полный кейс - const fullCase = await fetchCase(caseData.id); - const caseItems: CaseItem[] = fullCase.items || []; - setSelectedCase(fullCase); + try { + setIsOpening(true); - // 2. открываем кейс на бэке - const result = await openCase(fullCase.id, username, playerServer.id); + const fullCase = await fetchCase(caseData.id); + const caseItems: CaseItem[] = fullCase.items || []; + setSelectedCase(fullCase); - // 3. сохраняем данные для рулетки - setRouletteCaseItems(caseItems); - setRouletteReward(result.reward); - setRouletteOpen(true); - playBuySound(); + // ✅ открываем на выбранном сервере (даже если игрок не на сервере) + const result = await openCase(fullCase.id, username, selectedCaseServerIp); - setIsOpening(false); + setRouletteCaseItems(caseItems); + setRouletteReward(result.reward); + setRouletteOpen(true); + playBuySound(); - // 4. уведомление - if (!isNotificationsEnabled()) return; - setNotifMsg('Кейс открыт!'); - setNotifSeverity('success'); - setNotifPos({ vertical: 'top', horizontal: 'center' }); - setNotifOpen(true); - } catch (error) { - console.error('Ошибка при открытии кейса:', error); + if (!isNotificationsEnabled()) return; + setNotifMsg('Кейс открыт!'); + setNotifSeverity('success'); + setNotifPos({ vertical: 'top', horizontal: 'center' }); + setNotifOpen(true); + } catch (error) { + console.error('Ошибка при открытии кейса:', error); - setIsOpening(false); - - if (!isNotificationsEnabled()) return; - setNotifMsg('Ошибка при открытии кейса!'); - setNotifSeverity('error'); - setNotifPos({ vertical: 'top', horizontal: 'center' }); - setNotifOpen(true); - } - }; + if (!isNotificationsEnabled()) return; + setNotifMsg(String(error instanceof Error ? error.message : 'Ошибка при открытии кейса!')); + setNotifSeverity('error'); + setNotifPos({ vertical: 'top', horizontal: 'center' }); + setNotifOpen(true); + } finally { + setIsOpening(false); + } +}; const handleCloseNotification = () => { setNotification((prev) => ({ ...prev, open: false })); @@ -570,8 +608,6 @@ export default function Shop() { > Кейсы - - {!isOnline && ( - )} + + {caseServers.length > 0 && ( + + + Сервер + + + + )} - {!isOnline ? ( - - Для открытия кейсов вам необходимо находиться на одном из - серверов игры. Зайдите в игру и нажмите кнопку «Обновить». - - ) : casesLoading ? ( - + {casesLoading ? ( + ) : cases.length > 0 ? ( - {cases.map((c) => ( + {filteredCases.map((c) => ( handleOpenCase(c)} />