круто
This commit is contained in:
@ -35,8 +35,14 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
let detail = '';
|
||||||
throw new Error(`Ошибка авторизации: ${response.status} ${errorText}`);
|
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();
|
const auth = await response.json();
|
||||||
|
|||||||
260
src/renderer/components/CaseItemsDialog.tsx
Normal file
260
src/renderer/components/CaseItemsDialog.tsx
Normal file
@ -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<Case | null>(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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="md"
|
||||||
|
PaperProps={{
|
||||||
|
sx: GLASS_PAPER_SX,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
pr: 6, // место под крестик
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Предметы кейса{caseName ? ` — ${caseName}` : ''}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={onClose}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
'&:hover': { transform: 'scale(1.05)', background: 'rgba(255,255,255,0.10)' },
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers sx={{ borderColor: 'rgba(255,255,255,0.10)' }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ py: 6, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : !items.length ? (
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.75)' }}>
|
||||||
|
Предметы не найдены (или кейс временно недоступен).
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{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 (
|
||||||
|
<Grid item xs={3} key={it.id}>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
...CardFacePaperSx,
|
||||||
|
width: '12vw',
|
||||||
|
height: '12vw',
|
||||||
|
minWidth: 110,
|
||||||
|
minHeight: 110,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* верхняя плашка (редкость) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
py: 0.7,
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
color: getRarityColor(it.weight),
|
||||||
|
fontFamily: 'Benzin-Bold',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
title={displayName}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* иконка */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexGrow: 1 }}>
|
||||||
|
{texture ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={texture}
|
||||||
|
alt={displayName}
|
||||||
|
draggable={false}
|
||||||
|
sx={{
|
||||||
|
width: '5vw',
|
||||||
|
height: '5vw',
|
||||||
|
minWidth: 40,
|
||||||
|
minHeight: 40,
|
||||||
|
objectFit: 'contain',
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: 'rgba(255,255,255,0.6)' }}>?</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* низ: шанс/вес/кол-во */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
py: 0.9,
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: '0.75rem', fontFamily: 'Benzin-Bold' }}>
|
||||||
|
{chance.toFixed(2)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,8 @@ import CoinsDisplay from './CoinsDisplay';
|
|||||||
import { CapePreview } from './CapePreview';
|
import { CapePreview } from './CapePreview';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
import CapePreviewModal from './PlayerPreviewModal';
|
import CapePreviewModal from './PlayerPreviewModal';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import CaseItemsDialog from './CaseItemsDialog';
|
||||||
|
|
||||||
export type ShopItemType = 'case' | 'cape';
|
export type ShopItemType = 'case' | 'cape';
|
||||||
|
|
||||||
@ -32,6 +34,7 @@ export interface ShopItemProps {
|
|||||||
|
|
||||||
export default function ShopItem({
|
export default function ShopItem({
|
||||||
type,
|
type,
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@ -46,6 +49,7 @@ export default function ShopItem({
|
|||||||
type === 'case' ? (isOpening ? 'Открываем...' : 'Открыть кейс') : 'Купить';
|
type === 'case' ? (isOpening ? 'Открываем...' : 'Открыть кейс') : 'Купить';
|
||||||
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
const [caseInfoOpen, setCaseInfoOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -193,15 +197,35 @@ export default function ShopItem({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{type === 'case' && typeof itemsCount === 'number' && (
|
{type === 'case' && typeof itemsCount === 'number' && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
color: 'rgba(255,255,255,0.6)',
|
color: 'rgba(255,255,255,0.6)',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
mt: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Предметов в кейсе: {itemsCount}
|
Предметов в кейсе: {itemsCount}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -236,6 +260,14 @@ export default function ShopItem({
|
|||||||
skinUrl={playerSkinUrl}
|
skinUrl={playerSkinUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{type === 'case' && (
|
||||||
|
<CaseItemsDialog
|
||||||
|
open={caseInfoOpen}
|
||||||
|
onClose={() => setCaseInfoOpen(false)}
|
||||||
|
caseId={id}
|
||||||
|
caseName={name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,6 +207,8 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('launcher_config');
|
localStorage.removeItem('launcher_config');
|
||||||
|
localStorage.removeItem(`coins:${username}`);
|
||||||
|
localStorage.removeItem('last_route');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed: false });
|
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed: false });
|
||||||
};
|
};
|
||||||
@ -574,6 +576,19 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
<PersonIcon sx={{ fontSize: '2vw' }} /> Профиль
|
<PersonIcon sx={{ fontSize: '2vw' }} /> Профиль
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleAvatarMenuClose();
|
||||||
|
navigate('/inventory');
|
||||||
|
}}
|
||||||
|
sx={[
|
||||||
|
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
|
||||||
|
theme.launcher.topbar.menuItem,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CategoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
{/* ===== 2 строка: ежедневные задания ===== */}
|
{/* ===== 2 строка: ежедневные задания ===== */}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -602,19 +617,6 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
|||||||
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
|
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
handleAvatarMenuClose();
|
|
||||||
navigate('/inventory');
|
|
||||||
}}
|
|
||||||
sx={[
|
|
||||||
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
|
|
||||||
theme.launcher.topbar.menuItem,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CategoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
|
|
||||||
</MenuItem>
|
|
||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleAvatarMenuClose();
|
handleAvatarMenuClose();
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default function useAuth() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при аутентификации:', error);
|
console.error('Ошибка при аутентификации:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
return null;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -86,7 +86,7 @@ export default function useAuth() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при обновлении токена:', error);
|
console.error('Ошибка при обновлении токена:', error);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
return null;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { FullScreenLoader } from '../components/FullScreenLoader';
|
||||||
import { translateServer } from '../utils/serverTranslator';
|
import { translateServer } from '../utils/serverTranslator';
|
||||||
@ -18,6 +18,8 @@ const KNOWN_SERVER_IPS = [
|
|||||||
'minecraft.minigames.popa-popa.ru',
|
'minecraft.minigames.popa-popa.ru',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'inventory_layout';
|
||||||
|
|
||||||
function stripMinecraftColors(text?: string | null): string {
|
function stripMinecraftColors(text?: string | null): string {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
|
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
|
||||||
@ -34,30 +36,29 @@ const CardFacePaperSx = {
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function readLauncherConfig(): any {
|
function readInventoryLayout(): any {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('launcher_config');
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
return raw ? JSON.parse(raw) : {};
|
return raw ? JSON.parse(raw) : {};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLauncherConfig(next: any) {
|
function writeInventoryLayout(next: any) {
|
||||||
localStorage.setItem('launcher_config', JSON.stringify(next));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLayout(username: string, serverIp: string): Record<string, number> {
|
function getLayout(username: string, serverIp: string): Record<string, number> {
|
||||||
const cfg = readLauncherConfig();
|
const inv = readInventoryLayout();
|
||||||
return cfg?.inventory_layout?.[username]?.[serverIp] ?? {};
|
return inv?.[username]?.[serverIp] ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLayout(username: string, serverIp: string, layout: Record<string, number>) {
|
function setLayout(username: string, serverIp: string, layout: Record<string, number>) {
|
||||||
const cfg = readLauncherConfig();
|
const inv = readInventoryLayout();
|
||||||
cfg.inventory_layout ??= {};
|
inv[username] ??= {};
|
||||||
cfg.inventory_layout[username] ??= {};
|
inv[username][serverIp] = layout;
|
||||||
cfg.inventory_layout[username][serverIp] = layout;
|
writeInventoryLayout(inv);
|
||||||
writeLauncherConfig(cfg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSlots(
|
function buildSlots(
|
||||||
@ -399,8 +400,6 @@ export default function Inventory() {
|
|||||||
mt: '12vh',
|
mt: '12vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading && <FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />}
|
|
||||||
|
|
||||||
{/* ШАПКА + ПАГИНАЦИЯ */}
|
{/* ШАПКА + ПАГИНАЦИЯ */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -448,6 +447,73 @@ export default function Inventory() {
|
|||||||
</Box>
|
</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
|
<Typography
|
||||||
variant="h6"
|
variant="h6"
|
||||||
sx={{
|
sx={{
|
||||||
@ -463,6 +529,9 @@ export default function Inventory() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* GRID */}
|
{/* GRID */}
|
||||||
|
{loading ? (
|
||||||
|
<FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />
|
||||||
|
) : (
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{Array.from({ length: 28 }).map((_, index) => {
|
{Array.from({ length: 28 }).map((_, index) => {
|
||||||
const item = slots[index];
|
const item = slots[index];
|
||||||
@ -694,6 +763,7 @@ export default function Inventory() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
{draggedItemId && dragPos && (() => {
|
{draggedItemId && dragPos && (() => {
|
||||||
const draggedItem = items.find(i => i.id === draggedItemId);
|
const draggedItem = items.find(i => i.id === draggedItemId);
|
||||||
if (!draggedItem) return null;
|
if (!draggedItem) return null;
|
||||||
|
|||||||
@ -1,31 +1,95 @@
|
|||||||
import { Box, Typography } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import useAuth from '../hooks/useAuth';
|
import useAuth from '../hooks/useAuth';
|
||||||
import AuthForm from '../components/Login/AuthForm';
|
import AuthForm from '../components/Login/AuthForm';
|
||||||
import MemorySlider from '../components/Login/MemorySlider';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PopaPopa from '../components/popa-popa';
|
import PopaPopa from '../components/popa-popa';
|
||||||
import useConfig from '../hooks/useConfig';
|
import useConfig from '../hooks/useConfig';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
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 {
|
interface LoginProps {
|
||||||
onLoginSuccess?: (username: string) => void;
|
onLoginSuccess?: (username: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Login = ({ onLoginSuccess }: LoginProps) => {
|
const Login = ({ onLoginSuccess }: LoginProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
|
const { config, saveConfig, handleInputChange } = useConfig();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Snackbar / Notification states (как в LaunchPage)
|
||||||
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
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 () => {
|
const handleLogin = async () => {
|
||||||
if (!config.username.trim()) {
|
if (!config.username.trim()) {
|
||||||
alert('Введите никнейм!');
|
showNotification('Введите никнейм!', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.password) {
|
if (!config.password) {
|
||||||
alert('Введите пароль!');
|
showNotification('Введите пароль!', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,8 +113,6 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
|
|||||||
config.password,
|
config.password,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) throw new Error('Авторизация не удалась');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -60,21 +122,22 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
|
|||||||
config.password,
|
config.password,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) throw new Error('Авторизация не удалась');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Успех
|
|
||||||
if (onLoginSuccess) {
|
if (onLoginSuccess) {
|
||||||
onLoginSuccess(config.username);
|
onLoginSuccess(config.username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (опционально) можно показать успех
|
||||||
|
showNotification('Успешный вход', 'success');
|
||||||
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Ошибка авторизации:', error);
|
console.error('Ошибка авторизации:', error);
|
||||||
alert(`Ошибка: ${error.message}`);
|
|
||||||
|
|
||||||
// Очищаем невалидные токены
|
const msg = mapAuthErrorToMessage(error);
|
||||||
|
showNotification(msg, 'error');
|
||||||
|
|
||||||
saveConfig({
|
saveConfig({
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
clientToken: '',
|
clientToken: '',
|
||||||
@ -98,6 +161,15 @@ const Login = ({ onLoginSuccess }: LoginProps) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CustomNotification
|
||||||
|
open={notifOpen}
|
||||||
|
message={notifMsg}
|
||||||
|
severity={notifSeverity}
|
||||||
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Snackbar,
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import LoginRoundedIcon from '@mui/icons-material/LoginRounded';
|
import LoginRoundedIcon from '@mui/icons-material/LoginRounded';
|
||||||
import VerifiedRoundedIcon from '@mui/icons-material/VerifiedRounded';
|
import VerifiedRoundedIcon from '@mui/icons-material/VerifiedRounded';
|
||||||
@ -24,6 +23,12 @@ import {
|
|||||||
import popalogo from '../../../assets/icons/popa-popa.svg';
|
import popalogo from '../../../assets/icons/popa-popa.svg';
|
||||||
import GradientTextField from '../components/GradientTextField';
|
import GradientTextField from '../components/GradientTextField';
|
||||||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
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 }) => ({
|
const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
|
||||||
[`&.${stepConnectorClasses.alternativeLabel}`]: {
|
[`&.${stepConnectorClasses.alternativeLabel}`]: {
|
||||||
@ -147,13 +152,34 @@ export const Registration = () => {
|
|||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [enterpassword, setEnterPassword] = useState('');
|
const [enterpassword, setEnterPassword] = useState('');
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [verificationCode, setVerificationCode] = useState<string | null>(null);
|
const [verificationCode, setVerificationCode] = useState<string | null>(null);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const steps = ['Создание аккаунта', 'Верификация аккаунта в телеграмме'];
|
const steps = ['Создание аккаунта', 'Верификация аккаунта в телеграмме'];
|
||||||
|
|
||||||
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||||||
|
const [notifSeverity, setNotifSeverity] = useState<
|
||||||
|
'success' | 'info' | 'warning' | 'error'
|
||||||
|
>('info');
|
||||||
|
|
||||||
|
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
qrCode.append(ref.current);
|
qrCode.append(ref.current);
|
||||||
@ -167,27 +193,27 @@ export const Registration = () => {
|
|||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
const handleCreateAccount = async () => {
|
const handleCreateAccount = async () => {
|
||||||
// простая валидация на фронте
|
|
||||||
if (!username || !password || !enterpassword) {
|
if (!username || !password || !enterpassword) {
|
||||||
setOpen(true);
|
showNotification('Заполните все поля', 'warning');
|
||||||
setMessage('Заполните все поля');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== enterpassword) {
|
if (password !== enterpassword) {
|
||||||
setOpen(true);
|
showNotification('Пароли не совпадают', 'warning');
|
||||||
setMessage('Пароли не совпадают');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// тут уже точно всё ок — отправляем запрос
|
try {
|
||||||
const response = await registerUser(username, password);
|
const response = await registerUser(username, password);
|
||||||
|
|
||||||
if (response.status === 'success') {
|
if (response.status === 'success') {
|
||||||
setActiveStep(1);
|
setActiveStep(1);
|
||||||
|
showNotification('Аккаунт создан. Перейдите к верификации.', 'success');
|
||||||
} else {
|
} else {
|
||||||
setOpen(true);
|
showNotification(String(response.status), 'error');
|
||||||
setMessage(response.status);
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showNotification('Ошибка регистрации', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -440,11 +466,13 @@ export const Registration = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Snackbar
|
<CustomNotification
|
||||||
open={open}
|
open={notifOpen}
|
||||||
autoHideDuration={6000}
|
message={notifMsg}
|
||||||
onClose={handleClose}
|
severity={notifSeverity}
|
||||||
message={message}
|
position={notifPos}
|
||||||
|
onClose={() => setNotifOpen(false)}
|
||||||
|
autoHideDuration={2500}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -516,7 +516,7 @@ const Settings = () => {
|
|||||||
{/* RIGHT */}
|
{/* RIGHT */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0 }}>
|
||||||
<Glass>
|
<Glass>
|
||||||
<SectionTitle>Игра</SectionTitle>
|
<SectionTitle> </SectionTitle>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
|
||||||
<SettingCheckboxRow
|
<SettingCheckboxRow
|
||||||
title="Автоповорот персонажа в профиле"
|
title="Автоповорот персонажа в профиле"
|
||||||
|
|||||||
@ -371,7 +371,14 @@ useEffect(() => {
|
|||||||
}
|
}
|
||||||
}, [caseServers.length, playerServer?.ip]);
|
}, [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(
|
const availableCapes = storeCapes.filter(
|
||||||
@ -608,33 +615,6 @@ const filteredCases = cases;
|
|||||||
>
|
>
|
||||||
Кейсы
|
Кейсы
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
|
||||||
disableRipple
|
|
||||||
disableFocusRipple
|
|
||||||
disableTouchRipple
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
transition: 'transform 0.3s ease',
|
|
||||||
width: '60%',
|
|
||||||
background:
|
|
||||||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
|
|
||||||
fontFamily: 'Benzin-Bold',
|
|
||||||
borderRadius: '2.5vw',
|
|
||||||
fontSize: '0.8em',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'scale(1.02)',
|
|
||||||
},
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
checkPlayerStatus(); // обновляем онлайн-статус
|
|
||||||
loadCases(); // обновляем ТОЛЬКО кейсы
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{caseServers.length > 0 && (
|
{caseServers.length > 0 && (
|
||||||
<FormControl size="small" sx={{ minWidth: 220 }}>
|
<FormControl size="small" sx={{ minWidth: 220 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user