From fef89513c21b7d70311a31169c3c3394b4c83f78 Mon Sep 17 00:00:00 2001 From: aurinex <152972480+aurinex@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:16:59 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BA=D1=80=D1=83=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/auth-service.ts | 10 +- src/renderer/components/CaseItemsDialog.tsx | 260 ++++++++++++++++++++ src/renderer/components/ShopItem.tsx | 50 +++- src/renderer/components/TopBar.tsx | 28 ++- src/renderer/hooks/useAuth.ts | 4 +- src/renderer/pages/Inventory.tsx | 98 ++++++-- src/renderer/pages/Login.tsx | 98 +++++++- src/renderer/pages/Registration.tsx | 74 ++++-- src/renderer/pages/Settings.tsx | 2 +- src/renderer/pages/Shop.tsx | 36 +-- 10 files changed, 555 insertions(+), 105 deletions(-) create mode 100644 src/renderer/components/CaseItemsDialog.tsx diff --git a/src/main/auth-service.ts b/src/main/auth-service.ts index be8bdf4..b701477 100644 --- a/src/main/auth-service.ts +++ b/src/main/auth-service.ts @@ -35,8 +35,14 @@ export class AuthService { }); if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Ошибка авторизации: ${response.status} ${errorText}`); + let detail = ''; + try { + const data = await response.json(); // FastAPI: { detail: "..." } + detail = data?.detail || ''; + } catch { + detail = await response.text(); + } + throw new Error(detail || `HTTP ${response.status}`); } const auth = await response.json(); diff --git a/src/renderer/components/CaseItemsDialog.tsx b/src/renderer/components/CaseItemsDialog.tsx new file mode 100644 index 0000000..467b431 --- /dev/null +++ b/src/renderer/components/CaseItemsDialog.tsx @@ -0,0 +1,260 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + Box, + Typography, + Grid, + Paper, + CircularProgress, +} from '@mui/material'; +import type { Case, CaseItem } from '../api'; +import { fetchCase } from '../api'; +import CloseIcon from '@mui/icons-material/Close'; +import IconButton from '@mui/material/IconButton'; + +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; + +const GLASS_PAPER_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', + backdropFilter: 'blur(16px)', + } as const; + +function stripMinecraftColors(text?: string | null): string { + if (!text) return ''; + return text.replace(/§[0-9A-FK-ORa-fk-or]/g, ''); +} + +function getChancePercent(itemWeight: number, total: number) { + if (!total || total <= 0) return 0; + return (itemWeight / total) * 100; +} + +function getRarityByWeight(weight?: number): 'common' | 'rare' | 'epic' | 'legendary' { + if (weight === undefined || weight === null) return 'common'; + if (weight <= 5) return 'legendary'; + if (weight <= 20) return 'epic'; + if (weight <= 50) return 'rare'; + return 'common'; +} + +function getRarityColor(weight?: number): string { + const rarity = getRarityByWeight(weight); + switch (rarity) { + case 'legendary': + return 'rgba(255, 215, 0, 1)'; // gold + case 'epic': + return 'rgba(186, 85, 211, 1)'; // purple + case 'rare': + return 'rgba(65, 105, 225, 1)'; // blue + default: + return 'rgba(255, 255, 255, 0.75)'; + } +} + +type Props = { + open: boolean; + onClose: () => void; + caseId: string; + caseName?: string; +}; + +export default function CaseItemsDialog({ open, onClose, caseId, caseName }: Props) { + const [loading, setLoading] = useState(false); + const [caseData, setCaseData] = useState(null); + const items: CaseItem[] = useMemo(() => { + const list = caseData?.items ?? []; + return [...list].sort((a, b) => { + const wa = a.weight ?? Infinity; + const wb = b.weight ?? Infinity; + return wa - wb; // 🔥 по возрастанию weight (легендарки сверху) + }); + }, [caseData]); + + const totalWeight = useMemo(() => { + return items.reduce((sum, it) => sum + (Number(it.weight) || 0), 0); + }, [items]); + + useEffect(() => { + if (!open) return; + + let cancelled = false; + (async () => { + try { + setLoading(true); + const full = await fetchCase(caseId); + if (!cancelled) setCaseData(full); + } catch (e) { + console.error('Ошибка при загрузке предметов кейса:', e); + if (!cancelled) setCaseData(null); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [open, caseId]); + + return ( + + + Предметы кейса{caseName ? ` — ${caseName}` : ''} + + + + + + + + {loading ? ( + + + + ) : !items.length ? ( + + Предметы не найдены (или кейс временно недоступен). + + ) : ( + <> + + + {items.map((it) => { + const w = Number(it.weight) || 0; + const chance = getChancePercent(w, totalWeight); + const displayNameRaw = it.meta?.display_name ?? it.name ?? it.material ?? 'Предмет'; + const displayName = stripMinecraftColors(displayNameRaw); + + const texture = it.material + ? `https://cdn.minecraft.popa-popa.ru/textures/${it.material.toLowerCase()}.png` + : ''; + + return ( + + + {/* верхняя плашка (редкость) */} + + {displayName} + + + {/* иконка */} + + {texture ? ( + + ) : ( + ? + )} + + + {/* низ: шанс/вес/кол-во */} + + + {chance.toFixed(2)}% + + + + + ); + })} + + + )} + + + ); +} diff --git a/src/renderer/components/ShopItem.tsx b/src/renderer/components/ShopItem.tsx index f27e336..f7ffac8 100644 --- a/src/renderer/components/ShopItem.tsx +++ b/src/renderer/components/ShopItem.tsx @@ -13,6 +13,8 @@ import CoinsDisplay from './CoinsDisplay'; import { CapePreview } from './CapePreview'; import VisibilityIcon from '@mui/icons-material/Visibility'; import CapePreviewModal from './PlayerPreviewModal'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import CaseItemsDialog from './CaseItemsDialog'; export type ShopItemType = 'case' | 'cape'; @@ -32,6 +34,7 @@ export interface ShopItemProps { export default function ShopItem({ type, + id, name, description, imageUrl, @@ -46,6 +49,7 @@ export default function ShopItem({ type === 'case' ? (isOpening ? 'Открываем...' : 'Открыть кейс') : 'Купить'; const [previewOpen, setPreviewOpen] = useState(false); + const [caseInfoOpen, setCaseInfoOpen] = useState(false); return ( - Предметов в кейсе: {itemsCount} - + + + Предметов в кейсе: {itemsCount} + + + { + e.preventDefault(); + e.stopPropagation(); // важно: чтобы не сработало onClick карточки/кнопки открытия + setCaseInfoOpen(true); + }} + sx={{ + ml: 1, + color: 'rgba(255,255,255,0.85)', + border: '1px solid rgba(255,255,255,0.12)', + background: 'rgba(255,255,255,0.04)', + '&:hover': { transform: 'scale(1.05)' }, + transition: 'all 0.25s ease', + }} + > + + + )} @@ -236,6 +260,14 @@ export default function ShopItem({ skinUrl={playerSkinUrl} /> )} + {type === 'case' && ( + setCaseInfoOpen(false)} + caseId={id} + caseName={name} + /> + )} ); } diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index a9fe18c..ed37ee3 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -207,6 +207,8 @@ export default function TopBar({ onRegister, username }: TopBarProps) { const logout = () => { localStorage.removeItem('launcher_config'); + localStorage.removeItem(`coins:${username}`); + localStorage.removeItem('last_route'); navigate('/login'); window.electron.ipcRenderer.invoke('auth-changed', { isAuthed: false }); }; @@ -574,6 +576,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, + ]} + > + Инвентарь + + {/* ===== 2 строка: ежедневные задания ===== */} { @@ -602,19 +617,6 @@ 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/hooks/useAuth.ts b/src/renderer/hooks/useAuth.ts index 8a69a90..a763562 100644 --- a/src/renderer/hooks/useAuth.ts +++ b/src/renderer/hooks/useAuth.ts @@ -43,7 +43,7 @@ export default function useAuth() { } catch (error) { console.error('Ошибка при аутентификации:', error); setStatus('error'); - return null; + throw error; } }; @@ -86,7 +86,7 @@ export default function useAuth() { } catch (error) { console.error('Ошибка при обновлении токена:', error); setStatus('error'); - return null; + throw error; } }; diff --git a/src/renderer/pages/Inventory.tsx b/src/renderer/pages/Inventory.tsx index f047cb4..4804a82 100644 --- a/src/renderer/pages/Inventory.tsx +++ b/src/renderer/pages/Inventory.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Box, Typography, Grid, Button, Paper } from '@mui/material'; +import { Box, Typography, Grid, Button, Paper, FormControl, Select, MenuItem, InputLabel } from '@mui/material'; import { FullScreenLoader } from '../components/FullScreenLoader'; import { translateServer } from '../utils/serverTranslator'; @@ -18,6 +18,8 @@ const KNOWN_SERVER_IPS = [ '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, ''); @@ -34,30 +36,29 @@ const CardFacePaperSx = { color: 'white', } as const; -function readLauncherConfig(): any { +function readInventoryLayout(): any { try { - const raw = localStorage.getItem('launcher_config'); + const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } -function writeLauncherConfig(next: any) { - localStorage.setItem('launcher_config', JSON.stringify(next)); +function writeInventoryLayout(next: any) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } function getLayout(username: string, serverIp: string): Record { - const cfg = readLauncherConfig(); - return cfg?.inventory_layout?.[username]?.[serverIp] ?? {}; + const inv = readInventoryLayout(); + return inv?.[username]?.[serverIp] ?? {}; } function setLayout(username: string, serverIp: string, layout: Record) { - const cfg = readLauncherConfig(); - cfg.inventory_layout ??= {}; - cfg.inventory_layout[username] ??= {}; - cfg.inventory_layout[username][serverIp] = layout; - writeLauncherConfig(cfg); + const inv = readInventoryLayout(); + inv[username] ??= {}; + inv[username][serverIp] = layout; + writeInventoryLayout(inv); } function buildSlots( @@ -399,8 +400,6 @@ export default function Inventory() { mt: '12vh', }} > - {loading && } - {/* ШАПКА + ПАГИНАЦИЯ */} )} + {availableServers.length > 0 && ( + + + Сервер + + + + + )} + {/* GRID */} + {loading ? ( + + ) : ( {Array.from({ length: 28 }).map((_, index) => { const item = slots[index]; @@ -694,6 +763,7 @@ export default function Inventory() { ); })} + )} {draggedItemId && dragPos && (() => { const draggedItem = items.find(i => i.id === draggedItemId); if (!draggedItem) return null; diff --git a/src/renderer/pages/Login.tsx b/src/renderer/pages/Login.tsx index d58725a..68e9c30 100644 --- a/src/renderer/pages/Login.tsx +++ b/src/renderer/pages/Login.tsx @@ -1,31 +1,95 @@ -import { Box, Typography } from '@mui/material'; +import { Box } from '@mui/material'; import useAuth from '../hooks/useAuth'; import AuthForm from '../components/Login/AuthForm'; -import MemorySlider from '../components/Login/MemorySlider'; import { useNavigate } from 'react-router-dom'; import PopaPopa from '../components/popa-popa'; import useConfig from '../hooks/useConfig'; import { useState } from 'react'; import { FullScreenLoader } from '../components/FullScreenLoader'; +import React from 'react'; +import CustomNotification from '../components/Notifications/CustomNotification'; +import type { NotificationPosition } from '../components/Notifications/CustomNotification'; +import { + isNotificationsEnabled, + getNotifPositionFromSettings, +} from '../utils/notifications'; + interface LoginProps { onLoginSuccess?: (username: string) => void; } const Login = ({ onLoginSuccess }: LoginProps) => { const navigate = useNavigate(); - const { config, setConfig, saveConfig, handleInputChange } = useConfig(); + const { config, saveConfig, handleInputChange } = useConfig(); const auth = useAuth(); const [loading, setLoading] = useState(false); + // Snackbar / Notification states (как в LaunchPage) + const [notifOpen, setNotifOpen] = useState(false); + const [notifMsg, setNotifMsg] = useState(''); + const [notifSeverity, setNotifSeverity] = useState< + 'success' | 'info' | 'warning' | 'error' + >('info'); + + const [notifPos, setNotifPos] = useState({ + vertical: 'bottom', + horizontal: 'center', + }); + + const showNotification = ( + message: React.ReactNode, + severity: 'success' | 'info' | 'warning' | 'error' = 'info', + position: NotificationPosition = getNotifPositionFromSettings(), + ) => { + if (!isNotificationsEnabled()) return; + setNotifMsg(message); + setNotifSeverity(severity); + setNotifPos(position); + setNotifOpen(true); + }; + + const mapAuthErrorToMessage = (error: any): string => { + const raw = error?.message ? String(error.message) : String(error); + + // сеть + if (raw.includes('Failed to fetch') || raw.includes('NetworkError')) { + return 'Сервер недоступен'; + } + + // вытащим JSON после статуса + // пример: "Ошибка авторизации: 401 {"detail":"Invalid credentials"}" + const jsonStart = raw.indexOf('{'); + if (jsonStart !== -1) { + const jsonStr = raw.slice(jsonStart); + try { + const data = JSON.parse(jsonStr); + const detail = data?.detail; + + if (detail === 'Invalid credentials') return 'Неверный логин или пароль'; + if (detail === 'User not verified') return 'Аккаунт не подтверждён'; + + if (typeof detail === 'string' && detail.trim()) return detail; + } catch { + // если это не JSON — идём дальше + } + } + + // если бэк вернул просто строку без JSON + if (raw.includes('Invalid credentials')) return 'Неверный логин или пароль'; + if (raw.includes('User not verified')) return 'Аккаунт не подтверждён'; + + return raw.startsWith('Ошибка') ? raw : `Ошибка: ${raw}`; + }; + const handleLogin = async () => { if (!config.username.trim()) { - alert('Введите никнейм!'); + showNotification('Введите никнейм!', 'warning'); return; } if (!config.password) { - alert('Введите пароль!'); + showNotification('Введите пароль!', 'warning'); return; } @@ -49,8 +113,6 @@ const Login = ({ onLoginSuccess }: LoginProps) => { config.password, saveConfig, ); - - if (!session) throw new Error('Авторизация не удалась'); } } } else { @@ -60,21 +122,22 @@ const Login = ({ onLoginSuccess }: LoginProps) => { config.password, saveConfig, ); - - if (!session) throw new Error('Авторизация не удалась'); } - // Успех if (onLoginSuccess) { onLoginSuccess(config.username); } + // (опционально) можно показать успех + showNotification('Успешный вход', 'success'); + navigate('/'); } catch (error: any) { console.error('Ошибка авторизации:', error); - alert(`Ошибка: ${error.message}`); - - // Очищаем невалидные токены + + const msg = mapAuthErrorToMessage(error); + showNotification(msg, 'error'); + saveConfig({ accessToken: '', clientToken: '', @@ -98,6 +161,15 @@ const Login = ({ onLoginSuccess }: LoginProps) => { /> )} + + setNotifOpen(false)} + autoHideDuration={2500} + /> ); }; diff --git a/src/renderer/pages/Registration.tsx b/src/renderer/pages/Registration.tsx index f5c2cb7..f6c7ff3 100644 --- a/src/renderer/pages/Registration.tsx +++ b/src/renderer/pages/Registration.tsx @@ -10,7 +10,6 @@ import { Typography, Box, Button, - Snackbar, } from '@mui/material'; import LoginRoundedIcon from '@mui/icons-material/LoginRounded'; import VerifiedRoundedIcon from '@mui/icons-material/VerifiedRounded'; @@ -24,6 +23,12 @@ import { import popalogo from '../../../assets/icons/popa-popa.svg'; import GradientTextField from '../components/GradientTextField'; import { FullScreenLoader } from '../components/FullScreenLoader'; +import CustomNotification from '../components/Notifications/CustomNotification'; +import type { NotificationPosition } from '../components/Notifications/CustomNotification'; +import { + isNotificationsEnabled, + getNotifPositionFromSettings, +} from '../utils/notifications'; const ColorlibConnector = styled(StepConnector)(({ theme }) => ({ [`&.${stepConnectorClasses.alternativeLabel}`]: { @@ -147,13 +152,34 @@ export const Registration = () => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [enterpassword, setEnterPassword] = useState(''); - const [open, setOpen] = useState(false); - const [message, setMessage] = useState(''); const [verificationCode, setVerificationCode] = useState(null); const ref = useRef(null); const [url, setUrl] = useState(''); const steps = ['Создание аккаунта', 'Верификация аккаунта в телеграмме']; + const [notifOpen, setNotifOpen] = useState(false); + const [notifMsg, setNotifMsg] = useState(''); + const [notifSeverity, setNotifSeverity] = useState< + 'success' | 'info' | 'warning' | 'error' + >('info'); + + const [notifPos, setNotifPos] = useState({ + vertical: 'bottom', + horizontal: 'center', + }); + + const showNotification = ( + message: React.ReactNode, + severity: 'success' | 'info' | 'warning' | 'error' = 'info', + position: NotificationPosition = getNotifPositionFromSettings(), + ) => { + if (!isNotificationsEnabled()) return; + setNotifMsg(message); + setNotifSeverity(severity); + setNotifPos(position); + setNotifOpen(true); + }; + useEffect(() => { if (ref.current) { qrCode.append(ref.current); @@ -167,27 +193,27 @@ export const Registration = () => { }, [url]); const handleCreateAccount = async () => { - // простая валидация на фронте if (!username || !password || !enterpassword) { - setOpen(true); - setMessage('Заполните все поля'); + showNotification('Заполните все поля', 'warning'); return; } - + if (password !== enterpassword) { - setOpen(true); - setMessage('Пароли не совпадают'); + showNotification('Пароли не совпадают', 'warning'); return; } - - // тут уже точно всё ок — отправляем запрос - const response = await registerUser(username, password); - - if (response.status === 'success') { - setActiveStep(1); - } else { - setOpen(true); - setMessage(response.status); + + try { + const response = await registerUser(username, password); + + if (response.status === 'success') { + setActiveStep(1); + showNotification('Аккаунт создан. Перейдите к верификации.', 'success'); + } else { + showNotification(String(response.status), 'error'); + } + } catch (e: any) { + showNotification('Ошибка регистрации', 'error'); } }; @@ -440,11 +466,13 @@ export const Registration = () => { )} - setNotifOpen(false)} + autoHideDuration={2500} /> diff --git a/src/renderer/pages/Settings.tsx b/src/renderer/pages/Settings.tsx index 411c830..508da1e 100644 --- a/src/renderer/pages/Settings.tsx +++ b/src/renderer/pages/Settings.tsx @@ -516,7 +516,7 @@ const Settings = () => { {/* RIGHT */} - Игра + { } }, [caseServers.length, playerServer?.ip]); -const filteredCases = cases; +const filteredCases = (cases || []).filter((c) => { + const allowed = c.server_ips || []; + // если список пуст — значит кейс доступен везде + if (allowed.length === 0) return true; + + // иначе — показываем только те, где выбранный сервер разрешён + return !!selectedCaseServerIp && allowed.includes(selectedCaseServerIp); +}); // Фильтруем плащи, которые уже куплены пользователем const availableCapes = storeCapes.filter( @@ -608,33 +615,6 @@ const filteredCases = cases; > Кейсы - {caseServers.length > 0 && (