add info for item

This commit is contained in:
aurinex
2025-12-29 19:17:08 +05:00
parent e0889cfaea
commit dc4fe3b18e
3 changed files with 436 additions and 11 deletions

View File

@ -174,6 +174,7 @@ export interface MarketplaceResponse {
export interface MarketplaceItemResponse {
_id: string;
id: string;
description: string;
material: string;
amount: number;
price: number;
@ -186,6 +187,10 @@ export interface MarketplaceItemResponse {
slot: number;
material: string;
amount: number;
meta?: {
enchants?: Record<string, number>;
[key: string]: any;
};
};
created_at: string;
}

View File

@ -50,6 +50,8 @@ import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import type { SxProps, Theme } from '@mui/material/styles';
import { getWsBaseUrl } from '../realtime/wsBase';
import { formatEnchants } from '../utils/itemTranslator';
import { translateMetaKey, formatMetaValue } from '../utils/itemTranslator';
interface TabPanelProps {
children?: React.ReactNode;
@ -133,15 +135,31 @@ export default function Marketplace() {
const [editPriceOpen, setEditPriceOpen] = useState(false);
const [editItem, setEditItem] = useState<MarketplaceItemResponse | null>(null);
const [editPriceValue, setEditPriceValue] = useState<string>('');
const [description, setDescription] = useState('');
const [removeOpen, setRemoveOpen] = useState(false);
const [removeItem, setRemoveItem] = useState<MarketplaceItemResponse | null>(null);
const inventoryRef = useRef<PlayerInventoryHandle | null>(null);
const [metaDialogOpen, setMetaDialogOpen] = useState(false);
const [metaItem, setMetaItem] = useState<MarketplaceItemResponse | null>(null);
const [metaSearch, setMetaSearch] = useState('');
const openMetaDialog = (item: MarketplaceItemResponse) => {
setMetaItem(item);
setMetaDialogOpen(true);
};
const closeMetaDialog = () => {
setMetaDialogOpen(false);
setMetaItem(null);
};
const openEditPrice = (item: MarketplaceItemResponse) => {
setEditItem(item);
setEditPriceValue(String(item.price ?? ''));
setDescription(item.description ?? '');
setEditPriceOpen(true);
};
@ -319,14 +337,26 @@ export default function Marketplace() {
const handleSavePrice = async () => {
if (!username || !selectedServer || !editItem) return;
const parsed = Number(editPriceValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
const price = Number(editPriceValue);
if (!price || price <= 0) {
showNotification('Цена должна быть числом больше 0', 'warning');
return;
}
// ДОДЕЛАТЬ - ИЗМЕНЕНИЕ ОПИСАНИЯ
// const payload: any = {
// itemId: editItem.id,
// price,
// };
// if (description.trim() !== '') {
// payload.description = description.trim();
// } else {
// payload.description = null;
// }
try {
await updateMarketplaceItemPrice(username, editItem.id, parsed);
await updateMarketplaceItemPrice(username, editItem.id, price);
showNotification('Цена обновлена', 'success');
@ -413,6 +443,20 @@ export default function Marketplace() {
setTabValue(newValue);
};
const metaKeyMatchesSearch = (key: string, search: string) => {
if (!search) return true;
const s = search.toLowerCase();
const original = key.toLowerCase();
const translated = translateMetaKey(key).toLowerCase();
return (
original.includes(s) ||
translated.includes(s)
);
};
const handleBuyItem = async (itemId: string) => {
try {
if (!username) return;
@ -502,10 +546,10 @@ export default function Marketplace() {
color: 'rgba(255,255,255,0.95)',
},
transition: 'transform 0.18s ease, filter 0.18s ease, border-color 0.18s ease',
transition: 'transform 0.18s ease, filter 0.18s ease, border-color 0.25s ease, box-shadow 0.25s ease',
'&:hover': {
transform: 'scale(1.01)',
borderColor: 'rgba(255,255,255,0.14)',
borderColor: 'rgba(200, 33, 242, 0.35)',
boxShadow: '0 1.2vw 3.2vw rgba(53, 3, 66, 0.45)',
},
'&.Mui-focused': {
borderColor: 'rgba(255,255,255,0.18)',
@ -579,6 +623,26 @@ export default function Marketplace() {
}
};
const getItemMeta = (item: MarketplaceItemResponse) => {
return item.item_data?.meta ?? null;
};
const extractEnchants = (meta: Record<string, any>) => {
return meta?.enchants && typeof meta.enchants === 'object'
? meta.enchants
: null;
};
const hasItemMeta = (item: MarketplaceItemResponse) => {
const meta = item.item_data;
if (!meta) return false;
// базовые поля, которые НЕ считаем метой
const baseKeys = ['slot', 'material', 'amount'];
return Object.keys(meta).some((key) => !baseKeys.includes(key));
};
const statusChip = useMemo(() => {
if (statusLoading) {
@ -676,7 +740,6 @@ export default function Marketplace() {
</Typography>
<TextField
autoFocus
fullWidth
label="Новая цена"
value={editPriceValue}
@ -685,6 +748,15 @@ export default function Marketplace() {
sx={PRICE_FIELD_SX}
/>
{/* <TextField
fullWidth
label="Описание (необязательно)"
value={description}
onChange={(e) => setDescription(e.target.value)}
inputProps={{ min: 1 }}
sx={PRICE_FIELD_SX}
/> */}
{editItem && (
<Box sx={{ mt: 1.2, color: 'rgba(255,255,255,0.62)', fontWeight: 700 }}>
Текущая цена: <b style={{ color: 'rgba(255,255,255,0.9)' }}>{editItem.price}</b>
@ -802,6 +874,180 @@ export default function Marketplace() {
</DialogActions>
</Dialog>
{/* FULL INFO ITEM */}
<Dialog
open={metaDialogOpen}
onClose={closeMetaDialog}
fullWidth
maxWidth="sm"
PaperProps={{ sx: GLASS_PAPER_SX }}
>
<DialogTitle sx={DIALOG_TITLE_SX}>
Информация о предмете
<IconButton onClick={closeMetaDialog} sx={CLOSE_BTN_SX}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={DIALOG_DIVIDERS_SX}>
{metaItem && (() => {
const meta = getItemMeta(metaItem);
if (!meta) return null;
const enchants = formatEnchants(meta.enchants);
const filteredMeta = Object.entries(meta).filter(([key]) =>
key !== 'enchants' &&
metaKeyMatchesSearch(key, metaSearch)
);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1vw' }}>
{/* IMAGE + TITLE */}
<Box sx={{ display: 'flex', gap: '1vw', alignItems: 'center' }}>
<Box
sx={{
width: 80,
height: 80,
borderRadius: '1vw',
border: '1px solid rgba(255,255,255,0.12)',
background: 'rgba(0,0,0,0.35)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={`https://cdn.minecraft.popa-popa.ru/textures/${metaItem.material.toLowerCase()}.png`}
alt={metaItem.material}
style={{ width: '70%', imageRendering: 'pixelated' }}
/>
</Box>
<Box>
<Typography sx={{ fontFamily: 'Benzin-Bold', fontSize: '1.2rem' }}>
{metaItem.display_name || metaItem.material}
</Typography>
<Typography sx={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
Количество: {metaItem.amount} · Цена: {metaItem.price}
</Typography>
</Box>
</Box>
<Divider sx={{mt: '0.7vw'}} />
{/* DESCRIPTION (optional) */}
{metaItem.description && (
<Typography>
Описание
<Box
sx={{
mt: 0.6,
p: '0.8vw',
borderRadius: '0.8vw',
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.10)',
}}
>
<Typography sx={{ color: 'rgba(255,255,255,0.85)', fontSize: '0.9rem' }}>
{metaItem.description}
</Typography>
</Box>
<Divider sx={{mt: '2vw'}} />
</Typography>
)}
{/* SEARCH */}
<TextField
placeholder="Поиск по метаданным..."
value={metaSearch}
onChange={(e) => setMetaSearch(e.target.value)}
fullWidth
sx={PRICE_FIELD_SX}
/>
<Divider sx={{mt: '0.7vw'}} />
{/* ENCHANTS */}
{enchants && (
<>
{enchants.length > 0 &&
<Typography sx={{ fontFamily: 'Benzin-Bold', fontSize: '1rem' }}>
Зачарования
</Typography>
}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '0.6vw' }}>
{enchants.map((e) => (
<Chip
key={e.label}
label={`${e.label} ${e.level}`}
sx={{
fontFamily: 'Benzin-Bold',
background: 'rgba(156,255,198,0.15)',
border: '1px solid rgba(156,255,198,0.25)',
color: 'rgba(156,255,198,0.95)',
}}
/>
))}
</Box>
</>
)}
{/* OTHER META */}
{filteredMeta.length > 0 && (
<>
<Typography sx={{ fontFamily: 'Benzin-Bold', fontSize: '1rem' }}>
Свойства
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '0.4vw' }}>
{filteredMeta.map(([key, value]) => (
<Box
key={key}
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: '1vw',
fontSize: '0.85rem',
color: 'rgba(255,255,255,0.85)',
background: 'rgba(255,255,255,0.04)',
borderRadius: '0.6vw',
px: '0.8vw',
py: '0.4vw',
}}
>
<span style={{ opacity: 0.7 }}>{translateMetaKey(key)}</span>
<span style={{ fontWeight: 600 }}>{formatMetaValue(value)}</span>
</Box>
))}
</Box>
</>
)}
</Box>
);
})()}
</DialogContent>
<DialogActions sx={{ p: '1.2vw' }}>
<Button
onClick={closeMetaDialog}
sx={{
fontFamily: 'Benzin-Bold',
color: '#fff',
borderRadius: '999px',
px: '1.6vw',
py: '0.6vw',
background: GRADIENT,
}}
>
Закрыть
</Button>
</DialogActions>
</Dialog>
{/* HEADER (glass) */}
<Box
sx={{
@ -1114,6 +1360,7 @@ export default function Marketplace() {
<Box sx={{ position: 'relative', p: '1.0vw', pb: 0 }}>
<Box
sx={{
position: 'relative', // 👈 важно
borderRadius: '1.4vw',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.12)',
@ -1124,6 +1371,36 @@ export default function Marketplace() {
alignItems: 'center',
}}
>
{/* INFO BUTTON */}
{hasItemMeta(item) && (
<IconButton
onClick={() => openMetaDialog(item)}
size="small"
sx={{
position: 'absolute',
top: '0.5vw',
right: '0.5vw',
width: '1.8vw',
height: '1.8vw',
borderRadius: '50%',
zIndex: 2,
color: 'rgba(255,255,255,0.85)',
background: 'rgba(0,0,0,0.45)',
border: '1px solid rgba(255,255,255,0.18)',
backdropFilter: 'blur(8px)',
'&:hover': {
background: 'rgba(255,255,255,0.15)',
transform: 'scale(1.05)',
},
transition: 'all 0.2s ease',
}}
>
i
</IconButton>
)}
<CardMedia
component="img"
sx={{
@ -1252,11 +1529,11 @@ export default function Marketplace() {
p: '1.6vw',
}}
>
{playerServer && username ? (
{ username ? (
<PlayerInventory
ref={inventoryRef}
username={username}
serverIp={playerServer.ip}
username={'Yana'}
serverIp={'minecraft.survival.popa-popa.ru'}
onSellSuccess={() => {
if (selectedServer) loadMarketItems(selectedServer.ip, 1);
showNotification('Предмет успешно выставлен на продажу!', 'success');
@ -1270,7 +1547,6 @@ export default function Marketplace() {
</Box>
</TabPanel>
{/* "Мои товары" — пока заглушка (как было, логики у тебя в файле нет) */}
<TabPanel value={tabValue} index={2}>
{myLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: '6vh' }}>
@ -1491,3 +1767,41 @@ export default function Marketplace() {
</Box>
);
}
function ItemMetaTooltip({ meta }: { meta: Record<string, any> }) {
return (
<Box sx={{ minWidth: 220 }}>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.85rem',
mb: 0.6,
color: '#fff',
}}
>
Информация о предмете
</Typography>
{Object.entries(meta).map(([key, value]) => (
<Box
key={key}
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: '0.8vw',
fontSize: '0.75rem',
color: 'rgba(255,255,255,0.75)',
}}
>
<span>{key}</span>
<span style={{ opacity: 0.9 }}>
{typeof value === 'object'
? JSON.stringify(value)
: String(value)}
</span>
</Box>
))}
</Box>
);
}

View File

@ -0,0 +1,106 @@
// utils/itemTranslator.ts
/* ----------------------------- */
/* ENCHANT TRANSLATIONS */
/* ----------------------------- */
export const ENCHANT_TRANSLATIONS: Record<string, string> = {
sharpness: 'Острота',
smite: 'Небесная кара',
bane_of_arthropods: 'Бич членистоногих',
efficiency: 'Эффективность',
unbreaking: 'Прочность',
fortune: 'Удача',
silk_touch: 'Шёлковое касание',
power: 'Сила',
punch: 'Отдача',
flame: 'Огонь',
infinity: 'Бесконечность',
protection: 'Защита',
fire_protection: 'Огнестойкость',
blast_protection: 'Взрывоустойчивость',
projectile_protection: 'Защита от снарядов',
feather_falling: 'Невесомость',
respiration: 'Подводное дыхание',
aqua_affinity: 'Подводник',
thorns: 'Шипы',
depth_strider: 'Подводная ходьба',
frost_walker: 'Ледоход',
mending: 'Починка',
binding_curse: 'Проклятие несъёмности',
vanishing_curse: 'Проклятие утраты',
looting: 'Добыча',
sweeping: 'Разящий клинок',
fire_aspect: 'Заговор огня',
knockback: 'Отдача',
luck_of_the_sea: 'Морская удача',
lure: 'Приманка',
};
/* ----------------------------- */
/* GENERIC META TRANSLATIONS */
/* ----------------------------- */
export const META_TRANSLATIONS: Record<string, string> = {
durability: 'Прочность',
max_durability: 'Максимальная прочность',
custom_model_data: 'Кастомная модель',
unbreakable: 'Неразрушимый',
repair_cost: 'Стоимость починки',
hide_flags: 'Скрытые флаги',
rarity: 'Редкость',
damage: 'Урон',
attack_speed: 'Скорость атаки',
armor: 'Броня',
armor_toughness: 'Твёрдость брони',
knockback_resistance: 'Сопротивление отталкиванию',
glowing: 'Подсветка',
};
/* ----------------------------- */
/* FORMATTERS */
/* ----------------------------- */
export function translateEnchant(key: string): string {
return ENCHANT_TRANSLATIONS[key.toLowerCase()] ?? beautifyKey(key);
}
export function translateMetaKey(key: string): string {
return META_TRANSLATIONS[key.toLowerCase()] ?? beautifyKey(key);
}
export function beautifyKey(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
/* ----------------------------- */
/* VALUE FORMATTERS */
/* ----------------------------- */
export function formatMetaValue(value: any): string {
if (typeof value === 'boolean') return value ? 'Да' : 'Нет';
if (typeof value === 'number') return String(value);
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.join(', ');
if (typeof value === 'object') return 'Сложное значение';
return String(value);
}
/* ----------------------------- */
/* ENCHANT LIST HELPER */
/* ----------------------------- */
export function formatEnchants(
enchants?: Record<string, number>,
): { label: string; level: number }[] {
if (!enchants || typeof enchants !== 'object') return [];
return Object.entries(enchants).map(([key, level]) => ({
label: translateEnchant(key),
level,
}));
}