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

This commit is contained in:
2025-12-20 17:11:25 +05:00
4 changed files with 1729 additions and 553 deletions

View File

@ -165,26 +165,7 @@ export interface OnlinePlayersResponse {
}
export interface MarketplaceResponse {
items: [
{
_id: string;
id: string;
material: string;
amount: number;
price: number;
seller_name: string;
server_ip: string;
display_name: string | null;
lore: string | null;
enchants: string | null;
item_data: {
slot: number;
material: string;
amount: number;
};
created_at: string;
},
];
items: MarketplaceItemResponse[];
total: number;
page: number;
pages: number;
@ -1033,6 +1014,57 @@ export async function RequestPlayerInventory(
return await response.json();
}
// ===== Marketplace: мои лоты (изменить цену / снять с продажи) =====
export async function updateMarketplaceItemPrice(
username: string,
item_id: string,
new_price: number,
): Promise<{ status: string; message?: string }> {
const response = await fetch(
`${API_BASE_URL}/api/marketplace/items/${encodeURIComponent(item_id)}/price`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, new_price }),
},
);
if (!response.ok) {
let msg = 'Не удалось изменить цену';
try {
const err = await response.json();
msg = err.message || err.detail || msg;
} catch {}
throw new Error(msg);
}
return await response.json();
}
export async function cancelMarketplaceItemSale(
username: string,
item_id: string,
): Promise<{ status: string; message?: string }> {
const url = new URL(
`${API_BASE_URL}/api/marketplace/items/${encodeURIComponent(item_id)}`,
);
url.searchParams.set('username', username);
const response = await fetch(url.toString(), { method: 'DELETE' });
if (!response.ok) {
let msg = 'Не удалось снять товар с продажи';
try {
const err = await response.json();
msg = err.message || err.detail || msg;
} catch {}
throw new Error(msg);
}
return await response.json();
}
export async function buyItem(
buyer_username: string,
item_id: string,
@ -1175,6 +1207,40 @@ export async function fetchMarketplace(
return await response.json();
}
export async function fetchMyMarketplaceItems(
username: string,
server_ip?: string | null,
page = 1,
limit = 20,
): Promise<MarketplaceResponse> {
// вместо /items/me используем /items/by-seller/{username}
const url = new URL(`${API_BASE_URL}/api/marketplace/items/by-seller/${encodeURIComponent(username)}`);
if (server_ip) url.searchParams.set('server_ip', server_ip);
url.searchParams.set('page', String(page));
url.searchParams.set('limit', String(limit));
const response = await fetch(url.toString());
// если на бэке “нет предметов” возвращают 404 — можно трактовать как пустой список
if (response.status === 404) {
return { items: [], total: 0, page, pages: 1 };
}
if (!response.ok) {
let msg = 'Не удалось получить ваши товары';
try {
const err = await response.json();
msg = err.message || err.detail || msg;
} catch {}
throw new Error(msg);
}
return await response.json();
}
// ===== Marketplace ===== \\
// Исправьте тип возвращаемого значения
export async function fetchActiveServers(): Promise<Server[]> {
const response = await fetch(`${API_BASE_URL}/api/pranks/servers`);

View File

@ -134,13 +134,13 @@ export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
);
}
if (!onlinePlayers.length) {
return (
<Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>
Сейчас на серверах никого нет.
</Typography>
);
}
// if (!onlinePlayers.length) {
// return (
// <Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>
// Сейчас на серверах никого нет.
// </Typography>
// );
// }
const totalOnline = onlinePlayers.length;
@ -371,82 +371,119 @@ export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
},
}}
>
{filteredPlayers.map((p) => {
const isMe = p.username === currentUsername;
{filteredPlayers.length ? (
filteredPlayers.map((p) => {
const isMe = p.username === currentUsername;
return (
<Paper
key={p.uuid}
elevation={0}
sx={{
px: '1.1vw',
py: '0.75vw',
borderRadius: '1.1vw',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1vw',
transition: 'transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease',
'&:hover': {
transform: 'scale(1.01)',
borderColor: 'rgba(242,113,33,0.35)',
boxShadow: '0 0.8vw 2.4vw rgba(0,0,0,0.45)',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.8vw', minWidth: 0 }}>
<HeadAvatar skinUrl={skinMap[p.uuid]} size={26} />
<Typography
return (
<Paper
key={p.uuid}
elevation={0}
sx={{
px: '1.1vw',
py: '0.75vw',
borderRadius: '1.1vw',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1vw',
transition:
'transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease',
'&:hover': {
transform: 'scale(1.01)',
borderColor: 'rgba(242,113,33,0.35)',
boxShadow: '0 0.8vw 2.4vw rgba(0,0,0,0.45)',
},
}}
>
<Box
sx={{
fontFamily: 'Benzin-Bold',
color: 'rgba(255,255,255,0.92)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '0.8vw',
minWidth: 0,
}}
>
{p.username}
</Typography>
<HeadAvatar skinUrl={skinMap[p.uuid]} size={26} />
<Typography
sx={{
fontFamily: 'Benzin-Bold',
color: 'rgba(255,255,255,0.92)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{p.username}
</Typography>
{isMe && (
{isMe && (
<Chip
label="Вы"
size="small"
sx={{
height: '1.55rem',
fontSize: '0.72rem',
fontWeight: 900,
color: 'white',
borderRadius: '999px',
backgroundImage: GRADIENT,
boxShadow: '0 10px 22px rgba(0,0,0,0.45)',
}}
/>
)}
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '0.6vw',
flexShrink: 0,
}}
>
<Chip
label="Вы"
label={translateServer(p.serverName)}
size="small"
sx={{
height: '1.55rem',
fontFamily: 'Benzin-Bold',
fontSize: '0.72rem',
fontWeight: 900,
color: 'white',
borderRadius: '999px',
backgroundImage: GRADIENT,
boxShadow: '0 10px 22px rgba(0,0,0,0.45)',
color: 'rgba(255,255,255,0.88)',
background:
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,205,0.12), rgba(138,35,135,0.16))',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(12px)',
}}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.6vw', flexShrink: 0 }}>
<Chip
label={translateServer(p.serverName)}
size="small"
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.72rem',
borderRadius: '999px',
color: 'rgba(255,255,255,0.88)',
background:
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,205,0.12), rgba(138,35,135,0.16))',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(12px)',
}}
/>
{/* onlineSince можно потом красиво форматировать */}
</Box>
</Paper>
);
})}
</Box>
</Paper>
);
})
) : (
<Box
sx={{
py: '1.4vw',
px: '1.1vw',
borderRadius: '1.1vw',
background: 'rgba(255,255,255,0.03)',
border: '1px dashed rgba(255,255,255,0.14)',
textAlign: 'center',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.95vw',
color: 'rgba(255,255,255,0.78)',
}}
>
Сейчас на сервере никого нет!
</Typography>
</Box>
)}
</Box>
</Paper>
);

View File

@ -1,5 +1,5 @@
// src/renderer/components/PlayerInventory.tsx
import { useEffect, useState } from 'react';
import React, { useEffect, useImperativeHandle, useState } from 'react';
import {
Box,
Typography,
@ -22,6 +22,118 @@ import {
PlayerInventoryItem,
} from '../api';
import { FullScreenLoader } from './FullScreenLoader';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
const GLASS_PAPER_SX = {
borderRadius: '1.2vw',
overflow: 'hidden',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
color: 'white',
backdropFilter: 'blur(16px)',
} as const;
const DIALOG_TITLE_SX = {
fontFamily: 'Benzin-Bold',
pr: 6,
position: 'relative',
} as const;
const CLOSE_BTN_SX = {
position: 'absolute',
top: 10,
right: 10,
color: 'rgba(255,255,255,0.9)',
border: '1px solid rgba(255,255,255,0.12)',
background: 'rgba(255,255,255,0.06)',
backdropFilter: 'blur(12px)',
'&:hover': { transform: 'scale(1.05)', background: 'rgba(255,255,255,0.10)' },
transition: 'all 0.2s ease',
} as const;
const DIVIDERS_SX = {
borderColor: 'rgba(255,255,255,0.10)',
} as const;
const INPUT_SX = {
mt: 1.2,
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.72)',
fontFamily: 'Benzin-Bold',
letterSpacing: 0.3,
textTransform: 'uppercase',
},
'& .MuiInputLabel-root.Mui-focused': {
color: 'rgba(255,255,255,0.92)',
},
'& .MuiOutlinedInput-root': {
position: 'relative',
borderRadius: '1.1vw',
overflow: 'hidden',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.16), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.92)',
border: '1px solid rgba(255,255,255,0.10)',
boxShadow: '0 1.2vw 3.0vw rgba(0,0,0,0.55)',
backdropFilter: 'blur(14px)',
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
'& input': {
fontFamily: 'Benzin-Bold',
fontSize: '1.0rem',
padding: '1.0vw 1.0vw',
color: 'rgba(255,255,255,0.95)',
},
transition: 'transform 0.18s ease, filter 0.18s ease, border-color 0.18s ease',
'&:hover': {
transform: 'scale(1.01)',
borderColor: 'rgba(255,255,255,0.14)',
},
'&.Mui-focused': {
borderColor: 'rgba(255,255,255,0.18)',
filter: 'brightness(1.03)',
},
'&:after': {
content: '""',
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: '0.18vw',
borderRadius: '999px',
background: GRADIENT,
opacity: 0.92,
pointerEvents: 'none',
},
},
} as const;
const PRIMARY_BTN_SX = {
fontFamily: 'Benzin-Bold',
color: '#fff',
background: GRADIENT,
borderRadius: '999px',
px: '1.6vw',
py: '0.65vw',
boxShadow: '0 1.0vw 2.6vw rgba(0,0,0,0.45)',
'&:hover': { filter: 'brightness(1.05)' },
} as const;
const SECONDARY_BTN_SX = {
color: 'rgba(255,255,255,0.85)',
fontFamily: 'Benzin-Bold',
} as const;
interface PlayerInventoryProps {
username: string;
@ -29,11 +141,12 @@ interface PlayerInventoryProps {
onSellSuccess?: () => void; // Callback для обновления маркетплейса после продажи
}
export default function PlayerInventory({
username,
serverIp,
onSellSuccess,
}: PlayerInventoryProps) {
export type PlayerInventoryHandle = {
refresh: () => Promise<void>;
};
const PlayerInventory = React.forwardRef<PlayerInventoryHandle, PlayerInventoryProps>(
({ username, serverIp, onSellSuccess }, ref) => {
const [loading, setLoading] = useState<boolean>(false);
const [inventoryItems, setInventoryItems] = useState<PlayerInventoryItem[]>(
[],
@ -96,6 +209,12 @@ export default function PlayerInventory({
}
};
useImperativeHandle(ref, () => ({
refresh: async () => {
await fetchPlayerInventory();
},
}));
// Открываем диалог для продажи предмета
const handleOpenSellDialog = (item: PlayerInventoryItem) => {
setSelectedItem(item);
@ -183,7 +302,7 @@ export default function PlayerInventory({
};
return (
<Box sx={{ mt: '1vw' }}>
<Box>
<Box
sx={{
display: 'flex',
@ -195,25 +314,6 @@ export default function PlayerInventory({
<Typography variant="h5" color="white">
Ваш инвентарь
</Typography>
<Button
variant="outlined"
onClick={fetchPlayerInventory}
disabled={loading}
sx={{
borderRadius: '20px',
p: '10px 25px',
color: 'white',
borderColor: 'rgba(255, 77, 77, 1)',
'&:hover': {
backgroundColor: 'rgba(255, 77, 77, 1)',
borderColor: 'rgba(255, 77, 77, 1)',
},
fontFamily: 'Benzin-Bold',
fontSize: '1vw',
}}
>
Обновить
</Button>
</Box>
{error && (
@ -223,7 +323,7 @@ export default function PlayerInventory({
)}
{loading ? (
<FullScreenLoader fullScreen={false} />
<FullScreenLoader fullScreen={false} message="Загрузка инвентаря..." />
) : (
<>
{inventoryItems.length === 0 ? (
@ -311,79 +411,148 @@ export default function PlayerInventory({
)}
{/* Диалог для продажи предмета */}
<Dialog open={sellDialogOpen} onClose={handleCloseSellDialog}>
<DialogTitle>Продать предмет</DialogTitle>
<DialogContent>
<Dialog
open={sellDialogOpen}
onClose={handleCloseSellDialog}
fullWidth
maxWidth="xs"
PaperProps={{ sx: GLASS_PAPER_SX }}
>
<DialogTitle sx={DIALOG_TITLE_SX}>
Продать предмет
<IconButton onClick={handleCloseSellDialog} sx={CLOSE_BTN_SX}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={DIVIDERS_SX}>
{selectedItem && (
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CardMedia
<Box sx={{ mt: 0.5 }}>
{/* Верхняя карточка предмета */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: '1vw',
p: '0.9vw',
borderRadius: '1.1vw',
border: '1px solid rgba(255,255,255,0.10)',
background: 'rgba(255,255,255,0.05)',
mb: 1.1,
}}
>
<Box
component="img"
sx={{
width: 50,
height: 50,
objectFit: 'contain',
mr: 2,
}}
image={`https://cdn.minecraft.popa-popa.ru/textures/${selectedItem.material.toLowerCase()}.png`}
src={`https://cdn.minecraft.popa-popa.ru/textures/${selectedItem.material.toLowerCase()}.png`}
alt={selectedItem.material}
draggable={false}
style={{
width: 54,
height: 54,
objectFit: 'contain',
imageRendering: 'pixelated',
userSelect: 'none',
}}
/>
<Typography variant="h6">
{getItemDisplayName(selectedItem.material)}
</Typography>
<Box sx={{ minWidth: 0 }}>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.05rem',
lineHeight: 1.1,
color: 'rgba(255,255,255,0.95)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
title={getItemDisplayName(selectedItem.material)}
>
{getItemDisplayName(selectedItem.material)}
</Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.70)', fontWeight: 800, mt: 0.4 }}>
Доступно: <span style={{ color: 'rgba(255,255,255,0.92)' }}>{selectedItem.amount}</span>
</Typography>
</Box>
</Box>
<Typography variant="body2" gutterBottom>
Всего доступно: {selectedItem.amount}
</Typography>
{/* Поля */}
<TextField
label="Количество"
type="number"
fullWidth
margin="dense"
value={amount}
onChange={(e) =>
setAmount(
Math.min(
parseInt(e.target.value) || 0,
selectedItem.amount,
),
)
}
onChange={(e) => {
const v = Number(e.target.value);
const safe = Number.isFinite(v) ? v : 0;
setAmount(Math.min(Math.max(1, safe), selectedItem.amount));
}}
inputProps={{ min: 1, max: selectedItem.amount }}
sx={INPUT_SX}
/>
<TextField
label="Цена (за всё)"
type="number"
fullWidth
margin="dense"
value={price}
onChange={(e) => setPrice(parseInt(e.target.value) || 0)}
onChange={(e) => {
const v = Number(e.target.value);
setPrice(Number.isFinite(v) ? v : 0);
}}
inputProps={{ min: 1 }}
sx={INPUT_SX}
/>
{sellError && (
<Alert severity="error" sx={{ mt: 2 }}>
<Box
sx={{
mt: 1.2,
p: '0.9vw',
borderRadius: '1.0vw',
border: '1px solid rgba(255,70,70,0.22)',
background: 'rgba(255,70,70,0.12)',
color: 'rgba(255,255,255,0.92)',
fontWeight: 800,
}}
>
{sellError}
</Alert>
</Box>
)}
{/* Подсказка */}
<Typography sx={{ mt: 1.1, color: 'rgba(255,255,255,0.60)', fontWeight: 700 }}>
Цена указывается за весь лот!
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseSellDialog}>Отмена</Button>
<DialogActions sx={{ p: '1.2vw' }}>
<Button onClick={handleCloseSellDialog} sx={SECONDARY_BTN_SX}>
Отмена
</Button>
<Button
onClick={handleSellItem}
variant="contained"
color="primary"
disableRipple
disabled={sellLoading}
sx={{
...PRIMARY_BTN_SX,
...(sellLoading
? {
background: 'rgba(255,255,255,0.10)',
boxShadow: 'none',
color: 'rgba(255,255,255,0.55)',
}
: null),
}}
>
{sellLoading ? <FullScreenLoader fullScreen={false} /> : 'Продать'}
{sellLoading ? 'Выставляем…' : 'Продать'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
})
export default PlayerInventory;

File diff suppressed because it is too large Load Diff