Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer

This commit is contained in:
aurinex
2025-12-12 16:18:24 +05:00
31 changed files with 9960 additions and 3237 deletions

8465
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -55,6 +55,9 @@
"browserslist": [
"extends browserslist-config-erb"
],
"overrides": {
"undici": "6.10.2"
},
"prettier": {
"singleQuote": true,
"overrides": [
@ -112,6 +115,7 @@
"@xmcl/installer": "^6.1.2",
"@xmcl/resourcepack": "^1.2.4",
"@xmcl/user": "^4.2.0",
"easymde": "^2.20.0",
"electron-debug": "^4.1.0",
"electron-log": "^5.3.2",
"electron-updater": "^6.3.9",
@ -121,11 +125,12 @@
"qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.3.0",
"remark-gfm": "^4.0.1",
"skinview3d": "^3.4.1",
"stream-browserify": "^3.0.0",
"three": "^0.178.0",
"undici": "^7.16.0",
"util": "^0.12.5",
"uuid": "^11.1.0"
},

View File

@ -14,10 +14,64 @@ import {
installTask,
installDependenciesTask,
} from '@xmcl/installer';
import { Dispatcher, Agent } from 'undici';
import { spawn } from 'child_process';
import { AuthService } from './auth-service';
import { API_BASE_URL } from '../renderer/api';
// const CDN = 'https://cdn.minecraft.popa-popa.ru';
// const DOWNLOAD_OPTIONS = {
// // assets (objects/)
// assetsHost: `${CDN}/assets/objects`,
// // библиотеки (jar'ы)
// libraryHost(library: { path: any }) {
// return `${CDN}/libraries/${library.path}`;
// },
// assetsIndexUrl: (version: any) =>
// `${CDN}/assets/indexes/${version.assetIndex.id}.json`,
// // версии
// json(versionInfo: { id: any }) {
// return `${CDN}/versions/${versionInfo.id}/${versionInfo.id}.json`;
// },
// client(resolved: { id: any }) {
// return `${CDN}/versions/${resolved.id}/${resolved.id}.jar`;
// },
// };
const INSTALL_PHASES = [
{ id: 'download', weight: 0.25 }, // 25% — скачивание сборки
{ id: 'minecraft-install', weight: 0.3 }, // 30% — ваниль
{ id: 'fabric-install', weight: 0.15 }, // 15% — Fabric
{ id: 'dependencies', weight: 0.25 }, // 25% — библиотеки/ресурсы
{ id: 'launch', weight: 0.05 }, // 5% — запуск
] as const;
type InstallPhaseId = (typeof INSTALL_PHASES)[number]['id'];
function getGlobalProgress(phaseId: InstallPhaseId, localProgress01: number) {
let offset = 0;
let weight = 0;
for (const phase of INSTALL_PHASES) {
if (phase.id === phaseId) {
weight = phase.weight;
break;
}
offset += phase.weight;
}
if (!weight) return 100;
const clampedLocal = Math.max(0, Math.min(1, localProgress01));
const global = (offset + clampedLocal * weight) * 100;
return Math.round(Math.max(0, Math.min(global, 100)));
}
// Константы
const AUTHLIB_INJECTOR_FILENAME = 'authlib-injector-1.2.5.jar';
const MCSTATUS_API_URL = 'https://api.mcstatus.io/v2/status/java/';
@ -25,6 +79,13 @@ const MCSTATUS_API_URL = 'https://api.mcstatus.io/v2/status/java/';
// Создаем экземпляр сервиса аутентификации
const authService = new AuthService();
const agent = new Agent({
connections: 16, // максимум 16 одновременных соединений (скачиваний)
// тут можно задать и другие параметры при необходимости
});
let currentMinecraftProcess: any | null = null;
// Модифицированная функция для получения последней версии релиза с произвольного URL
export async function getLatestReleaseVersion(apiUrl: string): Promise<string> {
try {
@ -390,6 +451,9 @@ export function initMinecraftHandlers() {
// Скачиваем файл
await downloadFile(downloadUrl, zipPath, (progress) => {
event.sender.send('download-progress', progress);
const global = getGlobalProgress('download', progress / 100);
event.sender.send('overall-progress', global);
});
// Проверяем архив
@ -587,6 +651,7 @@ export function initMinecraftHandlers() {
skipRevalidate: true,
assetsDownloadConcurrency: 2,
librariesDownloadConcurrency: 2,
dispatcher: agent,
});
console.log('installMcTask started for', minecraftVersion.id);
@ -597,6 +662,16 @@ export function initMinecraftHandlers() {
});
await installMcTask.startAndWait({
onUpdate(task, chunkSize) {
// локальный прогресс инсталлятора XMCL
const local =
installMcTask.total > 0
? installMcTask.progress / installMcTask.total
: 0;
const global = getGlobalProgress('minecraft-install', local);
event.sender.send('overall-progress', global);
},
onFailed(task, error) {
const stepName = (task as any).path || task.name || 'unknown';
console.warn(
@ -635,6 +710,11 @@ export function initMinecraftHandlers() {
message: `Установка Fabric ${fabricVersion}...`,
});
event.sender.send(
'overall-progress',
getGlobalProgress('fabric-install', 0),
);
console.log('installFabric:', {
minecraftVersion: effectiveBaseVersion,
fabricVersion,
@ -646,6 +726,10 @@ export function initMinecraftHandlers() {
version: fabricVersion,
minecraft: minecraftDir,
});
event.sender.send(
'overall-progress',
getGlobalProgress('fabric-install', 1),
);
} catch (error) {
console.log('Ошибка при установке Fabric, продолжаем:', error);
}
@ -677,6 +761,7 @@ export function initMinecraftHandlers() {
const depsTask = installDependenciesTask(resolvedVersion, {
skipRevalidate: true,
prevalidSizeOnly: true,
dispatcher: agent,
assetsDownloadConcurrency: 2,
librariesDownloadConcurrency: 2,
checksumValidatorResolver: () => ({
@ -684,9 +769,17 @@ export function initMinecraftHandlers() {
// Игнорируем sha1, чтобы не падать из-за несоответствий
},
}),
// ...DOWNLOAD_OPTIONS,
});
await depsTask.startAndWait({
onUpdate(task, chunkSize) {
const local =
depsTask.total > 0 ? depsTask.progress / depsTask.total : 0;
const global = getGlobalProgress('dependencies', local);
event.sender.send('overall-progress', global);
},
onFailed(task, error) {
const stepName = (task as any).path || task.name || 'unknown';
console.warn(
@ -725,6 +818,8 @@ export function initMinecraftHandlers() {
message: 'Запуск игры...',
});
event.sender.send('overall-progress', getGlobalProgress('launch', 0));
const proc = await launch({
gamePath: packDir,
resourcePath: minecraftDir,
@ -752,11 +847,52 @@ export function initMinecraftHandlers() {
},
});
event.sender.send('minecraft-started', { pid: proc.pid });
currentMinecraftProcess = proc;
event.sender.send('overall-progress', getGlobalProgress('launch', 1));
let stderrBuffer = '';
proc.stdout?.on('data', (data) => {
console.log(`Minecraft stdout: ${data}`);
});
proc.stderr?.on('data', (data) => {
console.error(`Minecraft stderr: ${data}`);
const text = data.toString();
console.error(`Minecraft stderr: ${text}`);
stderrBuffer += text;
// Пробрасываем сырой лог клиенту (если захочешь где-то выводить)
event.sender.send('minecraft-log', text);
// Если это ошибка — сразу уведомим пользователя
if (text.toLowerCase().includes('error')) {
event.sender.send('minecraft-error', {
message: text,
});
}
});
proc.on('exit', (code) => {
console.log('Minecraft exited with code', code);
currentMinecraftProcess = null;
event.sender.send('minecraft-stopped', { code });
if (code !== 0) {
event.sender.send('installation-status', {
step: 'error',
message: `Minecraft завершился с ошибкой (код ${code})`,
});
event.sender.send('minecraft-error', {
message: `Minecraft завершился с ошибкой (код ${code})`,
stderr: stderrBuffer,
code,
});
}
});
console.log('Запуск игры...');
@ -773,6 +909,24 @@ export function initMinecraftHandlers() {
}
});
ipcMain.handle('stop-minecraft', async (event) => {
try {
if (currentMinecraftProcess && !currentMinecraftProcess.killed) {
console.log('Останавливаем Minecraft по запросу пользователя...');
// На Windows этого обычно достаточно
currentMinecraftProcess.kill();
// Можно чуть подождать, но не обязательно
return { success: true };
}
return { success: false, error: 'Minecraft сейчас не запущен' };
} catch (error: any) {
console.error('Ошибка при остановке Minecraft:', error);
return { success: false, error: error.message || String(error) };
}
});
// Добавьте в функцию initMinecraftHandlers или создайте новую
ipcMain.handle('get-pack-files', async (event, packName) => {
try {

View File

@ -13,7 +13,13 @@ export type Channels =
| 'update-available'
| 'install-update'
| 'get-installed-versions'
| 'get-available-versions';
| 'get-available-versions'
| 'minecraft-log'
| 'minecraft-error'
| 'overall-progress'
| 'stop-minecraft'
| 'minecraft-started'
| 'minecraft-stopped';
const electronHandler = {
ipcRenderer: {

View File

@ -58,3 +58,40 @@ h6 {
span {
font-family: 'Benzin-Bold' !important;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
/* трек */
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 100px;
margin: 20px 0; /* ⬅– отступы сверху и снизу */
}
/* Бегунок */
::-webkit-scrollbar-thumb {
background: linear-gradient(71deg, #f27121 0%, #e940cd 70%, #8a2387 100%);
border-radius: 10px;
}
/* hover эффект */
::-webkit-scrollbar-thumb:hover {
background-size: 400% 400%;
animation-duration: 1.7s;
}
/* shimmer-анимация градиента */
@keyframes scrollbarShimmer {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}

View File

@ -18,6 +18,8 @@ import Profile from './pages/Profile';
import Shop from './pages/Shop';
import Marketplace from './pages/Marketplace';
import { Registration } from './pages/Registration';
import { FullScreenLoader } from './components/FullScreenLoader';
import { News } from './pages/News';
const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -86,7 +88,7 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
};
if (isAuthenticated === null) {
return <div>Loading...</div>;
return <FullScreenLoader message="Загрузка..." />;
}
return isAuthenticated ? children : <Navigate to="/login" replace />;
@ -127,7 +129,10 @@ const App = () => {
<TopBar onRegister={handleRegister} username={username || ''} />
<Notifier />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/login"
element={<Login onLoginSuccess={setUsername} />}
/>
<Route path="/registration" element={<Registration />} />
<Route
path="/"
@ -169,6 +174,14 @@ const App = () => {
</AuthCheck>
}
/>
<Route
path="/news"
element={
<AuthCheck>
<News />
</AuthCheck>
}
/>
</Routes>
</Box>
</Router>

View File

@ -183,6 +183,386 @@ export interface VerificationStatusResponse {
is_verified: boolean;
}
export interface NewsItem {
id: string;
title: string;
markdown: string;
preview?: string;
tags?: string[];
is_published?: boolean;
created_at: string;
updated_at: string;
}
export interface CreateNewsPayload {
title: string;
preview?: string;
markdown: string;
is_published: boolean;
}
export interface MeResponse {
username: string;
uuid: string;
is_admin: boolean;
}
// ===== БОНУСЫ / ПРОКАЧКА =====
export interface UserBonus {
id: string;
bonus_type_id: string;
name: string;
description: string;
effect_type: string;
effect_value: number;
level: number;
purchased_at: string;
can_upgrade: boolean;
upgrade_price: number;
is_active: boolean;
is_permanent: boolean;
expires_at?: string;
image_url?: string;
}
export type UserBonusesResponse = {
bonuses: UserBonus[];
};
export interface BonusType {
id: string;
name: string;
description: string;
effect_type: string;
base_effect_value: number;
effect_increment: number;
price: number;
upgrade_price: number;
duration: number;
max_level: number;
image_url?: string;
}
export type BonusTypesResponse = {
bonuses: BonusType[];
};
export async function fetchBonusTypes(): Promise<BonusType[]> {
const response = await fetch(`${API_BASE_URL}/api/bonuses/types`);
if (!response.ok) {
throw new Error('Не удалось получить список прокачек');
}
const data: BonusTypesResponse = await response.json();
return data.bonuses || [];
}
export async function fetchUserBonuses(username: string): Promise<UserBonus[]> {
const response = await fetch(`${API_BASE_URL}/api/bonuses/user/${username}`);
if (!response.ok) {
throw new Error('Не удалось получить бонусы игрока');
}
const data: UserBonusesResponse = await response.json();
return data.bonuses || [];
}
export async function purchaseBonus(
username: string,
bonus_type_id: string,
): Promise<{
status: string;
message: string;
remaining_coins?: number;
}> {
const response = await fetch(`${API_BASE_URL}/api/bonuses/purchase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
bonus_type_id,
}),
});
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 {
// оставляем дефолтное сообщение
}
throw new Error(msg);
}
return await response.json();
}
export async function upgradeBonus(
username: string,
bonus_id: string,
): Promise<{
status: string;
message: string;
bonus_id: string;
new_level?: number;
}> {
const response = await fetch(`${API_BASE_URL}/api/bonuses/upgrade`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
bonus_id,
}),
});
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 {
// оставляем дефолтное сообщение
}
throw new Error(msg);
}
return await response.json();
}
export async function toggleBonusActivation(
username: string,
bonus_id: string,
): Promise<{
status: string;
message: string;
bonus_id: string;
is_active: boolean;
}> {
const response = await fetch(
`${API_BASE_URL}/api/bonuses/toggle-activation`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
bonus_id,
}),
},
);
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 {
// оставляем дефолтное сообщение
}
throw new Error(msg);
}
return await response.json();
}
// ===== КЕЙСЫ =====
export interface CaseItemMeta {
display_name?: string | null;
lore?: string[] | null;
}
export interface CaseItem {
id: string;
name?: string;
material: string;
amount: number;
weight?: number;
meta?: CaseItemMeta;
}
export interface Case {
id: string;
name: string;
description?: string;
price: number;
image_url?: string;
server_ids?: string[];
items_count?: number;
items?: CaseItem[];
}
export interface OpenCaseResponse {
status: string;
message: string;
operation_id: string;
balance: number;
reward: CaseItem;
}
// ===== КЕЙСЫ =====
export async function fetchCases(): Promise<Case[]> {
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<Case> {
const response = await fetch(`${API_BASE_URL}/cases/${case_id}`);
if (!response.ok) {
throw new Error('Не удалось получить информацию о кейсе');
}
return await response.json();
}
export async function openCase(
case_id: string,
username: string,
server_id: string,
): Promise<OpenCaseResponse> {
// Формируем 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);
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, оставляем дефолтное сообщение
}
throw new Error(msg);
}
return await response.json();
}
export async function fetchMe(): Promise<MeResponse> {
const { accessToken, clientToken } = getAuthTokens();
if (!accessToken || !clientToken) {
throw new Error('Нет токенов авторизации');
}
const params = new URLSearchParams({
accessToken,
clientToken,
});
const response = await fetch(`${API_BASE_URL}/auth/me?${params.toString()}`);
if (!response.ok) {
throw new Error('Не удалось получить данные пользователя');
}
return await response.json();
}
export async function createNews(payload: CreateNewsPayload) {
const { accessToken, clientToken } = getAuthTokens(); // ← используем launcher_config
if (!accessToken || !clientToken) {
throw new Error('Необходимо войти в лаунчер, чтобы публиковать новости');
}
const formData = new FormData();
formData.append('accessToken', accessToken);
formData.append('clientToken', clientToken);
formData.append('title', payload.title);
formData.append('markdown', payload.markdown);
formData.append('preview', payload.preview || '');
formData.append('is_published', String(payload.is_published));
const response = await fetch(`${API_BASE_URL}/news`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || 'Ошибка при создании новости');
}
return await response.json();
}
export async function deleteNews(id: string): Promise<void> {
const { accessToken, clientToken } = getAuthTokens();
if (!accessToken || !clientToken) {
throw new Error('Необходимо войти в лаунчер');
}
const params = new URLSearchParams({
accessToken,
clientToken,
});
const response = await fetch(
`${API_BASE_URL}/news/${id}?${params.toString()}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || 'Не удалось удалить новость');
}
}
export async function fetchNews(): Promise<NewsItem[]> {
const response = await fetch(`${API_BASE_URL}/news`);
if (!response.ok) {
throw new Error('Не удалось получить новости');
}
return await response.json();
}
export async function getVerificationStatus(
username: string,
): Promise<VerificationStatusResponse> {

View File

@ -0,0 +1,308 @@
// src/renderer/components/BonusShopItem.tsx
import React from 'react';
import {
Card,
CardContent,
Box,
Typography,
Button,
CardMedia,
} from '@mui/material';
import CoinsDisplay from './CoinsDisplay';
export interface BonusShopItemProps {
id: string;
name: string;
description?: string;
level: number;
effectValue: number;
nextEffectValue?: number;
// цена покупки и улучшения
price?: number;
upgradePrice: number;
canUpgrade: boolean;
mode?: 'buy' | 'upgrade';
isActive?: boolean;
isPermanent?: boolean;
imageUrl?: string;
disabled?: boolean;
onBuy?: () => void;
onUpgrade?: () => void;
onToggleActive?: () => void;
}
export const BonusShopItem: React.FC<BonusShopItemProps> = ({
name,
description,
level,
effectValue,
nextEffectValue,
price,
upgradePrice,
canUpgrade,
mode,
isActive = true,
isPermanent = false,
imageUrl,
disabled,
onBuy,
onUpgrade,
onToggleActive,
}) => {
const isBuyMode = mode === 'buy' || level === 0;
const buttonText = isBuyMode
? 'Купить'
: canUpgrade
? 'Улучшить'
: 'Макс. уровень';
const displayedPrice = isBuyMode ? (price ?? upgradePrice) : upgradePrice;
const buttonDisabled =
disabled ||
(isBuyMode
? !onBuy || displayedPrice === undefined
: !canUpgrade || !onUpgrade);
const handlePrimaryClick = () => {
if (buttonDisabled) return;
if (isBuyMode && onBuy) onBuy();
else if (!isBuyMode && onUpgrade) onUpgrade();
};
return (
<Card
sx={{
position: 'relative',
width: '100%',
maxWidth: 300,
height: 440,
display: 'flex',
flexDirection: 'column',
background: 'rgba(20,20,20,0.9)',
borderRadius: '2.5vw',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 10px 40px rgba(0,0,0,0.8)',
overflow: 'hidden',
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: '0 20px 60px rgba(242,113,33,0.45)',
},
}}
>
{/* Градиентный свет сверху — как в ShopItem */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'radial-gradient(circle at top, rgba(242,113,33,0.25), transparent 60%)',
}}
/>
{imageUrl && (
<Box sx={{ position: 'relative', p: 1.5, pb: 0 }}>
<Box
sx={{
borderRadius: '1.8vw',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.12)',
background:
'linear-gradient(135deg, rgba(40,40,40,0.9), rgba(15,15,15,0.9))',
}}
>
<CardMedia
component="img"
image={imageUrl}
alt={name}
sx={{
width: '100%',
height: 160,
objectFit: 'cover',
}}
/>
</Box>
</Box>
)}
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
pt: 2,
pb: 2,
}}
>
<Box>
{/* Имя бонуса — градиентом как у ShopItem */}
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.05rem',
mb: 0.5,
backgroundImage:
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{name}
</Typography>
<Typography
sx={{
color: 'rgba(255,255,255,0.7)',
fontSize: '0.8rem',
mb: 0.8,
}}
>
Уровень: {level}
{isPermanent && ' • Постоянный'}
</Typography>
{description && (
<Typography
sx={{
color: 'rgba(255,255,255,0.75)',
fontSize: '0.85rem',
mb: 1.2,
minHeight: 40,
maxHeight: 40,
overflow: 'hidden',
}}
>
{description}
</Typography>
)}
<Box sx={{ mb: 1 }}>
<Typography
sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}
>
Текущий эффект:{' '}
<Box component="b" sx={{ fontWeight: 600 }}>
{effectValue.toLocaleString('ru-RU')}
</Box>
</Typography>
{typeof nextEffectValue === 'number' &&
!isBuyMode &&
canUpgrade && (
<Typography
sx={{
color: 'rgba(255,255,255,0.8)',
fontSize: '0.8rem',
mt: 0.4,
}}
>
Следующий уровень:{' '}
<Box component="b" sx={{ fontWeight: 600 }}>
{nextEffectValue.toLocaleString('ru-RU')}
</Box>
</Typography>
)}
</Box>
<Typography
sx={{
fontSize: '0.78rem',
mb: 1,
color: isActive
? 'rgba(0, 200, 140, 0.9)'
: 'rgba(255, 180, 80, 0.9)',
}}
>
{isActive ? 'Бонус активен' : 'Бонус не активен'}
</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
}}
>
<Typography
sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.85rem' }}
>
{isBuyMode ? 'Цена покупки' : 'Цена улучшения'}
</Typography>
{displayedPrice !== undefined && (
<CoinsDisplay
value={displayedPrice}
size="small"
autoUpdate={false}
showTooltip={true}
/>
)}
</Box>
{!isBuyMode && onToggleActive && (
<Button
variant="outlined"
size="small"
onClick={onToggleActive}
disabled={disabled}
sx={{
mt: 0.5,
borderRadius: '2.5vw',
textTransform: 'none',
fontSize: '0.75rem',
px: 2,
borderColor: 'rgba(255,255,255,0.4)',
color: 'rgba(255,255,255,0.9)',
'&:hover': {
borderColor: 'rgba(255,255,255,0.8)',
background: 'rgba(255,255,255,0.05)',
},
}}
>
{isActive ? 'Выключить' : 'Включить'}
</Button>
)}
</Box>
{/* Кнопка в стиле Registration / ShopItem */}
<Button
variant="contained"
fullWidth
disabled={buttonDisabled}
onClick={handlePrimaryClick}
sx={{
mt: 2,
transition: 'transform 0.3s ease, opacity 0.2s ease',
background: buttonDisabled
? 'linear-gradient(71deg, #555 0%, #666 70%, #444 100%)'
: 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold',
borderRadius: '2.5vw',
fontSize: '0.85rem',
color: '#fff',
opacity: buttonDisabled ? 0.6 : 1,
'&:hover': {
transform: buttonDisabled ? 'none' : 'scale(1.1)',
},
}}
>
{buttonText}
</Button>
</CardContent>
</Card>
);
};
export default BonusShopItem;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Box } from '@mui/material';
interface CapePreviewProps {
imageUrl: string;
alt?: string;
}
export const CapePreview: React.FC<CapePreviewProps> = ({
imageUrl,
alt = 'Плащ',
}) => {
return (
<Box
sx={{
position: 'relative',
width: '100%',
height: 140, // фиксированная область под плащ
overflow: 'hidden',
}}
>
<Box
component="img"
src={imageUrl}
alt={alt}
sx={{
width: '100%',
height: '100%',
imageRendering: 'pixelated',
// Берём старый "зум" из CapeCard — плащ становится большим,
// а лишнее обрезается контейнером.
transform: 'scale(2.9) translateX(0px) translateY(0px)',
transformOrigin: 'top left',
}}
/>
</Box>
);
};

View File

@ -0,0 +1,421 @@
import { Box, Typography, Button, Dialog, DialogContent } from '@mui/material';
import { useEffect, useState, useRef, useCallback } from 'react';
import { CaseItem } from '../api';
type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
interface CaseRouletteProps {
open: boolean;
onClose: () => void;
caseName?: string;
items: CaseItem[];
reward: CaseItem | null;
}
// --- настройки рулетки ---
const ITEM_WIDTH = 110;
const ITEM_GAP = 8;
const VISIBLE_ITEMS = 21;
const CONTAINER_WIDTH = 800;
const LINE_X = CONTAINER_WIDTH / 2;
const ANIMATION_DURATION = 10; // секунды
const ANIMATION_DURATION_MS = ANIMATION_DURATION * 1000;
// Удаляем майнкрафтовские цвет-коды (§a, §b, §l и т.д.)
function stripMinecraftColors(text?: string | null): string {
if (!text) return '';
return text.replace(/§[0-9A-FK-ORa-fk-or]/g, '');
}
function getRarityByWeight(weight?: number): Rarity {
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)';
case 'epic':
return 'rgba(186, 85, 211, 1)';
case 'rare':
return 'rgba(65, 105, 225, 1)';
case 'common':
default:
return 'rgba(255, 255, 255, 0.6)';
}
}
export default function CaseRoulette({
open,
onClose,
caseName,
items,
reward,
}: CaseRouletteProps) {
const [sequence, setSequence] = useState<CaseItem[]>([]);
const [offset, setOffset] = useState(0);
const [animating, setAnimating] = useState(false);
const [animationFinished, setAnimationFinished] = useState(false);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationTimeoutRef = useRef<NodeJS.Timeout>();
const finishTimeoutRef = useRef<NodeJS.Timeout>();
const winningNameRaw =
reward?.meta?.display_name || reward?.name || reward?.material || '';
const winningName = stripMinecraftColors(winningNameRaw);
// Измеряем реальные ширины элементов
const measureItemWidths = useCallback((): number[] => {
return itemRefs.current.map((ref) =>
ref ? ref.getBoundingClientRect().width : ITEM_WIDTH,
);
}, []);
// Основной эффект для инициализации
useEffect(() => {
if (!open || !reward || !items || items.length === 0) return;
if (animationTimeoutRef.current) clearTimeout(animationTimeoutRef.current);
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
setAnimating(false);
setAnimationFinished(false);
setOffset(0);
itemRefs.current = [];
const totalItems = VISIBLE_ITEMS * 3;
const seq: CaseItem[] = [];
for (let i = 0; i < totalItems; i++) {
const randomItem = items[Math.floor(Math.random() * items.length)];
seq.push(randomItem);
}
const winPosition = Math.floor(totalItems / 2);
const fromCase =
items.find((i) => i.material === reward.material) || reward;
seq[winPosition] = fromCase;
setSequence(seq);
}, [open, reward, items]);
// Эффект запуска анимации
useEffect(() => {
if (sequence.length === 0 || !open) return;
const startAnimation = () => {
const widths = measureItemWidths();
const winPosition = Math.floor(sequence.length / 2);
const EXTRA_SPINS = 3;
const averageItemWidth = ITEM_WIDTH + ITEM_GAP;
const extraDistance = EXTRA_SPINS * VISIBLE_ITEMS * averageItemWidth;
if (widths.length === 0 || widths.length !== sequence.length) {
const centerItemCenter =
winPosition * (ITEM_WIDTH + ITEM_GAP) + ITEM_WIDTH / 2;
const finalOffset = centerItemCenter - LINE_X;
const initialOffset = Math.max(finalOffset - extraDistance, 0);
setOffset(initialOffset);
animationTimeoutRef.current = setTimeout(() => {
setAnimating(true);
setOffset(finalOffset);
}, 50);
finishTimeoutRef.current = setTimeout(() => {
setAnimationFinished(true);
}, ANIMATION_DURATION_MS + 200);
return;
}
let cumulativeOffset = 0;
for (let i = 0; i < winPosition; i++) {
cumulativeOffset += widths[i] + ITEM_GAP;
}
const centerItemCenter = cumulativeOffset + widths[winPosition] / 2;
const finalOffset = centerItemCenter - LINE_X;
const initialOffset = Math.max(finalOffset - extraDistance, 0);
setOffset(initialOffset);
animationTimeoutRef.current = setTimeout(() => {
setAnimating(true);
setOffset(finalOffset);
}, 50);
finishTimeoutRef.current = setTimeout(() => {
setAnimationFinished(true);
}, ANIMATION_DURATION_MS + 200);
};
const renderTimeout = setTimeout(startAnimation, 100);
return () => {
clearTimeout(renderTimeout);
if (animationTimeoutRef.current)
clearTimeout(animationTimeoutRef.current);
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
};
}, [sequence, open, measureItemWidths]);
// Очистка при закрытии
useEffect(() => {
if (!open) {
if (animationTimeoutRef.current)
clearTimeout(animationTimeoutRef.current);
if (finishTimeoutRef.current) clearTimeout(finishTimeoutRef.current);
setSequence([]);
setAnimating(false);
setAnimationFinished(false);
setOffset(0);
}
}, [open]);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
bgcolor: 'transparent',
borderRadius: '2.5vw',
overflow: 'hidden',
boxShadow: '0 30px 80px rgba(0,0,0,0.9)',
},
}}
>
<DialogContent
sx={{
position: 'relative',
px: 3,
py: 3.5,
background:
'radial-gradient(circle at top, #101018 0%, #050509 40%, #000 100%)',
}}
>
{/* лёгкий "бордер" по контуру */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
borderRadius: '2.5vw',
border: '1px solid rgba(255,255,255,0.08)',
}}
/>
{/* заголовок с градиентом как в Registration */}
<Typography
variant="h6"
sx={{
textAlign: 'center',
mb: 2.5,
fontFamily: 'Benzin-Bold',
letterSpacing: 0.6,
backgroundImage:
'linear-gradient(136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Открытие кейса {caseName}
</Typography>
{/* контейнер рулетки */}
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderRadius: '2vw',
px: 2,
py: 3,
width: `${CONTAINER_WIDTH}px`,
maxWidth: '100%',
mx: 'auto',
background:
'linear-gradient(135deg, rgba(15,15,20,0.96), rgba(30,20,35,0.96))',
boxShadow: '0 0 40px rgba(0,0,0,0.8)',
}}
>
{/* затемнённые края */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'linear-gradient(90deg, rgba(0,0,0,0.85) 0%, transparent 20%, transparent 80%, rgba(0,0,0,0.85) 100%)',
zIndex: 1,
}}
/>
{/* центральная линия (прицел) */}
<Box
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${LINE_X}px`,
transform: 'translateX(-1px)',
width: '2px',
background:
'linear-gradient(180deg, rgb(242,113,33), rgb(233,64,87))',
boxShadow: '0 0 16px rgba(233,64,87,0.9)',
zIndex: 2,
}}
/>
{/* Лента предметов */}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: `${ITEM_GAP}px`,
transform: `translateX(-${offset}px)`,
willChange: 'transform',
transition: animating
? `transform ${ANIMATION_DURATION}s cubic-bezier(0.15, 0.85, 0.25, 1)`
: 'none',
position: 'relative',
zIndex: 0,
}}
>
{sequence.map((item, index) => {
const color = getRarityColor(item.weight);
const isWinningItem =
animationFinished && index === Math.floor(sequence.length / 2);
const rawName =
item.meta?.display_name ||
item.name ||
item.material
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (l) => l.toUpperCase());
const displayName = stripMinecraftColors(rawName);
return (
<Box
key={index}
ref={(el) => (itemRefs.current[index] = el)}
sx={{
minWidth: `${ITEM_WIDTH}px`,
height: 130,
borderRadius: '1.4vw',
background: 'rgba(255,255,255,0.03)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: isWinningItem
? `2px solid ${color}`
: `1px solid ${color}`,
boxShadow: isWinningItem
? `0 0 24px ${color}`
: '0 0 10px rgba(0,0,0,0.6)',
transition: 'all 0.3s ease',
px: 1,
transform: isWinningItem ? 'scale(1.08)' : 'scale(1)',
}}
>
<Box
component="img"
src={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
alt={item.material}
sx={{
width: 52,
height: 52,
objectFit: 'contain',
imageRendering: 'pixelated',
mb: 1,
}}
/>
<Typography
variant="body2"
sx={{
color: 'white',
textAlign: 'center',
fontSize: '0.72rem',
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayName}
</Typography>
</Box>
);
})}
</Box>
</Box>
{animationFinished && winningName && (
<Typography
variant="body1"
sx={{
textAlign: 'center',
mt: 2.5,
color: 'rgba(255,255,255,0.9)',
}}
>
Вам выпало:{' '}
<Box
component="span"
sx={{
fontFamily: 'Benzin-Bold',
backgroundImage:
'linear-gradient(136deg, rgb(242,113,33) 0%, rgb(233,64,87) 50%, rgb(138,35,135) 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{winningName}
</Box>
</Typography>
)}
{/* кнопка как в Registration */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Button
variant="contained"
onClick={onClose}
sx={{
transition: 'transform 0.3s ease',
borderRadius: '2.5vw',
px: '3vw',
py: '0.7vw',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold',
fontSize: '0.9rem',
textTransform: 'uppercase',
letterSpacing: 1,
color: '#fff',
opacity: animationFinished ? 1 : 0.4,
pointerEvents: animationFinished ? 'auto' : 'none',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
Закрыть
</Button>
</Box>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,233 @@
// CoinsDisplay.tsx
import { Box, Typography } from '@mui/material';
import CustomTooltip from './CustomTooltip';
import { useEffect, useState } from 'react';
import { fetchCoins } from '../api';
interface CoinsDisplayProps {
// Основные пропсы
value?: number; // Передаем значение напрямую
username?: string; // Или получаем по username из API
// Опции отображения
size?: 'small' | 'medium' | 'large';
showTooltip?: boolean;
tooltipText?: string;
showIcon?: boolean;
iconColor?: string;
// Опции обновления
autoUpdate?: boolean; // Автоматическое обновление из API
updateInterval?: number; // Интервал обновления в миллисекундах
// Стилизация
backgroundColor?: string;
textColor?: string;
}
export default function CoinsDisplay({
// Основные пропсы
value: externalValue,
username,
// Опции отображения
size = 'medium',
showTooltip = true,
tooltipText = 'Попы — внутриигровая валюта, начисляемая за время игры на серверах.',
showIcon = true,
iconColor = '#2bff00ff',
// Опции обновления
autoUpdate = false,
updateInterval = 60000,
// Стилизация
backgroundColor = 'rgba(0, 0, 0, 0.2)',
textColor = 'white',
}: CoinsDisplayProps) {
const [coins, setCoins] = useState<number>(externalValue || 0);
const [isLoading, setIsLoading] = useState<boolean>(false);
// Определяем размеры в зависимости от параметра size
const getSizes = () => {
switch (size) {
case 'small':
return {
containerPadding: '4px 8px',
iconSize: '16px',
fontSize: '12px',
borderRadius: '12px',
gap: '6px',
};
case 'large':
return {
containerPadding: '8px 16px',
iconSize: '28px',
fontSize: '18px',
borderRadius: '20px',
gap: '10px',
};
case 'medium':
default:
return {
containerPadding: '6px 12px',
iconSize: '24px',
fontSize: '16px',
borderRadius: '16px',
gap: '8px',
};
}
};
const sizes = getSizes();
// Функция для получения количества монет из API
const fetchCoinsData = async () => {
if (!username) return;
setIsLoading(true);
try {
const coinsData = await fetchCoins(username);
setCoins(coinsData.coins);
} catch (error) {
console.error('Ошибка при получении количества монет:', error);
} finally {
setIsLoading(false);
}
};
// Эффект для внешнего значения
useEffect(() => {
if (externalValue !== undefined) {
setCoins(externalValue);
}
}, [externalValue]);
// Эффект для API обновлений
useEffect(() => {
if (username && autoUpdate) {
fetchCoinsData();
// Создаем интервалы для периодического обновления данных
const coinsInterval = setInterval(fetchCoinsData, updateInterval);
return () => {
clearInterval(coinsInterval);
};
}
}, [username, autoUpdate, updateInterval]);
// Ручное обновление данных
const handleRefresh = () => {
if (username) {
fetchCoinsData();
}
};
// Форматирование числа с разделителями тысяч
const formatNumber = (num: number): string => {
return num.toLocaleString('ru-RU');
};
const coinsDisplay = (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: sizes.gap,
backgroundColor,
borderRadius: sizes.borderRadius,
padding: sizes.containerPadding,
border: '1px solid rgba(255, 255, 255, 0.1)',
cursor: showTooltip ? 'help' : 'default',
opacity: isLoading ? 0.7 : 1,
transition: 'opacity 0.2s ease',
}}
onClick={username ? handleRefresh : undefined}
title={username ? 'Нажмите для обновления' : undefined}
>
{showIcon && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: sizes.iconSize,
height: sizes.iconSize,
borderRadius: '50%',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
>
<Typography
sx={{
color: iconColor,
fontWeight: 'bold',
fontSize: `calc(${sizes.fontSize} * 0.8)`,
}}
>
P
</Typography>
</Box>
)}
<Typography
variant="body1"
sx={{
color: textColor,
fontWeight: 'bold',
fontSize: sizes.fontSize,
lineHeight: 1,
fontFamily: 'Benzin-Bold, sans-serif',
}}
>
{isLoading ? '...' : formatNumber(coins)}
</Typography>
</Box>
);
if (showTooltip) {
return (
<CustomTooltip
title={tooltipText}
arrow
placement="bottom"
TransitionProps={{ timeout: 300 }}
>
{coinsDisplay}
</CustomTooltip>
);
}
return coinsDisplay;
}
// Примеры использования в комментариях для разработчика:
/*
// Пример 1: Простое отображение числа
<CoinsDisplay value={1500} />
// Пример 2: Получение данных по username с автообновлением
<CoinsDisplay
username="player123"
autoUpdate={true}
updateInterval={30000} // обновлять каждые 30 секунд
/>
// Пример 3: Кастомная стилизация без иконки
<CoinsDisplay
value={9999}
size="small"
showIcon={false}
showTooltip={false}
backgroundColor="rgba(255, 100, 100, 0.2)"
textColor="#ffcc00"
/>
// Пример 4: Большой отображение для профиля
<CoinsDisplay
username="player123"
size="large"
tooltipText="Ваш текущий баланс"
iconColor="#00ffaa"
/>
*/

View File

@ -8,16 +8,28 @@ const CustomTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} classes={{ popper: className }} />
))(({ theme }) => ({
[`& .${tooltipClasses.tooltip}`]: {
backgroundColor: 'rgba(0, 0, 0, 1)',
color: 'white',
backgroundColor: 'rgba(0, 0, 0, 0.9)',
color: '#fff',
maxWidth: 300,
fontSize: '0.9vw',
border: '1px solid rgba(255, 77, 77, 0.5)',
border: '1px solid rgba(242, 113, 33, 0.5)',
borderRadius: '1vw',
padding: '1vw',
boxShadow:
'0 0 1vw rgba(255, 77, 77, 0.3), inset 0.8vw -0.8vw 2vw rgba(255, 77, 77, 0.15)',
boxShadow: `
0 0 1.5vw rgba(242, 113, 33, 0.4),
0 0 0.5vw rgba(233, 64, 87, 0.3),
inset 0 0 0.5vw rgba(138, 35, 135, 0.2)
`,
fontFamily: 'Benzin-Bold',
background: `
linear-gradient(
135deg,
rgba(0, 0, 0, 0.95) 0%,
rgba(20, 20, 20, 0.95) 100%
)
`,
position: 'relative',
zIndex: 1,
'&::before': {
content: '""',
position: 'absolute',
@ -26,12 +38,37 @@ const CustomTooltip = styled(({ className, ...props }: TooltipProps) => (
right: 0,
bottom: 0,
borderRadius: '1vw',
// background: 'linear-gradient(45deg, rgba(255, 77, 77, 0.1), transparent)',
padding: '2px',
background: `
linear-gradient(
135deg,
rgba(242, 113, 33, 0.8) 0%,
rgba(233, 64, 87, 0.6) 50%,
rgba(138, 35, 135, 0.4) 100%
)
`,
WebkitMask: `
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0)
`,
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
zIndex: -1,
},
},
[`& .${tooltipClasses.arrow}`]: {
color: 'rgba(255, 77, 77, 0.5)',
color: 'rgba(242, 113, 33, 0.9)',
'&::before': {
background: `
linear-gradient(
135deg,
rgba(242, 113, 33, 0.9) 0%,
rgba(233, 64, 87, 0.7) 50%,
rgba(138, 35, 135, 0.5) 100%
)
`,
border: '1px solid rgba(242, 113, 33, 0.5)',
},
},
}));

View File

@ -8,12 +8,12 @@ import {
ListItemIcon,
ListItemText,
Collapse,
CircularProgress,
} from '@mui/material';
import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { FullScreenLoader } from '../components/FullScreenLoader';
interface FileNode {
name: string;
@ -190,7 +190,7 @@ export default function FilesSelector({
};
if (loading) {
return <CircularProgress />;
return <FullScreenLoader fullScreen={false} />;
}
if (error) {

View File

@ -0,0 +1,72 @@
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
interface FullScreenLoaderProps {
message?: string;
fullScreen?: boolean; // <-- новый проп
}
export const FullScreenLoader = ({
message,
fullScreen = true,
}: FullScreenLoaderProps) => {
const containerSx = fullScreen
? {
position: 'fixed' as const,
inset: 0,
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: 3,
zIndex: 9999,
pointerEvents: 'none' as const,
}
: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: 3,
width: '100%',
height: '100%',
};
return (
<Box sx={containerSx}>
{/* Градиентное вращающееся кольцо */}
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
position: 'relative',
overflow: 'hidden',
background: 'conic-gradient(#F27121, #E940CD, #8A2387, #F27121)',
animation: 'spin 1s linear infinite',
WebkitMask: 'radial-gradient(circle, transparent 55%, black 56%)',
mask: 'radial-gradient(circle, transparent 55%, black 56%)',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
{message && (
<Typography
variant="h6"
sx={{
fontFamily: 'Benzin-Bold',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{message}
</Typography>
)}
</Box>
);
};

View File

@ -0,0 +1,72 @@
// src/renderer/components/HeadAvatar.tsx
import { useEffect, useRef } from 'react';
interface HeadAvatarProps {
skinUrl?: string;
size?: number; // финальный размер головы, px
}
export const HeadAvatar: React.FC<HeadAvatarProps> = ({
skinUrl,
size = 24,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!skinUrl || !canvasRef.current) return;
const img = new Image();
img.crossOrigin = 'anonymous'; // на всякий случай, если CDN
img.src = skinUrl;
img.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = size;
canvas.height = size;
ctx.clearRect(0, 0, size, size);
// Координаты головы в стандартном скине 64x64:
// База головы: (8, 8, 8, 8)
// Слой шляпы/маски: (40, 8, 8, 8)
// Рисуем основную голову
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
img,
8, // sx
8, // sy
8, // sWidth
8, // sHeight
0, // dx
0, // dy
size, // dWidth
size, // dHeight
);
// Рисуем слой шляпы поверх (если есть)
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, size, size);
};
img.onerror = (e) => {
console.error('Не удалось загрузить скин для HeadAvatar:', e);
};
}, [skinUrl, size]);
return (
<canvas
ref={canvasRef}
style={{
width: size,
height: size,
borderRadius: 4,
imageRendering: 'pixelated',
}}
/>
);
};

View File

@ -0,0 +1,69 @@
// components/MarkdownEditor.tsx
import { useEffect, useRef } from 'react';
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
}
export const MarkdownEditor = ({ value, onChange }: MarkdownEditorProps) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const editorRef = useRef<EasyMDE | null>(null);
// Один раз создаём EasyMDE поверх textarea
useEffect(() => {
if (!textareaRef.current) return;
if (editorRef.current) return; // уже создан
const instance = new EasyMDE({
element: textareaRef.current,
initialValue: value,
spellChecker: false,
minHeight: '200px',
toolbar: [
'bold',
'italic',
'strikethrough',
'|',
'heading',
'quote',
'unordered-list',
'ordered-list',
'|',
'link',
'image',
'|',
'preview',
'side-by-side',
'fullscreen',
'|',
'guide',
],
status: false,
});
instance.codemirror.on('change', () => {
onChange(instance.value());
});
editorRef.current = instance;
// При анмаунте красиво убрать за собой
return () => {
instance.toTextArea();
editorRef.current = null;
};
}, []);
// Если извне поменяли value — обновляем редактор
useEffect(() => {
if (editorRef.current && editorRef.current.value() !== value) {
editorRef.current.value(value);
}
}, [value]);
// Сам текстариа — просто якорь для EasyMDE
return <textarea ref={textareaRef} />;
};

View File

@ -0,0 +1,281 @@
// src/renderer/components/OnlinePlayersPanel.tsx
import { useEffect, useState, useMemo } from 'react';
import {
Box,
Typography,
Paper,
Chip,
TextField,
MenuItem,
Select,
FormControl,
InputLabel,
} from '@mui/material';
import {
fetchActiveServers,
fetchOnlinePlayers,
fetchPlayer,
Server,
} from '../api';
import { FullScreenLoader } from './FullScreenLoader';
import { HeadAvatar } from './HeadAvatar';
import { translateServer } from '../utils/serverTranslator';
type OnlinePlayerFlat = {
username: string;
uuid: string;
serverId: string;
serverName: string;
onlineSince: string;
};
interface OnlinePlayersPanelProps {
currentUsername: string;
}
export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
currentUsername,
}) => {
const [servers, setServers] = useState<Server[]>([]);
const [onlinePlayers, setOnlinePlayers] = useState<OnlinePlayerFlat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serverFilter, setServerFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [skinMap, setSkinMap] = useState<Record<string, string>>({});
useEffect(() => {
const load = async () => {
try {
setLoading(true);
setError(null);
const activeServers = await fetchActiveServers();
setServers(activeServers);
const results = await Promise.all(
activeServers.map((s) => fetchOnlinePlayers(s.id)),
);
const flat: OnlinePlayerFlat[] = [];
results.forEach((res) => {
res.online_players.forEach((p) => {
flat.push({
username: p.username,
uuid: p.uuid,
serverId: res.server.id,
serverName: res.server.name,
onlineSince: p.online_since,
});
});
});
setOnlinePlayers(flat);
} catch (e: any) {
setError(e?.message || 'Не удалось загрузить онлайн игроков');
} finally {
setLoading(false);
}
};
load();
}, []);
// Догружаем скины по uuid
useEffect(() => {
const loadSkins = async () => {
// Берём всех видимых игроков (чтобы не грузить для тысяч, если их много)
const uuids = Array.from(new Set(onlinePlayers.map((p) => p.uuid)));
const toLoad = uuids.filter((uuid) => !skinMap[uuid]);
if (!toLoad.length) return;
try {
// Просто по очереди, чтобы не DDOS'ить API
for (const uuid of toLoad) {
try {
const player = await fetchPlayer(uuid);
if (player.skin_url) {
setSkinMap((prev) => ({
...prev,
[uuid]: player.skin_url,
}));
}
} catch (e) {
console.warn('Не удалось получить скин для', uuid, e);
}
}
} catch (e) {
console.error('Ошибка при загрузке скинов:', e);
}
};
loadSkins();
}, [onlinePlayers]);
const filteredPlayers = useMemo(() => {
return (
onlinePlayers
.filter((p) =>
serverFilter === 'all' ? true : p.serverId === serverFilter,
)
.filter((p) =>
search.trim()
? p.username.toLowerCase().includes(search.toLowerCase())
: true,
)
// свой ник наверх
.sort((a, b) => {
if (a.username === currentUsername && b.username !== currentUsername)
return -1;
if (b.username === currentUsername && a.username !== currentUsername)
return 1;
return a.username.localeCompare(b.username);
})
);
}, [onlinePlayers, serverFilter, search, currentUsername]);
if (loading) {
return <FullScreenLoader message="Загружаем игроков онлайн..." />;
}
if (error) {
return (
<Typography color="error" sx={{ mt: 2 }}>
{error}
</Typography>
);
}
if (!onlinePlayers.length) {
return (
<Typography sx={{ mt: 2, opacity: 0.8 }}>
Сейчас на серверах никого нет.
</Typography>
);
}
const totalOnline = onlinePlayers.length;
return (
<Paper
sx={{
mt: 3,
p: 2,
borderRadius: '1vw',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
color: 'white',
}}
elevation={0}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
gap: 2,
}}
>
<Box>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.2vw',
mb: 0.5,
}}
>
Игроки онлайн
</Typography>
<Typography sx={{ fontSize: '0.9vw', opacity: 0.7 }}>
Сейчас на серверах: {totalOnline}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel sx={{ color: 'white' }}>Сервер</InputLabel>
<Select
label="Сервер"
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
sx={{ color: 'white' }}
>
<MenuItem value="all" sx={{ color: 'black' }}>
Все сервера
</MenuItem>
{servers.map((s) => (
<MenuItem key={s.id} value={s.id} sx={{ color: 'black' }}>
{s.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="Поиск по нику"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ color: 'white' }}
/>
</Box>
</Box>
<Box
sx={{
maxHeight: '35vh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{filteredPlayers.map((p) => (
<Box
key={p.uuid}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 0.6,
px: 1.5,
borderRadius: '999px',
background: 'rgba(0,0,0,0.35)',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<HeadAvatar skinUrl={skinMap[p.uuid]} size={24} />
<Typography sx={{ fontFamily: 'Benzin-Bold' }}>
{p.username}
</Typography>
{p.username === currentUsername && (
<Chip
label="Вы"
size="small"
sx={{
height: '1.4rem',
fontSize: '0.7rem',
bgcolor: 'rgb(255,77,77)',
color: 'white',
}}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Chip
label={translateServer({ name: p.serverName })}
size="small"
sx={{ bgcolor: 'rgba(255,255,255,0.08)', color: 'white' }}
/>
{/* Можно позже красиво форматировать onlineSince */}
</Box>
</Box>
))}
</Box>
</Paper>
);
};

View File

@ -8,7 +8,6 @@ import {
CardMedia,
CardContent,
Button,
CircularProgress,
Dialog,
DialogTitle,
DialogContent,
@ -22,6 +21,7 @@ import {
sellItem,
PlayerInventoryItem,
} from '../api';
import { FullScreenLoader } from './FullScreenLoader';
interface PlayerInventoryProps {
username: string;
@ -223,9 +223,7 @@ export default function PlayerInventory({
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<CircularProgress />
</Box>
<FullScreenLoader fullScreen={false} />
) : (
<>
{inventoryItems.length === 0 ? (
@ -237,10 +235,15 @@ export default function PlayerInventory({
Ваш инвентарь пуст или не удалось загрузить предметы.
</Typography>
) : (
<Grid container spacing={2}>
<Grid
container
spacing={2}
columns={10}
sx={{ justifyContent: 'center' }}
>
{inventoryItems.map((item) =>
item.material !== 'AIR' && item.amount > 0 ? (
<Grid item xs={6} sm={4} md={3} lg={2} key={item.slot}>
<Grid item xs={1} key={item.slot}>
<Card
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
@ -258,19 +261,33 @@ export default function PlayerInventory({
minHeight: '10vw',
maxHeight: '10vw',
objectFit: 'contain',
bgcolor: 'white',
p: '1vw',
imageRendering: 'pixelated',
}}
image={`/minecraft/${item.material.toLowerCase()}.png`}
image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
alt={item.material}
/>
<CardContent sx={{ p: 1 }}>
<Box sx={{ display: 'flex', gap: '1vw', justifyContent: 'space-between' }}>
<Typography variant="body2" color="white" noWrap>
<Box
sx={{
display: 'flex',
gap: '1vw',
justifyContent: 'space-between',
}}
>
<Typography
variant="body2"
color="white"
noWrap
sx={{ fontSize: '0.8vw' }}
>
{getItemDisplayName(item.material)}
</Typography>
<Typography variant="body2" color="white">
<Typography
variant="body2"
color="white"
sx={{ fontSize: '0.8vw' }}
>
{item.amount > 1 ? `x${item.amount}` : ''}
</Typography>
</Box>
@ -278,7 +295,7 @@ export default function PlayerInventory({
<Typography
variant="caption"
color="secondary"
sx={{ display: 'block' }}
sx={{ display: 'block', fontSize: '0.8vw' }}
>
Зачарования: {Object.keys(item.enchants).length}
</Typography>
@ -308,7 +325,7 @@ export default function PlayerInventory({
objectFit: 'contain',
mr: 2,
}}
image={`/items/${selectedItem.material.toLowerCase()}.png`}
image={`https://cdn.minecraft.popa-popa.ru/textures/${selectedItem.material.toLowerCase()}.png`}
alt={selectedItem.material}
/>
<Typography variant="h6">
@ -363,7 +380,7 @@ export default function PlayerInventory({
color="primary"
disabled={sellLoading}
>
{sellLoading ? <CircularProgress size={24} /> : 'Продать'}
{sellLoading ? <FullScreenLoader fullScreen={false} /> : 'Продать'}
</Button>
</DialogActions>
</Dialog>

View File

@ -0,0 +1,79 @@
// src/renderer/components/CapePreviewModal.tsx
import React from 'react';
import {
Dialog,
DialogContent,
IconButton,
Box,
Typography,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import SkinViewer from './SkinViewer';
interface CapePreviewModalProps {
open: boolean;
onClose: () => void;
capeUrl: string;
skinUrl?: string;
}
const CapePreviewModal: React.FC<CapePreviewModalProps> = ({
open,
onClose,
capeUrl,
skinUrl,
}) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogContent
sx={{
bgcolor: 'rgba(5, 5, 15, 0.96)',
position: 'relative',
p: 2,
}}
>
<IconButton
onClick={onClose}
sx={{
position: 'absolute',
top: 8,
right: 8,
color: 'white',
}}
>
<CloseIcon />
</IconButton>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
fontSize: '1.1rem',
}}
>
Предпросмотр плаща
</Typography>
<SkinViewer
width={350}
height={450}
capeUrl={capeUrl} // скин возьмётся дефолтный из SkinViewer
skinUrl={skinUrl}
autoRotate={true}
walkingSpeed={0.5}
/>
</Box>
</DialogContent>
</Dialog>
);
};
export default CapePreviewModal;

View File

@ -1,4 +1,4 @@
import { Box, Typography, CircularProgress, Avatar } from '@mui/material';
import { Box, Typography, Avatar } from '@mui/material';
import { useEffect, useState } from 'react';
interface ServerStatusProps {

View File

@ -0,0 +1,240 @@
// src/renderer/components/ShopItem.tsx
import React, { useState } from 'react';
import {
Card,
CardMedia,
CardContent,
Box,
Typography,
Button,
IconButton,
} from '@mui/material';
import CoinsDisplay from './CoinsDisplay';
import { CapePreview } from './CapePreview';
import VisibilityIcon from '@mui/icons-material/Visibility';
import CapePreviewModal from './PlayerPreviewModal';
export type ShopItemType = 'case' | 'cape';
export interface ShopItemProps {
type: ShopItemType;
id: string;
name: string;
description?: string;
imageUrl?: string;
price?: number;
itemsCount?: number;
isOpening?: boolean;
playerSkinUrl?: string;
disabled?: boolean;
onClick: () => void;
}
export default function ShopItem({
type,
name,
description,
imageUrl,
price,
itemsCount,
isOpening,
disabled,
playerSkinUrl,
onClick,
}: ShopItemProps) {
const buttonText =
type === 'case' ? (isOpening ? 'Открываем...' : 'Открыть кейс') : 'Купить';
const [previewOpen, setPreviewOpen] = useState(false);
return (
<Card
sx={{
position: 'relative',
width: '100%',
maxWidth: 300,
height: 440,
display: 'flex',
flexDirection: 'column',
background: 'rgba(20,20,20,0.9)',
borderRadius: '2.5vw',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 10px 40px rgba(0,0,0,0.8)',
overflow: 'hidden',
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
'&:hover': {
transform: 'scale(1.05)',
boxShadow: '0 20px 60px rgba(242,113,33,0.45)',
},
}}
>
{/* Градиентный свет сверху */}
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background:
'radial-gradient(circle at top, rgba(242,113,33,0.25), transparent 60%)',
}}
/>
{imageUrl && (
<Box sx={{ position: 'relative', p: 1.5, pb: 0 }}>
{type === 'case' ? (
<Box
sx={{
borderRadius: '1.8vw',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.12)',
background:
'linear-gradient(135deg, rgba(40,40,40,0.9), rgba(15,15,15,0.9))',
}}
>
<CardMedia
component="img"
image={imageUrl}
alt={name}
sx={{
width: '100%',
height: 160,
objectFit: 'cover',
}}
/>
</Box>
) : (
<CapePreview imageUrl={imageUrl} alt={name} />
)}
{/* Кнопка предпросмотра плаща */}
{type === 'cape' && (
<IconButton
onClick={(e) => {
e.stopPropagation();
setPreviewOpen(true);
}}
sx={{
position: 'absolute',
top: 10,
right: 10,
color: 'white',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
<VisibilityIcon fontSize="small" />
</IconButton>
)}
</Box>
)}
<CardContent
sx={{
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
pt: 2,
pb: 2,
}}
>
<Box>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.05rem',
mb: 1,
backgroundImage:
'linear-gradient(136deg, rgb(242,113,33), rgb(233,64,87), rgb(138,35,135))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{name}
</Typography>
{description && (
<Typography
sx={{
color: 'rgba(255,255,255,0.75)',
fontSize: '0.85rem',
minHeight: 42,
maxHeight: 42,
overflow: 'hidden',
}}
>
{description}
</Typography>
)}
{typeof price === 'number' && (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 1.2,
}}
>
<Typography
sx={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.8rem' }}
>
Цена
</Typography>
<CoinsDisplay value={price} size="small" />
</Box>
)}
{type === 'case' && typeof itemsCount === 'number' && (
<Typography
sx={{
color: 'rgba(255,255,255,0.6)',
fontSize: '0.75rem',
mt: 1,
}}
>
Предметов в кейсе: {itemsCount}
</Typography>
)}
</Box>
{/* Кнопка как в Registration */}
<Button
variant="contained"
fullWidth
disabled={disabled}
onClick={onClick}
sx={{
mt: 2,
transition: 'transform 0.3s ease',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold',
borderRadius: '2.5vw',
fontSize: '0.85rem',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
{buttonText}
</Button>
</CardContent>
{type === 'cape' && imageUrl && (
<CapePreviewModal
open={previewOpen}
onClose={() => setPreviewOpen(false)}
capeUrl={imageUrl}
skinUrl={playerSkinUrl}
/>
)}
</Card>
);
}

View File

@ -2,10 +2,11 @@ import { Box, Button, Tab, Tabs, Typography } from '@mui/material';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { useLocation, useNavigate } from 'react-router-dom';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Tooltip } from '@mui/material';
import { fetchCoins } from '../api';
import CustomTooltip from './CustomTooltip';
import CoinsDisplay from './CoinsDisplay';
declare global {
interface Window {
electron: {
@ -33,50 +34,89 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
const isRegistrationPage = location.pathname === '/registration';
const navigate = useNavigate();
const [coins, setCoins] = useState<number>(0);
const [value, setValue] = useState(0);
const [value, setValue] = useState(1);
const [activePage, setActivePage] = useState('versions');
const tabsWrapperRef = useRef<HTMLDivElement | null>(null);
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
if (newValue === 0) {
navigate('/');
navigate('/news');
} else if (newValue === 1) {
navigate('/profile');
navigate('/');
} else if (newValue === 2) {
navigate('/shop');
navigate('/profile');
} else if (newValue === 3) {
navigate('/shop');
} else if (newValue === 4) {
navigate('/marketplace');
}
};
useEffect(() => {
if (location.pathname === '/news') {
setValue(0);
setActivePage('news');
} else if (location.pathname === '/') {
setValue(1);
setActivePage('versions');
} else if (location.pathname.startsWith('/profile')) {
setValue(2);
setActivePage('profile');
} else if (location.pathname.startsWith('/shop')) {
setValue(3);
setActivePage('shop');
} else if (location.pathname.startsWith('/marketplace')) {
setValue(4);
setActivePage('marketplace');
}
}, [location.pathname]);
const handleLaunchPage = () => {
navigate('/');
};
const getPageTitle = () => {
if (isLoginPage) {
return 'Вход';
}
if (isLaunchPage) {
return 'Запуск';
}
if (isVersionsExplorerPage) {
if (activePage === 'versions') {
return 'Версии';
}
if (activePage === 'profile') {
return 'Профиль';
}
if (activePage === 'shop') {
return 'Магазин';
}
if (activePage === 'marketplace') {
return 'Рынок';
}
}
return 'Неизвестная страница';
const handleTabsWheel = (event: React.WheelEvent<HTMLDivElement>) => {
// чтобы страница не скроллилась вертикально
event.preventDefault();
if (!tabsWrapperRef.current) return;
// Находим внутренний скроллер MUI Tabs
const scroller = tabsWrapperRef.current.querySelector(
'.MuiTabs-scroller',
) as HTMLDivElement | null;
if (!scroller) return;
// Прокручиваем горизонтально, используя вертикальный скролл мыши
scroller.scrollLeft += event.deltaY * 0.3;
};
// const getPageTitle = () => {
// if (isLoginPage) {
// return 'Вход';
// }
// if (isLaunchPage) {
// return 'Запуск';
// }
// if (isVersionsExplorerPage) {
// if (activePage === 'versions') {
// return 'Версии';
// }
// if (activePage === 'profile') {
// return 'Профиль';
// }
// if (activePage === 'shop') {
// return 'Магазин';
// }
// if (activePage === 'marketplace') {
// return 'Рынок';
// }
// }
// return 'Неизвестная страница';
// };
// Функция для получения количества монет
const fetchCoinsData = async () => {
if (!username) return;
@ -148,6 +188,10 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
color: 'white',
minWidth: 'unset',
minHeight: 'unset',
transition: 'transform 0.3s ease',
'&:hover': {
transform: 'scale(1.2)',
},
}}
>
<ArrowBackRoundedIcon />
@ -155,6 +199,8 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
)}
{!isLaunchPage && !isRegistrationPage && !isLoginPage && (
<Box
ref={tabsWrapperRef}
onWheel={handleTabsWheel}
sx={{
borderBottom: 1,
borderColor: 'transparent',
@ -162,13 +208,43 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
backgroundColor: 'rgba(255, 77, 77, 1)',
},
}}
>
<CustomTooltip
title={
'Покрути колесиком мыши чтобы увидеть остальные элементы меню'
}
arrow
placement="bottom"
TransitionProps={{ timeout: 100 }}
>
<Tabs
value={value}
onChange={handleChange}
aria-label="basic tabs example"
variant="scrollable"
scrollButtons={false}
disableRipple={true}
sx={{ maxWidth: '42vw' }}
>
<Tab
label="Новости"
disableRipple={true}
onClick={() => {
setActivePage('news');
}}
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
'&.Mui-selected': {
color: 'rgba(255, 77, 77, 1)',
},
'&:hover': {
color: 'rgb(177, 52, 52)',
},
transition: 'all 0.3s ease',
}}
/>
<Tab
label="Версии"
disableRipple={true}
@ -246,6 +322,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
}}
/>
</Tabs>
</CustomTooltip>
</Box>
)}
</Box>
@ -262,12 +339,12 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
WebkitAppRegion: 'drag',
}}
>
<Typography
{/* <Typography
variant="h6"
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
>
{getPageTitle()}
</Typography>
</Typography> */}
</Box>
{/* Правая часть со всеми кнопками */}
<Box
@ -285,16 +362,23 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
color="primary"
onClick={() => logout()}
sx={{
width: '10em',
width: '8em',
height: '3em',
borderRadius: '1.5vw',
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
fontSize: '0.9em',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
color: 'white',
backgroundImage: 'linear-gradient(to right, #7BB8FF, #FFB7ED)',
border: 'unset',
border: 'none',
transition: 'transform 0.3s ease',
'&:hover': {
backgroundImage: 'linear-gradient(to right, #6AA8EE, #EEA7DD)',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
transform: 'scale(1.05)',
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
},
boxShadow: '0.5em 0.5em 0.5em 0px #00000040 inset',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}}
>
Выйти
@ -302,63 +386,35 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
)}
{/* Кнопка регистрации, если на странице логина */}
{!isLoginPage && !isRegistrationPage && username && (
<CustomTooltip
title="Попы — внутриигровая валюта, начисляемая за время игры на серверах."
arrow
placement="bottom"
TransitionProps={{ timeout: 300 }}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
borderRadius: '16px',
padding: '6px 12px',
border: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '24px',
height: '24px',
}}
>
<Typography sx={{ color: '#2bff00ff' }}>P</Typography>
</Box>
<Typography
variant="body1"
sx={{
color: 'white',
fontWeight: 'bold',
fontSize: '16px',
lineHeight: 1,
}}
>
{coins}
</Typography>
</Box>
</CustomTooltip>
<CoinsDisplay
username={username}
size="medium"
autoUpdate={true}
showTooltip={true}
/>
)}
{isLoginPage && (
<Button
variant="outlined"
color="primary"
variant="contained"
onClick={() => navigate('/registration')}
sx={{
width: '10em',
width: '13em',
height: '3em',
borderRadius: '1.5vw',
borderRadius: '2.5vw',
fontFamily: 'Benzin-Bold',
fontSize: '0.9em',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
color: 'white',
backgroundImage: 'linear-gradient(to right, #7BB8FF, #FFB7ED)',
border: 'unset',
border: 'none',
transition: 'transform 0.3s ease',
'&:hover': {
backgroundImage: 'linear-gradient(to right, #6AA8EE, #EEA7DD)',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
transform: 'scale(1.05)',
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
},
boxShadow: '0.5em 0.5em 0.5em 0px #00000040 inset',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}}
>
Регистрация

View File

@ -59,7 +59,7 @@ const LaunchPage = ({
preserveFiles: [],
});
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [progress, setProgress] = useState(0);
const [buffer, setBuffer] = useState(10);
const [installStatus, setInstallStatus] = useState('');
const [notification, setNotification] = useState<{
@ -70,6 +70,7 @@ const LaunchPage = ({
const [installStep, setInstallStep] = useState('');
const [installMessage, setInstallMessage] = useState('');
const [open, setOpen] = React.useState(false);
const [isGameRunning, setIsGameRunning] = useState(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
@ -79,10 +80,10 @@ const LaunchPage = ({
navigate('/login');
}
const progressListener = (...args: unknown[]) => {
const progress = args[0] as number;
setDownloadProgress(progress);
setBuffer(Math.min(progress + 10, 100));
const overallProgressListener = (...args: unknown[]) => {
const value = args[0] as number; // 0..100
setProgress(value);
setBuffer(Math.min(value + 10, 100));
};
const statusListener = (...args: unknown[]) => {
@ -91,16 +92,49 @@ const LaunchPage = ({
setInstallMessage(status.message);
};
window.electron.ipcRenderer.on('download-progress', progressListener);
const minecraftErrorListener = (...args: unknown[]) => {
const payload = (args[0] || {}) as {
message?: string;
stderr?: string;
code?: number;
};
// Главное — показать пользователю, что запуск не удался
showNotification(
payload.message ||
'Minecraft завершился с ошибкой. Подробности смотрите в логах.',
'error',
);
};
const minecraftStartedListener = () => {
setIsGameRunning(true);
};
const minecraftStoppedListener = () => {
setIsGameRunning(false);
};
window.electron.ipcRenderer.on('overall-progress', overallProgressListener);
window.electron.ipcRenderer.on('minecraft-error', minecraftErrorListener);
window.electron.ipcRenderer.on('installation-status', statusListener);
window.electron.ipcRenderer.on(
'minecraft-started',
minecraftStartedListener,
);
window.electron.ipcRenderer.on(
'minecraft-stopped',
minecraftStoppedListener,
);
return () => {
// Удаляем только конкретных слушателей, а не всех
// Это безопаснее, чем removeAllListeners
const cleanup = window.electron.ipcRenderer.on;
if (typeof cleanup === 'function') {
cleanup('download-progress', progressListener);
cleanup('installation-status', statusListener);
cleanup('minecraft-error', statusListener);
cleanup('overall-progress', overallProgressListener);
}
// Удаляем использование removeAllListeners
};
@ -203,7 +237,6 @@ const LaunchPage = ({
const handleLaunchMinecraft = async () => {
try {
setIsDownloading(true);
setDownloadProgress(0);
setBuffer(10);
// Используем настройки выбранной версии или дефолтные
@ -288,6 +321,28 @@ const LaunchPage = ({
}
};
const handleStopMinecraft = async () => {
try {
const result = await window.electron.ipcRenderer.invoke('stop-minecraft');
if (result?.success) {
showNotification('Minecraft остановлен', 'info');
setIsGameRunning(false);
} else if (result?.error) {
showNotification(
`Не удалось остановить Minecraft: ${result.error}`,
'error',
);
}
} catch (error: any) {
console.error('Ошибка при остановке Minecraft:', error);
showNotification(
`Ошибка при остановке Minecraft: ${error.message || String(error)}`,
'error',
);
}
};
// Функция для сохранения настроек
const savePackConfig = async () => {
try {
@ -353,15 +408,40 @@ const LaunchPage = ({
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress
variant="buffer"
value={downloadProgress}
value={progress}
valueBuffer={buffer}
sx={{
height: 12,
borderRadius: 6,
// Фон прогресс-бара (buffer background)
backgroundColor: 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar1Buffer': {
// Основная прогресс-линия
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
borderRadius: 6,
},
'& .MuiLinearProgress-bar2Buffer': {
// Buffer линия (вторая линия)
backgroundColor: 'rgba(255,255,255,0.25)',
borderRadius: 6,
},
'& .MuiLinearProgress-dashed': {
// Линии пунктирного эффекта
display: 'none',
},
}}
/>
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography
variant="body2"
sx={{ color: 'white' }}
>{`${Math.round(downloadProgress)}%`}</Typography>
>{`${Math.round(progress)}%`}</Typography>
</Box>
</Box>
</Box>
@ -377,16 +457,42 @@ const LaunchPage = ({
<Button
variant="contained"
color="primary"
onClick={handleLaunchMinecraft}
onClick={
isGameRunning ? handleStopMinecraft : handleLaunchMinecraft
}
sx={{
flexGrow: 1, // занимает всё свободное место
width: 'auto', // ширина подстраивается
flexGrow: 1,
width: 'auto',
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
background: 'linear-gradient(90deg, #3B96FF 0%, #FFB7ED 100%)',
transition: 'transform 0.3s ease',
...(isGameRunning
? {
// 🔹 Стиль, когда игра запущена (серая кнопка)
background: 'linear-gradient(71deg, #555 0%, #777 100%)',
'&:hover': {
background: 'linear-gradient(71deg, #666 0%, #888 100%)',
transform: 'scale(1.05)',
boxShadow: '0 4px 15px rgba(100, 100, 100, 0.4)',
},
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}
: {
// 🔹 Стиль, когда Minecraft НЕ запущен (твоя стандартная красочная кнопка)
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
'&:hover': {
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
transform: 'scale(1.05)',
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
},
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
}),
}}
>
Запустить Minecraft
{isGameRunning ? 'Остановить Minecraft' : 'Запустить Minecraft'}
</Button>
{/* Вторая кнопка — квадратная, фиксированного размера (ширина = высоте) */}
@ -400,6 +506,13 @@ const LaunchPage = ({
minHeight: 'unset',
minWidth: 'unset',
height: '100%', // занимает полную высоту родителя
transition: 'transform 0.3s ease',
'&:hover': {
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
transform: 'scale(1.05)',
boxShadow: '0 4px 15px rgba(242, 113, 33, 0.4)',
},
}}
onClick={handleOpen}
>

View File

@ -5,12 +5,19 @@ 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';
const Login = () => {
interface LoginProps {
onLoginSuccess?: (username: string) => void;
}
const Login = ({ onLoginSuccess }: LoginProps) => {
const navigate = useNavigate();
const { config, setConfig, saveConfig, handleInputChange } = useConfig();
const { status, validateSession, refreshSession, authenticateWithElyBy } =
useAuth();
const [loading, setLoading] = useState(false);
const authorization = async () => {
console.log('Начинаем процесс авторизации...');
@ -21,6 +28,7 @@ const Login = () => {
return;
}
setLoading(true);
try {
// Проверяем, есть ли сохранённый токен
if (config.accessToken && config.clientToken) {
@ -84,25 +92,37 @@ const Login = () => {
}
console.log('Авторизация успешно завершена');
if (onLoginSuccess) {
onLoginSuccess(config.username);
}
navigate('/');
} catch (error) {
} catch (error: any) {
console.log(`ОШИБКА при авторизации: ${error.message}`);
// Очищаем недействительные токены при ошибке
saveConfig({
accessToken: '',
clientToken: '',
});
} finally {
setLoading(false);
}
};
return (
<Box>
{loading ? (
<FullScreenLoader message="Входим..." />
) : (
<>
<PopaPopa />
<AuthForm
config={config}
handleInputChange={handleInputChange}
onLogin={authorization}
/>
</>
)}
</Box>
);
};

View File

@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import {
Box,
Typography,
CircularProgress,
Button,
Grid,
Card,
@ -18,6 +17,7 @@ import {
import { isPlayerOnline, getPlayerServer } from '../utils/playerOnlineCheck';
import { buyItem, fetchMarketplace, MarketplaceResponse, Server } from '../api';
import PlayerInventory from '../components/PlayerInventory';
import { FullScreenLoader } from '../components/FullScreenLoader';
interface TabPanelProps {
children?: React.ReactNode;
@ -195,14 +195,14 @@ export default function Marketplace() {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
height: '20vh',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="white">
Проверяем, находитесь ли вы на сервере...
</Typography>
<FullScreenLoader
fullScreen={true}
message="Проверяем, находитесь ли вы на сервере..."
/>
</Box>
);
}
@ -339,7 +339,7 @@ export default function Marketplace() {
<TabPanel value={tabValue} index={0}>
{marketLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '50vw' }}>
<CircularProgress />
<FullScreenLoader fullScreen={false} />
</Box>
) : !marketItems || marketItems.items.length === 0 ? (
<Box sx={{ mt: 4, textAlign: 'center' }}>
@ -386,11 +386,10 @@ export default function Marketplace() {
minHeight: '10vw',
maxHeight: '10vw',
objectFit: 'contain',
bgcolor: 'white',
p: '1vw',
imageRendering: 'pixelated',
}}
image={`/minecraft/${item.material.toLowerCase()}.png`}
image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
alt={item.material}
/>
<CardContent>

721
src/renderer/pages/News.tsx Normal file
View File

@ -0,0 +1,721 @@
import { useEffect, useState } from 'react';
import {
Box,
Typography,
Paper,
Stack,
Chip,
IconButton,
TextField,
Button,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { fetchNews, NewsItem, createNews, fetchMe, deleteNews } from '../api';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { MarkdownEditor } from '../components/MarkdownEditor';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
export const News = () => {
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Markdown-рендерер (динамический импорт, чтобы не ругался CommonJS)
const [ReactMarkdown, setReactMarkdown] = useState<any>(null);
const [remarkGfm, setRemarkGfm] = useState<any>(null);
// --- Админский редактор ---
const [creating, setCreating] = useState(false);
const [title, setTitle] = useState('');
const [preview, setPreview] = useState('');
const [markdown, setMarkdown] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
(async () => {
try {
const me = await fetchMe();
setIsAdmin(me.is_admin);
} catch {
setIsAdmin(false);
}
})();
}, []);
// Загружаем react-markdown + remark-gfm
useEffect(() => {
(async () => {
const md = await import('react-markdown');
const gfm = await import('remark-gfm');
setReactMarkdown(() => md.default);
setRemarkGfm(() => gfm.default);
})();
}, []);
// Загрузка списка новостей
useEffect(() => {
const load = async () => {
try {
const data = await fetchNews();
setNews(data);
} catch (e: any) {
setError(e?.message || 'Не удалось загрузить новости');
} finally {
setLoading(false);
}
};
load();
}, []);
const handleToggleExpand = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
const handleCreateNews = async () => {
if (!title.trim() || !markdown.trim()) {
setError('У новости должны быть заголовок и текст');
return;
}
setError(null);
setCreating(true);
try {
await createNews({
title: title.trim(),
preview: preview.trim() || undefined,
markdown,
is_published: true,
});
const updated = await fetchNews();
setNews(updated);
// Сброс формы
setTitle('');
setPreview('');
setMarkdown('');
} catch (e: any) {
setError(e?.message || 'Не удалось создать новость');
} finally {
setCreating(false);
}
};
const handleDeleteNews = async (id: string) => {
const confirmed = window.confirm('Точно удалить эту новость?');
if (!confirmed) return;
try {
await deleteNews(id);
setNews((prev) => prev.filter((n) => n.id !== id));
} catch (e: any) {
setError(e?.message || 'Не удалось удалить новость');
}
};
// ждём пока react-markdown / remark-gfm загрузятся
if (!ReactMarkdown || !remarkGfm) {
return <FullScreenLoader />;
}
if (loading) {
return <FullScreenLoader />;
}
if (error && news.length === 0) {
return (
<Box
sx={{
mt: '10vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
px: '3vw',
}}
>
<Typography
sx={{
color: '#ff8080',
fontFamily: 'Benzin-Bold',
textAlign: 'center',
}}
>
{error}
</Typography>
</Box>
);
}
return (
<Box
sx={{
mt: '7vh',
px: '7vw',
pb: '4vh',
maxHeight: '90vh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '2vh',
}}
>
{/* Заголовок страницы */}
<Box
sx={{
mb: '2vh',
}}
>
<Typography
variant="h3"
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '3vw',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
Новости
</Typography>
<Typography
variant="body2"
sx={{
mt: 0.5,
color: 'rgba(255,255,255,0.6)',
}}
>
Последние обновления лаунчера, сервера и ивентов
</Typography>
</Box>
{/* Админский редактор */}
{isAdmin && (
<Paper
sx={{
mb: 3,
p: 2.5,
borderRadius: '1.5vw',
background:
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(14px)',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.1vw',
mb: 1.5,
color: 'rgba(255,255,255,0.9)',
}}
>
Создать новость
</Typography>
<TextField
label="Заголовок"
fullWidth
variant="outlined"
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{
mb: 2,
'& .MuiInputBase-root': {
backgroundColor: 'rgba(0,0,0,0.6)',
color: 'white',
},
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.7)',
},
}}
/>
<TextField
label="Краткий превью-текст (опционально)"
fullWidth
variant="outlined"
value={preview}
onChange={(e) => setPreview(e.target.value)}
sx={{
mb: 2,
'& .MuiInputBase-root': {
backgroundColor: 'rgba(0,0,0,0.6)',
color: 'white',
},
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.7)',
},
}}
/>
<Box
sx={{
mb: 2,
'& .EasyMDEContainer': {
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: '0.8vw',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.15)',
},
'& .editor-toolbar': {
background: 'transparent',
borderBottom: '1px solid rgba(255, 255, 255, 1)',
color: 'white',
},
'& .editor-toolbar .fa': {
color: 'white',
},
'& .CodeMirror': {
backgroundColor: 'transparent',
color: 'white',
},
}}
>
<MarkdownEditor value={markdown} onChange={setMarkdown} />
</Box>
{error && (
<Typography
sx={{
color: '#ff8080',
fontSize: '0.8vw',
}}
>
{error}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
disabled={creating}
onClick={handleCreateNews}
sx={{
px: 3,
py: 0.8,
borderRadius: '999px',
textTransform: 'uppercase',
fontFamily: 'Benzin-Bold',
fontSize: '0.8vw',
letterSpacing: '0.08em',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
boxShadow: '0 12px 30px rgba(0,0,0,0.9)',
'&:hover': {
boxShadow: '0 18px 40px rgba(0,0,0,1)',
filter: 'brightness(1.05)',
},
}}
>
{creating ? 'Публикация...' : 'Опубликовать'}
</Button>
</Box>
</Paper>
)}
{/* Если новостей нет */}
{news.length === 0 && (
<Box
sx={{
mt: '5vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
px: '3vw',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textAlign: 'center',
}}
>
Новостей пока нет
</Typography>
</Box>
)}
{/* Список новостей */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1.8vh',
}}
>
{news.map((item) => {
const isExpanded = expandedId === item.id;
const shortContent = item.preview || item.markdown;
const fullContent = item.markdown;
const contentToRender = isExpanded ? fullContent : shortContent;
const isImageUrl =
!isExpanded &&
typeof shortContent === 'string' &&
/^https?:\/\/.*\.(png|jpe?g|gif|webp)$/i.test(shortContent.trim());
return (
<Paper
key={item.id}
sx={{
position: 'relative',
overflow: 'hidden',
mb: 1,
p: 2.5,
borderRadius: '1.5vw',
background:
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
border: '1px solid rgba(255, 255, 255, 0.08)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(14px)',
width: '80vw',
transition:
'transform 0.25s ease, box-shadow 0.25s.ease, border-color 0.25s ease',
'&:hover': {
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.9)',
borderColor: 'rgba(242,113,33,0.5)',
},
}}
>
{/* Шапка новости */}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 2,
mb: 1.5,
}}
>
<Box sx={{ flexGrow: 1 }}>
<Typography
variant="h6"
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '2.5vw',
mb: 0.5,
textShadow: '0 0 18px rgba(0,0,0,0.8)',
}}
>
{item.title}
</Typography>
{item.created_at && (
<Typography
variant="body2"
sx={{
color: 'rgba(255,255,255,0.5)',
fontSize: '0.85vw',
}}
>
{new Date(item.created_at).toLocaleString()}
</Typography>
)}
{item.tags && item.tags.length > 0 && (
<Stack
direction="row"
spacing={1}
sx={{ mt: 1, flexWrap: 'wrap' }}
>
{item.tags.map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
sx={{
fontSize: '0.7vw',
color: 'rgba(255,255,255,0.85)',
borderRadius: '999px',
border: '1px solid rgba(242,113,33,0.6)',
background:
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,87,0.12), rgba(138,35,135,0.16))',
backdropFilter: 'blur(12px)',
}}
/>
))}
</Stack>
)}
</Box>
<IconButton
onClick={() => handleToggleExpand(item.id)}
sx={{
ml: 1,
alignSelf: 'center',
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.25s ease, background 0.25s ease',
background:
'linear-gradient(140deg, rgba(242,113,33,0.15), rgba(233,64,87,0.15))',
borderRadius: '1.4vw',
'&:hover': {
background:
'linear-gradient(140deg, rgba(242,113,33,0.4), rgba(233,64,87,0.4))',
},
}}
>
<ExpandMoreIcon
sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '1.4vw' }}
/>
</IconButton>
{isAdmin && (
<IconButton
onClick={() => handleDeleteNews(item.id)}
sx={{
mt: 0.5,
backgroundColor: 'rgba(255, 77, 77, 0.1)',
borderRadius: '1.4vw',
'&:hover': {
backgroundColor: 'rgba(255, 77, 77, 0.3)',
},
}}
>
<DeleteOutlineIcon
sx={{
color: 'rgba(255, 120, 120, 0.9)',
fontSize: '1.2vw',
}}
/>
</IconButton>
)}
</Box>
{/* Контент */}
<Box
sx={{
position: 'relative',
mt: 1,
display: 'flex',
justifyContent: 'center',
}}
>
{isImageUrl ? (
<Box
component="img"
src={(shortContent as string).trim()}
alt={item.title}
sx={{
maxHeight: '30vh',
objectFit: 'cover',
borderRadius: '1.2vw',
mb: 2,
}}
/>
) : (
<Box
sx={{
maxHeight: isExpanded ? 'none' : '12em',
overflow: 'hidden',
pr: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => (
<Typography
{...props}
sx={{
color: 'rgba(255,255,255,0.9)',
fontSize: '1.5vw',
lineHeight: 1.6,
mb: 1,
whiteSpace: 'pre-line', // ← вот это
'&:last-of-type': { mb: 0 },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
/>
),
strong: ({ node, ...props }) => (
<Box
component="strong"
sx={{
fontWeight: 700,
color: 'rgba(255,255,255,1)',
}}
{...props}
/>
),
em: ({ node, ...props }) => (
<Box
component="em"
sx={{ fontStyle: 'italic' }}
{...props}
/>
),
del: ({ node, ...props }) => (
<Box
component="del"
sx={{ textDecoration: 'line-through' }}
{...props}
/>
),
a: ({ node, ...props }) => (
<Box
component="a"
{...props}
sx={{
color: '#F27121',
textDecoration: 'none',
borderBottom: '1px solid rgba(242,113,33,0.6)',
'&:hover': {
color: '#E940CD',
borderBottomColor: 'rgba(233,64,205,0.8)',
},
}}
target="_blank"
rel="noopener noreferrer"
/>
),
li: ({ node, ordered, ...props }) => (
<li
style={{
color: 'rgba(255,255,255,0.9)',
fontSize: '1.5vw',
marginBottom: '0.3em',
}}
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
style={{
paddingLeft: '1.3em',
marginTop: '0.3em',
marginBottom: '0.8em',
}}
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
style={{
paddingLeft: '1.3em',
marginTop: '0.3em',
marginBottom: '0.8em',
}}
{...props}
/>
),
img: ({ node, ...props }) => (
<Box
component="img"
{...props}
sx={{
maxHeight: '30vh',
objectFit: 'cover',
borderRadius: '1.2vw',
my: 2,
}}
/>
),
h1: ({ node, ...props }) => (
<Typography
{...props}
variant="h5"
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
mb: 1,
}}
/>
),
h2: ({ node, ...props }) => (
<Typography
{...props}
variant="h6"
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
mb: 1,
}}
/>
),
h3: ({ node, ...props }) => (
<Typography
{...props}
variant="h7"
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
mb: 1,
}}
/>
),
}}
>
{contentToRender}
</ReactMarkdown>
</Box>
)}
{!isExpanded && !isImageUrl && (
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '3.5em',
// background:
// 'linear-gradient(to top, rgba(0, 0, 0, 0.43), rgba(0,0,0,0))',
pointerEvents: 'none',
}}
/>
)}
</Box>
<Box
sx={{
mt: 1.5,
display: 'flex',
justifyContent: 'flex-end',
}}
>
<Typography
onClick={() => handleToggleExpand(item.id)}
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.8vw',
textTransform: 'uppercase',
letterSpacing: '0.08em',
cursor: 'pointer',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textShadow: '0 0 15px rgba(0,0,0,0.9)',
'&:hover': {
opacity: 0.85,
},
}}
>
{isExpanded ? 'Свернуть' : 'Читать полностью'}
</Typography>
</Box>
</Paper>
);
})}
</Box>
</Box>
);
};

View File

@ -19,10 +19,11 @@ import {
Select,
MenuItem,
Alert,
CircularProgress,
} from '@mui/material';
import CapeCard from '../components/CapeCard';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
export default function Profile() {
const fileInputRef = useRef<HTMLInputElement>(null);
@ -195,7 +196,7 @@ export default function Profile() {
}}
>
{loading ? (
<CircularProgress />
<FullScreenLoader message="Загрузка вашего профиля" />
) : (
<>
<Paper
@ -406,12 +407,12 @@ export default function Profile() {
disabled={uploadStatus === 'loading' || !skinFile}
startIcon={
uploadStatus === 'loading' ? (
<CircularProgress size={20} color="inherit" />
<FullScreenLoader fullScreen={false} />
) : null
}
>
{uploadStatus === 'loading' ? (
<Typography sx={{ color: 'white' }}>Загрузка...</Typography>
<FullScreenLoader message="Загрузка..." />
) : (
<Typography sx={{ color: 'white' }}>
Установить скин
@ -449,6 +450,7 @@ export default function Profile() {
))}
</Box>
</Box>
<OnlinePlayersPanel currentUsername={username} />
</Box>
</>
)}

View File

@ -12,7 +12,6 @@ import {
TextField,
Button,
Snackbar,
CircularProgress,
} from '@mui/material';
import LoginRoundedIcon from '@mui/icons-material/LoginRounded';
import VerifiedRoundedIcon from '@mui/icons-material/VerifiedRounded';
@ -25,6 +24,7 @@ import {
import QRCodeStyling from 'qr-code-styling';
import popalogo from '../../../assets/icons/popa-popa.svg';
import GradientTextField from '../components/GradientTextField';
import { FullScreenLoader } from '../components/FullScreenLoader';
const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
[`&.${stepConnectorClasses.alternativeLabel}`]: {
@ -34,7 +34,7 @@ const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
[`& .${stepConnectorClasses.line}`]: {
backgroundImage:
//'linear-gradient( 95deg,rgb(242,113,33) 0%,rgb(233,64,87) 50%,rgb(138,35,135) 100%)',
'linear-gradient( 95deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)'
'linear-gradient( 95deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)',
},
},
[`&.${stepConnectorClasses.completed}`]: {
@ -46,7 +46,8 @@ const ColorlibConnector = styled(StepConnector)(({ theme }) => ({
[`& .${stepConnectorClasses.line}`]: {
height: 3,
border: 0,
backgroundImage: 'linear-gradient( 275deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)',
backgroundImage:
'linear-gradient( 275deg,rgb(150,150,150) 0%, rgb(242,113,33) 80%,rgb(233,64,87) 110%,rgb(138,35,135) 150%)',
borderRadius: 1,
transition: 'background-image 1s ease, background-color 1s ease',
...theme.applyStyles('dark', {
@ -84,8 +85,7 @@ const ColorlibStepIconRoot = styled('div')<{
{
props: ({ ownerState }) => ownerState.completed,
style: {
backgroundImage:
'#adadad',
backgroundImage: '#adadad',
},
},
],
@ -311,7 +311,15 @@ export const Registration = () => {
))}
</Stepper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, width: '50vw', mt: activeStep === 1 ? '20%' : '0%' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vh',
width: '50vw',
mt: activeStep === 1 ? '20%' : '0%',
}}
>
{activeStep === 0 && (
<Box
sx={{
@ -357,13 +365,13 @@ export const Registration = () => {
transition: 'transform 0.3s ease',
width: '60%',
mt: 2,
background: 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold',
borderRadius: '2.5vw',
fontSize: '2vw',
'&:hover': {
transform: 'scale(1.1)',
},
}}
onClick={handleCreateAccount}
@ -389,13 +397,13 @@ export const Registration = () => {
transition: 'transform 0.3s ease',
width: '60%',
mt: 2,
background: 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
fontFamily: 'Benzin-Bold',
borderRadius: '2.5vw',
fontSize: '2vw',
'&:hover': {
transform: 'scale(1.1)',
},
}}
onClick={handleOpenBot}
@ -425,10 +433,10 @@ export const Registration = () => {
{verificationCode}
</Typography>
<Typography variant="body1">Ждем ответа от бота</Typography>
<CircularProgress />
<FullScreenLoader fullScreen={false} />
</>
) : (
<CircularProgress />
<FullScreenLoader fullScreen={false} />
)}
</Box>
)}

View File

@ -1,14 +1,57 @@
import { Box } from '@mui/material';
import { Typography } from '@mui/material';
import CapeCard from '../components/CapeCard';
import { Box, Typography, Button, Grid, Snackbar, Alert } from '@mui/material';
import {
Cape,
fetchCapes,
fetchCapesStore,
purchaseCape,
StoreCape,
Case,
CaseItem,
fetchCases,
fetchCase,
openCase,
Server,
fetchPlayer,
BonusType,
UserBonus,
fetchBonusTypes,
fetchUserBonuses,
purchaseBonus,
upgradeBonus,
toggleBonusActivation,
} from '../api';
import { useEffect, useState } from 'react';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { getPlayerServer } from '../utils/playerOnlineCheck';
import CaseRoulette from '../components/CaseRoulette';
import BonusShopItem from '../components/BonusShopItem';
import ShopItem from '../components/ShopItem';
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)'; // золотой
case 'epic':
return 'rgba(186, 85, 211, 1)'; // фиолетовый
case 'rare':
return 'rgba(65, 105, 225, 1)'; // синий
case 'common':
default:
return 'rgba(255, 255, 255, 0.25)'; // сероватый
}
}
export default function Shop() {
const [storeCapes, setStoreCapes] = useState<StoreCape[]>([]);
@ -17,6 +60,77 @@ export default function Shop() {
const [uuid, setUuid] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
// Прокачка
const [bonusTypes, setBonusTypes] = useState<BonusType[]>([]);
const [userBonuses, setUserBonuses] = useState<UserBonus[]>([]);
const [bonusesLoading, setBonusesLoading] = useState<boolean>(false);
const [processingBonusIds, setProcessingBonusIds] = useState<string[]>([]);
// Кейсы
const [cases, setCases] = useState<Case[]>([]);
const [casesLoading, setCasesLoading] = useState<boolean>(false);
// Онлайн/сервер (по аналогии с Marketplace)
const [isOnline, setIsOnline] = useState<boolean>(false);
const [playerServer, setPlayerServer] = useState<Server | null>(null);
const [onlineCheckLoading, setOnlineCheckLoading] = useState<boolean>(true);
// Рулетка
const [isOpening, setIsOpening] = useState<boolean>(false);
const [selectedCase, setSelectedCase] = useState<Case | null>(null);
const [rouletteOpen, setRouletteOpen] = useState(false);
const [rouletteCaseItems, setRouletteCaseItems] = useState<CaseItem[]>([]);
const [rouletteReward, setRouletteReward] = useState<CaseItem | null>(null);
// Уведомления
const [notification, setNotification] = useState<{
open: boolean;
message: string;
type: 'success' | 'error';
}>({
open: false,
message: '',
type: 'success',
});
const loadBonuses = async (username: string) => {
try {
setBonusesLoading(true);
const [types, user] = await Promise.all([
fetchBonusTypes(),
fetchUserBonuses(username),
]);
setBonusTypes(types);
setUserBonuses(user);
} catch (error) {
console.error('Ошибка при получении прокачек:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при загрузке прокачки',
type: 'error',
});
} finally {
setBonusesLoading(false);
}
};
const loadPlayerSkin = async (uuid: string) => {
try {
const player = await fetchPlayer(uuid);
setPlayerSkinUrl(player.skin_url);
} catch (error) {
console.error('Ошибка при получении скина игрока:', error);
setPlayerSkinUrl('');
}
};
// Функция для загрузки плащей из магазина
const loadStoreCapes = async () => {
try {
@ -43,12 +157,55 @@ export default function Shop() {
try {
await purchaseCape(username, cape_id);
await loadUserCapes(username);
setNotification({
open: true,
message: 'Плащ успешно куплен!',
type: 'success',
});
} catch (error) {
console.error('Ошибка при покупке плаща:', error);
setNotification({
open: true,
message:
error instanceof Error ? error.message : 'Ошибка при покупке плаща',
type: 'error',
});
}
};
// Загружаем данные при монтировании
// Загрузка кейсов
const loadCases = async () => {
try {
setCasesLoading(true);
const casesData = await fetchCases();
setCases(casesData);
} catch (error) {
console.error('Ошибка при получении кейсов:', error);
setCases([]);
} finally {
setCasesLoading(false);
}
};
// Проверка онлайна игрока (по аналогии с Marketplace.tsx)
const checkPlayerStatus = async () => {
if (!username) return;
try {
setOnlineCheckLoading(true);
const { online, server } = await getPlayerServer(username);
setIsOnline(online);
setPlayerServer(server || null);
} catch (error) {
console.error('Ошибка при проверке онлайн-статуса:', error);
setIsOnline(false);
setPlayerServer(null);
} finally {
setOnlineCheckLoading(false);
}
};
// Загружаем базовые данные при монтировании
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
@ -59,72 +216,460 @@ export default function Shop() {
setLoading(true);
// Загружаем оба списка плащей
Promise.all([loadStoreCapes(), loadUserCapes(config.username)]).finally(
() => {
Promise.all([
loadStoreCapes(),
loadUserCapes(config.username),
loadCases(),
loadPlayerSkin(config.uuid),
loadBonuses(config.username),
])
.catch((err) => console.error(err))
.finally(() => {
setLoading(false);
},
);
});
}
}
}, []);
// Проверяем онлайн после того, как знаем username
useEffect(() => {
if (username) {
checkPlayerStatus();
}
}, [username]);
const withProcessing = async (id: string, fn: () => Promise<void>) => {
setProcessingBonusIds((prev) => [...prev, id]);
try {
await fn();
} finally {
setProcessingBonusIds((prev) => prev.filter((x) => x !== id));
}
};
const handlePurchaseBonus = async (bonusTypeId: string) => {
if (!username) {
setNotification({
open: true,
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
type: 'error',
});
return;
}
await withProcessing(bonusTypeId, async () => {
try {
const res = await purchaseBonus(username, bonusTypeId);
setNotification({
open: true,
message: res.message || 'Прокачка успешно куплена!',
type: 'success',
});
await loadBonuses(username);
} catch (error) {
console.error('Ошибка при покупке прокачки:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при покупке прокачки',
type: 'error',
});
}
});
};
const handleUpgradeBonus = async (bonusId: string) => {
if (!username) return;
await withProcessing(bonusId, async () => {
try {
await upgradeBonus(username, bonusId);
setNotification({
open: true,
message: 'Бонус улучшен!',
type: 'success',
});
await loadBonuses(username);
} catch (error) {
console.error('Ошибка при улучшении бонуса:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при улучшении бонуса',
type: 'error',
});
}
});
};
const handleToggleBonusActivation = async (bonusId: string) => {
if (!username) return;
await withProcessing(bonusId, async () => {
try {
await toggleBonusActivation(username, bonusId);
await loadBonuses(username);
} catch (error) {
console.error('Ошибка при переключении бонуса:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при переключении бонуса',
type: 'error',
});
}
});
};
// Фильтруем плащи, которые уже куплены пользователем
const availableCapes = storeCapes.filter(
(storeCape) =>
!userCapes.some((userCape) => userCape.cape_id === storeCape.id),
);
const handleOpenCase = async (caseData: Case) => {
if (!username) {
setNotification({
open: true,
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
type: 'error',
});
return;
}
if (!isOnline || !playerServer) {
setNotification({
open: true,
message: 'Для открытия кейсов необходимо находиться на сервере в игре.',
type: 'error',
});
return;
}
if (isOpening) return;
try {
setIsOpening(true);
// 1. получаем полный кейс
const fullCase = await fetchCase(caseData.id);
const caseItems: CaseItem[] = fullCase.items || [];
setSelectedCase(fullCase);
// 2. открываем кейс на бэке
const result = await openCase(fullCase.id, username, playerServer.id);
// 3. сохраняем данные для рулетки
setRouletteCaseItems(caseItems);
setRouletteReward(result.reward);
setRouletteOpen(true);
// 4. уведомление
setNotification({
open: true,
message: result.message || 'Кейс открыт!',
type: 'success',
});
setIsOpening(false);
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
setNotification({
open: true,
message:
error instanceof Error ? error.message : 'Ошибка при открытии кейса',
type: 'error',
});
setIsOpening(false);
}
};
const handleCloseNotification = () => {
setNotification((prev) => ({ ...prev, open: false }));
};
const handleCloseRoulette = () => {
setRouletteOpen(false);
};
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '2vw',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
}}
>
{loading ? (
<Typography>Загрузка...</Typography>
) : (
{(loading || onlineCheckLoading) && (
<FullScreenLoader message="Загрузка магазина..." />
)}
{!loading && !onlineCheckLoading && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
alignContent: 'flex-start',
width: '90%',
height: '80%',
gap: '2vw',
overflow: 'auto',
paddingTop: '3vh',
paddingBottom: '10vh',
paddingLeft: '5vw',
paddingRight: '5vw',
}}
>
<Typography variant="h6">Доступные плащи</Typography>
{availableCapes.length > 0 ? (
{/* Блок прокачки */}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: '2vw',
flexWrap: 'wrap',
flexDirection: 'column',
gap: 2,
}}
>
{availableCapes.map((cape) => (
<CapeCard
key={cape.id}
cape={cape}
mode="shop"
onAction={handlePurchaseCape}
<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',
}}
>
Прокачка
</Typography>
{bonusesLoading ? (
<FullScreenLoader
fullScreen={false}
message="Загрузка прокачки..."
/>
))}
) : bonusTypes.length > 0 ? (
<Grid container spacing={2} sx={{ mb: 4 }}>
{bonusTypes.map((bt) => {
const userBonus = userBonuses.find(
(ub) => ub.bonus_type_id === bt.id,
);
const owned = !!userBonus;
const level = owned ? userBonus!.level : 0;
const effectValue = owned
? userBonus!.effect_value
: bt.base_effect_value;
const nextEffectValue =
owned && userBonus!.can_upgrade
? bt.base_effect_value +
userBonus!.level * bt.effect_increment
: undefined;
const isActive = owned ? userBonus!.is_active : false;
const isPermanent = owned
? userBonus!.is_permanent
: bt.duration === 0;
const cardId = owned ? userBonus!.id : bt.id;
const processing = processingBonusIds.includes(cardId);
return (
<Grid item xs={12} sm={6} md={4} lg={3} key={bt.id}>
<BonusShopItem
id={cardId}
name={bt.name}
description={bt.description}
imageUrl={bt.image_url}
level={level}
effectValue={effectValue}
nextEffectValue={nextEffectValue}
price={bt.price}
upgradePrice={bt.upgrade_price}
canUpgrade={userBonus?.can_upgrade ?? false}
mode={owned ? 'upgrade' : 'buy'}
isActive={isActive}
isPermanent={isPermanent}
disabled={processing}
onBuy={
!owned ? () => handlePurchaseBonus(bt.id) : undefined
}
onUpgrade={
owned
? () => handleUpgradeBonus(userBonus!.id)
: undefined
}
onToggleActive={
owned
? () => handleToggleBonusActivation(userBonus!.id)
: undefined
}
/>
</Grid>
);
})}
</Grid>
) : (
<Typography>Прокачка временно недоступна.</Typography>
)}
</Box>
{/* Блок кейсов */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 2,
mb: 1,
flexDirection: 'column',
}}
>
<Box sx={{ display: 'flex', gap: 2 }}>
<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',
}}
>
Кейсы
</Typography>
{!isOnline && (
<Button
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.1)',
},
}}
onClick={() => {
checkPlayerStatus(); // обновляем онлайн-статус
loadCases(); // обновляем ТОЛЬКО кейсы
}}
>
Обновить
</Button>
)}
</Box>
{!isOnline ? (
<Typography variant="body1" color="error" sx={{ mb: 2 }}>
Для открытия кейсов вам необходимо находиться на одном из
серверов игры. Зайдите в игру и нажмите кнопку «Обновить».
</Typography>
) : casesLoading ? (
<FullScreenLoader
fullScreen={false}
message="Загрузка кейсов..."
/>
) : cases.length > 0 ? (
<Grid container spacing={2} sx={{ mb: 4 }}>
{cases.map((c) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={c.id}>
<ShopItem
type="case"
id={c.id}
name={c.name}
description={c.description}
imageUrl={c.image_url}
price={c.price}
itemsCount={c.items_count}
isOpening={isOpening && selectedCase?.id === c.id}
disabled={!isOnline || isOpening}
onClick={() => handleOpenCase(c)}
/>
</Grid>
))}
</Grid>
) : (
<Typography>Кейсы временно недоступны.</Typography>
)}
</Box>
{/* Блок плащей (как был) */}
{/* Блок плащей */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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',
}}
>
Плащи
</Typography>
{availableCapes.length > 0 ? (
<Grid container spacing={2}>
{availableCapes.map((cape) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={cape.id}>
<ShopItem
type="cape"
id={cape.id}
name={cape.name}
description={cape.description}
imageUrl={cape.image_url}
price={cape.price}
disabled={false}
playerSkinUrl={playerSkinUrl}
onClick={() => handlePurchaseCape(cape.id)}
/>
</Grid>
))}
</Grid>
) : (
<Typography>У вас уже есть все доступные плащи!</Typography>
)}
</Box>
</Box>
)}
{/* Компонент с анимацией рулетки */}
<CaseRoulette
open={rouletteOpen}
onClose={handleCloseRoulette}
caseName={selectedCase?.name}
items={rouletteCaseItems}
reward={rouletteReward}
/>
{/* Уведомления */}
<Snackbar
open={notification.open}
autoHideDuration={6000}
onClose={handleCloseNotification}
>
<Alert
onClose={handleCloseNotification}
severity={notification.type}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
</Box>
);
}

View File

@ -4,20 +4,16 @@ import {
Typography,
Grid,
Card,
CardMedia,
CardContent,
CardActions,
Button,
CircularProgress,
Modal,
List,
ListItem,
ListItemText,
IconButton,
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import AddIcon from '@mui/icons-material/Add';
import DownloadIcon from '@mui/icons-material/Download';
import { FullScreenLoader } from '../components/FullScreenLoader';
interface VersionCardProps {
id: string;
@ -30,10 +26,13 @@ interface VersionCardProps {
hoveredCardId: string | null;
}
const gradientPrimary =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
export const VersionCard: React.FC<VersionCardProps> = ({
id,
name,
imageUrl,
imageUrl, // пока не используется, но оставляем для будущего
version,
onSelect,
isHovered,
@ -43,24 +42,28 @@ export const VersionCard: React.FC<VersionCardProps> = ({
return (
<Card
sx={{
backgroundColor: 'rgba(30, 30, 50, 0.8)',
backdropFilter: 'blur(10px)',
background:
'radial-gradient(circle at top left, rgba(242,113,33,0.2), transparent 55%), rgba(10,10,20,0.95)',
backdropFilter: 'blur(18px)',
width: '35vw',
height: '35vh',
minWidth: 'unset',
minHeight: 'unset',
display: 'flex',
flexDirection: 'column',
borderRadius: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
transition: 'transform 0.3s, box-shadow 0.3s, filter 0.3s',
borderRadius: '2.5vw',
border: '1px solid rgba(255,255,255,0.06)',
boxShadow: isHovered
? '0 0 10px rgba(233,64,205,0.55)'
: '0 14px 40px rgba(0, 0, 0, 0.6)',
transition:
'transform 0.35s ease, box-shadow 0.35s ease, border-color 0.35s ease',
overflow: 'hidden',
cursor: 'pointer',
filter: hoveredCardId && !isHovered ? 'blur(5px)' : 'blur(0px)',
transform: isHovered ? 'scale(1.03)' : 'scale(1)',
transform: isHovered ? 'scale(1.04)' : 'scale(1)',
zIndex: isHovered ? 10 : 1,
'&:hover': {
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.5)',
borderColor: 'rgba(242,113,33,0.8)',
},
}}
onClick={() => onSelect(id)}
@ -74,7 +77,8 @@ export const VersionCard: React.FC<VersionCardProps> = ({
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '10%',
gap: '1vh',
textAlign: 'center',
}}
>
<Typography
@ -83,12 +87,24 @@ export const VersionCard: React.FC<VersionCardProps> = ({
component="div"
sx={{
fontWeight: 'bold',
color: '#ffffff',
fontSize: '1.5rem',
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage: gradientPrimary,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
{name}
</Typography>
<Typography
variant="subtitle1"
sx={{
color: 'rgba(255,255,255,0.8)',
fontSize: '1.1vw',
}}
>
Версия {version}
</Typography>
</CardContent>
</Card>
);
@ -168,7 +184,6 @@ export const VersionsExplorer = () => {
}
} catch (error) {
console.error('Ошибка при загрузке версий:', error);
// Можно добавить обработку ошибки, например показать уведомление
} finally {
setLoading(false);
}
@ -181,10 +196,8 @@ export const VersionsExplorer = () => {
const cfg: any = (version as any).config;
if (cfg && (cfg.downloadUrl || cfg.apiReleaseUrl)) {
// Версия из Gist — у неё есть нормальный config
localStorage.setItem('selected_version_config', JSON.stringify(cfg));
} else {
// Установленная версия без config — не засоряем localStorage пустым объектом
localStorage.removeItem('selected_version_config');
}
@ -203,7 +216,6 @@ export const VersionsExplorer = () => {
try {
setDownloadLoading(version.id);
// Скачивание и установка выбранной версии
const downloadResult = await window.electron.ipcRenderer.invoke(
'download-and-extract',
{
@ -216,7 +228,6 @@ export const VersionsExplorer = () => {
);
if (downloadResult?.success) {
// Добавляем скачанную версию в список установленных
setInstalledVersions((prev) => [...prev, version]);
setModalOpen(false);
}
@ -231,49 +242,42 @@ export const VersionsExplorer = () => {
const AddVersionCard = () => (
<Card
sx={{
backgroundColor: 'rgba(30, 30, 50, 0.8)',
backdropFilter: 'blur(10px)',
background:
'radial-gradient(circle at top left, rgba(233,64,205,0.3), rgba(10,10,20,0.95))',
width: '35vw',
height: '35vh',
minWidth: 'unset',
minHeight: 'unset',
display: 'flex',
flexDirection: 'column',
borderRadius: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
transition: 'transform 0.3s, box-shadow 0.3s, filter 0.3s',
borderRadius: '2.5vw',
border: '1px dashed rgba(255,255,255,0.3)',
boxShadow: '0 14px 40px rgba(0, 0, 0, 0.6)',
transition: 'transform 0.35s ease, box-shadow 0.35s ease',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
filter:
hoveredCardId && hoveredCardId !== 'add' ? 'blur(5px)' : 'blur(0)',
transform: hoveredCardId === 'add' ? 'scale(1.03)' : 'scale(1)',
transform: hoveredCardId === 'add' ? 'scale(1.04)' : 'scale(1)',
zIndex: hoveredCardId === 'add' ? 10 : 1,
'&:hover': {
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.5)',
boxShadow: '0 0 40px rgba(242,113,33,0.7)',
borderStyle: 'solid',
},
}}
onClick={handleAddVersion}
onMouseEnter={() => setHoveredCardId('add')}
onMouseLeave={() => setHoveredCardId(null)}
>
<AddIcon sx={{ fontSize: 60, color: '#fff' }} />
<AddIcon sx={{ fontSize: '4vw', color: '#fff' }} />
<Typography
variant="h6"
sx={{
color: '#fff',
fontFamily: 'Benzin-Bold',
fontSize: '1.5vw',
mt: 1,
}}
>
Добавить
</Typography>
<Typography
variant="h6"
sx={{
color: '#fff',
}}
>
версию
Добавить версию
</Typography>
</Card>
);
@ -292,25 +296,54 @@ export const VersionsExplorer = () => {
overflow: 'hidden',
}}
>
{/* Глобальный фоновый слой для размытия всего интерфейса */}
{/* Заголовок страницы в стиле Registration */}
<Box
sx={{
mt: '7vh',
mb: '1vh',
textAlign: 'center',
}}
>
<Typography
variant="h3"
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '3vw',
backgroundImage: gradientPrimary,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Выбор версии клиента
</Typography>
<Typography
variant="subtitle1"
sx={{
color: 'rgba(255,255,255,0.7)',
mt: 1,
}}
>
Выберите установленную версию или добавьте новую сборку
</Typography>
</Box>
{/* Глобальный фоновый слой (мягкий эффект) */}
{/* <Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backdropFilter: hoveredCardId ? 'blur(10px)' : 'blur(0)',
transition: 'backdrop-filter 0.3s ease-in-out',
zIndex: 5,
background:
'radial-gradient(circle at top, rgba(242,113,33,0.12), transparent 55%)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
/> */}
{loading ? (
<Box display="flex" justifyContent="center" my={5}>
<CircularProgress />
</Box>
<FullScreenLoader message="Загрузка ваших версий..." />
) : (
<Grid
container
@ -320,13 +353,12 @@ export const VersionsExplorer = () => {
height: '100%',
overflowY: 'auto',
justifyContent: 'center',
alignContent: 'center',
alignContent: 'flex-start',
position: 'relative',
zIndex: 6,
padding: '2vh 0',
zIndex: 1,
pt: '3vh',
}}
>
{/* Показываем установленные версии или дефолтную, если она есть */}
{installedVersions.length > 0 ? (
installedVersions.map((version) => (
<Grid
@ -357,7 +389,6 @@ export const VersionsExplorer = () => {
</Grid>
))
) : (
// Если нет ни одной версии, показываем карточку добавления
<Grid
item
xs={12}
@ -373,7 +404,6 @@ export const VersionsExplorer = () => {
</Grid>
)}
{/* Всегда добавляем карточку для добавления новых версий */}
{installedVersions.length > 0 && (
<Grid
item
@ -405,21 +435,30 @@ export const VersionsExplorer = () => {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
width: 420,
maxWidth: '90vw',
maxHeight: '80vh',
overflowY: 'auto',
background: 'linear-gradient(45deg, #000000 10%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
background: 'linear-gradient(145deg, #000000 10%, #8A2387 100%)',
border: '1px solid rgba(255,255,255,0.16)',
boxShadow: '0 20px 60px rgba(0,0,0,0.85)',
p: 4,
borderRadius: '3vw',
gap: '1vh',
borderRadius: '2.5vw',
gap: '1.5vh',
display: 'flex',
flexDirection: 'column',
backdropFilter: 'blur(10px)',
backdropFilter: 'blur(18px)',
}}
>
<Typography
variant="h6"
component="h2"
sx={{
color: '#fff',
fontFamily: 'Benzin-Bold',
mb: 1,
}}
>
<Typography variant="h6" component="h2" sx={{ color: '#fff' }}>
Доступные версии для скачивания
</Typography>
@ -428,17 +467,22 @@ export const VersionsExplorer = () => {
Загрузка доступных версий...
</Typography>
) : (
<List sx={{ mt: 2 }}>
<List sx={{ mt: 1 }}>
{availableVersions.map((version) => (
<ListItem
key={version.id}
sx={{
borderRadius: '8px',
borderRadius: '1vw',
mb: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(0, 0, 0, 0.35)',
border: '1px solid rgba(255,255,255,0.08)',
cursor: 'pointer',
transition:
'background-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
transform: 'translateY(-2px)',
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
},
}}
onClick={() => handleSelectVersion(version)}
@ -446,11 +490,22 @@ export const VersionsExplorer = () => {
<ListItemText
primary={version.name}
secondary={version.version}
primaryTypographyProps={{ color: '#fff' }}
primaryTypographyProps={{
color: '#fff',
fontFamily: 'Benzin-Bold',
}}
secondaryTypographyProps={{
color: 'rgba(255,255,255,0.7)',
}}
/>
{downloadLoading === version.id && (
<Typography
variant="body2"
sx={{ color: 'rgba(255,255,255,0.7)' }}
>
Загрузка...
</Typography>
)}
</ListItem>
))}
</List>
@ -458,15 +513,20 @@ export const VersionsExplorer = () => {
<Button
onClick={handleCloseModal}
variant="outlined"
variant="contained"
sx={{
mt: 3,
alignSelf: 'center',
borderColor: '#fff',
color: '#fff',
px: 6,
py: 1.2,
borderRadius: '2.5vw',
background: gradientPrimary,
fontFamily: 'Benzin-Bold',
fontSize: '1vw',
textTransform: 'none',
'&:hover': {
borderColor: '#ccc',
backgroundColor: 'rgba(255,255,255,0.1)',
transform: 'scale(1.05)',
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
},
}}
>

View File

@ -0,0 +1,21 @@
// src/renderer/utils/serverTranslator.ts
import { Server } from '../api';
type ServerLike = Pick<Server, 'name'> | { name: string };
export const translateServer = (
server: ServerLike | null | undefined,
): string => {
if (!server?.name) return '';
switch (server.name) {
case 'Server minecraft.hub.popa-popa.ru':
return 'Хаб';
case 'Server survival.hub.popa-popa.ru':
return 'Выживание';
case 'Server minecraft.minigames.popa-popa.ru':
return 'Миниигры';
default:
return server.name;
}
};