feat: improve Minecraft version handling

This commit is contained in:
2025-07-13 23:37:46 +05:00
parent 815ce286f7
commit 942066ea76
9 changed files with 491 additions and 217 deletions

View File

@ -119,7 +119,7 @@ const createWindow = async () => {
width: 1024, width: 1024,
height: 850, height: 850,
autoHideMenuBar: true, autoHideMenuBar: true,
resizable: false, resizable: true,
frame: false, frame: false,
icon: getAssetPath('icon.png'), icon: getAssetPath('icon.png'),
webPreferences: { webPreferences: {

View File

@ -805,8 +805,11 @@ export function initMinecraftHandlers() {
} }
}); });
// Добавьте в функцию initMinecraftHandlers новый обработчик // ПРОБЛЕМА: У вас два обработчика для одного и того же канала 'get-installed-versions'
ipcMain.handle('get-available-versions', async (event) => { // РЕШЕНИЕ: Объединим логику в один обработчик, а из второго обработчика вызовем функцию getInstalledVersions
// Сначала создаем общую функцию для получения установленных версий
function getInstalledVersions() {
try { try {
const appPath = path.dirname(app.getPath('exe')); const appPath = path.dirname(app.getPath('exe'));
const minecraftDir = path.join(appPath, '.minecraft'); const minecraftDir = path.join(appPath, '.minecraft');
@ -846,9 +849,59 @@ export function initMinecraftHandlers() {
} }
return { success: true, versions }; return { success: true, versions };
} catch (error) {
console.error('Ошибка при получении установленных версий:', error);
return { success: false, error: error.message, versions: [] };
}
}
// Регистрируем обработчик для get-installed-versions
ipcMain.handle('get-installed-versions', async () => {
return getInstalledVersions();
});
// Обработчик get-available-versions использует функцию getInstalledVersions
ipcMain.handle('get-available-versions', async (event, { gistUrl }) => {
try {
// Используем URL из параметров или значение по умолчанию
const url =
gistUrl ||
'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json';
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch versions from Gist: ${response.status} ${response.statusText}`,
);
}
const versions = await response.json();
// Получаем уже установленные версии
const installedResult = getInstalledVersions();
const installedVersions = installedResult.success
? installedResult.versions
: [];
// Добавляем флаг installed к каждой версии
const versionsWithInstallStatus = versions.map((version: any) => {
const isInstalled = installedVersions.some(
(installed: any) => installed.id === version.id,
);
return {
...version,
installed: isInstalled,
};
});
return {
success: true,
versions: versionsWithInstallStatus,
};
} catch (error) { } catch (error) {
console.error('Ошибка при получении доступных версий:', error); console.error('Ошибка при получении доступных версий:', error);
return { success: false, error: error.message }; return { success: false, error: error.message, versions: [] };
} }
}); });
} }

View File

@ -11,7 +11,9 @@ export type Channels =
| 'save-pack-config' | 'save-pack-config'
| 'load-pack-config' | 'load-pack-config'
| 'update-available' | 'update-available'
| 'install-update'; | 'install-update'
| 'get-installed-versions'
| 'get-available-versions';
const electronHandler = { const electronHandler = {
ipcRenderer: { ipcRenderer: {

View File

@ -47,3 +47,7 @@ h4 {
h5 { h5 {
font-family: 'Benzin-Bold' !important; font-family: 'Benzin-Bold' !important;
} }
h6 {
font-family: 'Benzin-Bold' !important;
}

View File

@ -43,15 +43,37 @@ const AuthCheck = ({ children }: { children: ReactNode }) => {
const validateToken = async (token: string) => { const validateToken = async (token: string) => {
try { try {
const response = await fetch('https://authserver.ely.by/auth/validate', { // Используем IPC для валидации токена через main процесс
method: 'POST', const result = await window.electron.ipcRenderer.invoke(
headers: { 'validate-token',
'Content-Type': 'application/json', token,
}, );
body: JSON.stringify({ accessToken: token }),
}); // Если токен недействителен, очищаем сохраненные данные в localStorage
return response.ok; if (!result.valid) {
console.log(
'Токен недействителен, очищаем данные авторизации из localStorage',
);
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
const config = JSON.parse(savedConfig);
// Сохраняем только логин и другие настройки, но удаляем токены
const cleanedConfig = {
username: config.username,
memory: config.memory || 4096,
comfortVersion: config.comfortVersion || '',
password: '', // Очищаем пароль для безопасности
};
localStorage.setItem(
'launcher_config',
JSON.stringify(cleanedConfig),
);
}
}
return result.valid;
} catch (error) { } catch (error) {
console.error('Ошибка проверки токена:', error);
return false; return false;
} }
}; };
@ -80,6 +102,7 @@ const App = () => {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
overflowX: 'hidden',
}} }}
> >
<MinecraftBackround /> <MinecraftBackround />

View File

@ -0,0 +1,92 @@
import { Box, Typography, Button, Modal } from '@mui/material';
import React from 'react';
import MemorySlider from '../Login/MemorySlider';
import FilesSelector from '../FilesSelector';
interface SettingsModalProps {
open: boolean;
onClose: () => void;
config: {
memory: number;
preserveFiles: string[];
};
onConfigChange: (newConfig: {
memory: number;
preserveFiles: string[];
}) => void;
packName: string;
onSave: () => void;
}
const SettingsModal = ({
open,
onClose,
config,
onConfigChange,
packName,
onSave,
}: SettingsModalProps) => {
return (
<Modal
open={open}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
background:
'linear-gradient(-242.94deg, #000000 39.07%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
p: 4,
borderRadius: '3vw',
gap: '1vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography id="modal-modal-title" variant="body1" component="h2">
Файлы и папки, которые будут сохранены после переустановки сборки
</Typography>
<FilesSelector
packName={packName}
initialSelected={config.preserveFiles}
onSelectionChange={(selected) => {
onConfigChange({ ...config, preserveFiles: selected });
}}
/>
<Typography variant="body1" sx={{ color: 'white' }}>
Оперативная память выделенная для Minecraft
</Typography>
<MemorySlider
memory={config.memory}
onChange={(e, value) => {
onConfigChange({ ...config, memory: value as number });
}}
/>
<Button
variant="contained"
color="success"
onClick={() => {
onSave();
onClose();
}}
sx={{
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
}}
>
Сохранить
</Button>
</Box>
</Modal>
);
};
export default SettingsModal;

View File

@ -5,7 +5,6 @@ import {
Snackbar, Snackbar,
Alert, Alert,
LinearProgress, LinearProgress,
Modal,
} from '@mui/material'; } from '@mui/material';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@ -13,8 +12,7 @@ import ServerStatus from '../components/ServerStatus/ServerStatus';
import PopaPopa from '../components/popa-popa'; import PopaPopa from '../components/popa-popa';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import React from 'react'; import React from 'react';
import MemorySlider from '../components/Login/MemorySlider'; import SettingsModal from '../components/Settings/SettingsModal';
import FilesSelector from '../components/FilesSelector';
declare global { declare global {
interface Window { interface Window {
@ -44,7 +42,10 @@ interface LaunchPageProps {
}; };
} }
const LaunchPage = ({ onLaunchPage, launchOptions = {} as any }: LaunchPageProps) => { const LaunchPage = ({
onLaunchPage,
launchOptions = {} as any,
}: LaunchPageProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { versionId } = useParams(); const { versionId } = useParams();
const [versionConfig, setVersionConfig] = useState<any>(null); const [versionConfig, setVersionConfig] = useState<any>(null);
@ -417,65 +418,14 @@ const LaunchPage = ({ onLaunchPage, launchOptions = {} as any }: LaunchPageProps
</Alert> </Alert>
</Snackbar> </Snackbar>
<Modal <SettingsModal
open={open} open={open}
onClose={handleClose} onClose={handleClose}
aria-labelledby="modal-modal-title" config={config}
aria-describedby="modal-modal-description" onConfigChange={setConfig}
> packName={versionId || versionConfig?.packName || 'Comfort'}
<Box onSave={savePackConfig}
sx={{ />
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
background:
'linear-gradient(-242.94deg, #000000 39.07%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
p: 4,
borderRadius: '3vw',
gap: '1vh',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography id="modal-modal-title" variant="body1" component="h2">
Файлы и папки, которые будут сохранены после переустановки сборки
</Typography>
<FilesSelector
packName={versionId || versionConfig?.packName || 'Comfort'}
initialSelected={config.preserveFiles}
onSelectionChange={(selected) => {
setConfig((prev) => ({ ...prev, preserveFiles: selected }));
}}
/>
<Typography variant="body1" sx={{ color: 'white' }}>
Оперативная память выделенная для Minecraft
</Typography>
<MemorySlider
memory={config.memory}
onChange={(e, value) => {
setConfig((prev) => ({ ...prev, memory: value as number }));
}}
/>
<Button
variant="contained"
color="success"
onClick={() => {
savePackConfig();
handleClose();
}}
sx={{
borderRadius: '3vw',
fontFamily: 'Benzin-Bold',
}}
>
Сохранить
</Button>
</Box>
</Modal>
</Box> </Box>
); );
}; };

View File

@ -38,13 +38,25 @@ const Login = () => {
console.log( console.log(
'Не удалось обновить токен, требуется новая авторизация', 'Не удалось обновить токен, требуется новая авторизация',
); );
const newSession = await authenticateWithElyBy( // Очищаем недействительные токены
config.username, saveConfig({
config.password, accessToken: '',
saveConfig, clientToken: '',
); });
if (!newSession) {
console.log('Авторизация не удалась'); // Пытаемся выполнить новую авторизацию
if (config.password) {
const newSession = await authenticateWithElyBy(
config.username,
config.password,
saveConfig,
);
if (!newSession) {
console.log('Авторизация не удалась');
return;
}
} else {
console.log('Требуется ввод пароля для новой авторизации');
return; return;
} }
} }
@ -53,6 +65,13 @@ const Login = () => {
} }
} else { } else {
console.log('Токен отсутствует, выполняем авторизацию...'); console.log('Токен отсутствует, выполняем авторизацию...');
// Проверяем наличие пароля
if (!config.password) {
console.log('Ошибка: не указан пароль');
alert('Введите пароль!');
return;
}
const session = await authenticateWithElyBy( const session = await authenticateWithElyBy(
config.username, config.username,
config.password, config.password,
@ -68,6 +87,11 @@ const Login = () => {
navigate('/'); navigate('/');
} catch (error) { } catch (error) {
console.log(`ОШИБКА при авторизации: ${error.message}`); console.log(`ОШИБКА при авторизации: ${error.message}`);
// Очищаем недействительные токены при ошибке
saveConfig({
accessToken: '',
clientToken: '',
});
} }
}; };

View File

@ -9,8 +9,15 @@ import {
CardActions, CardActions,
Button, Button,
CircularProgress, CircularProgress,
Modal,
List,
ListItem,
ListItemText,
IconButton,
} from '@mui/material'; } from '@mui/material';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import AddIcon from '@mui/icons-material/Add';
import DownloadIcon from '@mui/icons-material/Download';
interface VersionCardProps { interface VersionCardProps {
id: string; id: string;
@ -32,51 +39,30 @@ const VersionCard: React.FC<VersionCardProps> = ({
sx={{ sx={{
backgroundColor: 'rgba(30, 30, 50, 0.8)', backgroundColor: 'rgba(30, 30, 50, 0.8)',
backdropFilter: 'blur(10px)', backdropFilter: 'blur(10px)',
width: '100%', width: '35vw',
height: '100%', height: '35vh',
minWidth: 'unset',
minHeight: 'unset',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
borderRadius: '16px', borderRadius: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)', boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
transition: 'transform 0.3s, box-shadow 0.3s', transition: 'transform 0.3s, box-shadow 0.3s',
overflow: 'hidden', overflow: 'hidden',
'&:hover': { cursor: 'pointer',
transform: 'translateY(-8px)',
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.5)',
},
}} }}
onClick={() => onSelect(id)}
> >
<Box sx={{ position: 'relative', overflow: 'hidden', width: '100%' }}> <CardContent
<CardMedia sx={{
component="img" flexGrow: 1,
height="180" display: 'flex',
image={'https://placehold.co/300x140?text=' + name} flexDirection: 'column',
alt={name} alignItems: 'center',
sx={{ justifyContent: 'center',
transition: 'transform 0.5s', height: '10%',
'&:hover': { }}
transform: 'scale(1.05)', >
},
}}
/>
<Box
sx={{
position: 'absolute',
top: 0,
right: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
padding: '4px 12px',
borderBottomLeftRadius: '12px',
}}
>
<Typography variant="caption" fontWeight="bold">
{version}
</Typography>
</Box>
</Box>
<CardContent sx={{ flexGrow: 1, padding: '16px' }}>
<Typography <Typography
gutterBottom gutterBottom
variant="h5" variant="h5"
@ -89,37 +75,7 @@ const VersionCard: React.FC<VersionCardProps> = ({
> >
{name} {name}
</Typography> </Typography>
<Typography
variant="body2"
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginBottom: '12px',
}}
>
Версия: {version}
</Typography>
</CardContent> </CardContent>
<CardActions sx={{ padding: '0 16px 16px' }}>
<Button
fullWidth
variant="contained"
color="primary"
onClick={() => onSelect(id)}
sx={{
borderRadius: '12px',
textTransform: 'none',
fontWeight: 'bold',
padding: '10px 0',
background: 'linear-gradient(90deg, #3a7bd5, #6d5bf1)',
'&:hover': {
background: 'linear-gradient(90deg, #4a8be5, #7d6bf1)',
},
}}
>
Играть
</Button>
</CardActions>
</Card> </Card>
); );
}; };
@ -142,40 +98,62 @@ interface VersionInfo {
}; };
} }
interface AvailableVersionInfo {
id: string;
name: string;
version: string;
imageUrl?: string;
config: {
downloadUrl: string;
apiReleaseUrl: string;
versionFileName: string;
packName: string;
memory: number;
baseVersion: string;
serverIp: string;
fabricVersion: string;
preserveFiles: string[];
};
}
// В компоненте VersionsExplorer // В компоненте VersionsExplorer
export const VersionsExplorer = () => { export const VersionsExplorer = () => {
const [versions, setVersions] = useState<VersionInfo[]>([]); const [installedVersions, setInstalledVersions] = useState<VersionInfo[]>([]);
const [availableVersions, setAvailableVersions] = useState<
AvailableVersionInfo[]
>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [downloadLoading, setDownloadLoading] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const fetchVersions = async () => { const fetchVersions = async () => {
try { try {
const result = await window.electron.ipcRenderer.invoke( setLoading(true);
'get-available-versions',
// Получаем список установленных версий через IPC
const installedResult = await window.electron.ipcRenderer.invoke(
'get-installed-versions',
); );
if (result.success) { if (installedResult.success) {
// Для каждой версии получаем её конфигурацию setInstalledVersions(installedResult.versions);
const versionsWithConfig = await Promise.all( }
result.versions.map(async (version: VersionInfo) => {
const configResult = await window.electron.ipcRenderer.invoke(
'get-version-config',
{ versionId: version.id },
);
return { // Получаем доступные версии с GitHub Gist
...version, const availableResult = await window.electron.ipcRenderer.invoke(
config: configResult.success ? configResult.config : undefined, 'get-available-versions',
}; {
}), gistUrl:
); 'https://gist.githubusercontent.com/DIKER0K/06cd12fb3a4d08b1f0f8c763a7d05e06/raw/versions.json',
},
setVersions(versionsWithConfig); );
} else { if (availableResult.success) {
console.error('Ошибка получения версий:', result.error); setAvailableVersions(availableResult.versions);
} }
} catch (error) { } catch (error) {
console.error('Ошибка при запросе версий:', error); console.error('Ошибка при загрузке версий:', error);
// Можно добавить обработку ошибки, например показать уведомление
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -185,7 +163,6 @@ export const VersionsExplorer = () => {
}, []); }, []);
const handleSelectVersion = (version: VersionInfo) => { const handleSelectVersion = (version: VersionInfo) => {
// Сохраняем конфигурацию в localStorage для использования в LaunchPage
localStorage.setItem( localStorage.setItem(
'selected_version_config', 'selected_version_config',
JSON.stringify(version.config || {}), JSON.stringify(version.config || {}),
@ -193,45 +170,94 @@ export const VersionsExplorer = () => {
navigate(`/launch/${version.id}`); navigate(`/launch/${version.id}`);
}; };
// Тестовая версия, если нет доступных const handleAddVersion = () => {
const displayVersions = setModalOpen(true);
versions.length > 0 };
? versions
: [ const handleCloseModal = () => {
{ setModalOpen(false);
id: 'Comfort', };
name: 'Comfort',
version: '1.21.4-fabric0.16.14', const handleDownloadVersion = async (version: AvailableVersionInfo) => {
imageUrl: 'https://via.placeholder.com/300x140?text=Comfort', try {
config: { setDownloadLoading(version.id);
downloadUrl:
'https://github.com/DIKER0K/Comfort/releases/latest/download/Comfort.zip', // Скачивание и установка выбранной версии
apiReleaseUrl: const downloadResult = await window.electron.ipcRenderer.invoke(
'https://api.github.com/repos/DIKER0K/Comfort/releases/latest', 'download-and-extract',
versionFileName: 'comfort_version.txt', {
packName: 'Comfort', downloadUrl: version.config.downloadUrl,
memory: 4096, apiReleaseUrl: version.config.apiReleaseUrl,
baseVersion: '1.21.4', versionFileName: version.config.versionFileName,
serverIp: 'popa-popa.ru', packName: version.id,
fabricVersion: '0.16.14', preserveFiles: version.config.preserveFiles || [],
preserveFiles: ['popa-launcher-config.json'], },
}, );
},
]; if (downloadResult?.success) {
// Добавляем скачанную версию в список установленных
setInstalledVersions((prev) => [...prev, version]);
setModalOpen(false);
}
} catch (error) {
console.error(`Ошибка при скачивании версии ${version.id}:`, error);
} finally {
setDownloadLoading(null);
}
};
// Карточка добавления новой версии
const AddVersionCard = () => (
<Card
sx={{
backgroundColor: 'rgba(30, 30, 50, 0.8)',
backdropFilter: 'blur(10px)',
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',
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={handleAddVersion}
>
<AddIcon sx={{ fontSize: 60, color: '#fff' }} />
<Typography
variant="h6"
sx={{
color: '#fff',
}}
>
Добавить
</Typography>
<Typography
variant="h6"
sx={{
color: '#fff',
}}
>
версию
</Typography>
</Card>
);
return ( return (
<Box <Box
sx={{ sx={{
width: '100vw',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
paddingLeft: '5vw',
paddingRight: '5vw',
}} }}
> >
<Typography variant="h4" sx={{ mb: 4 }}>
Доступные версии
</Typography>
{loading ? ( {loading ? (
<Box display="flex" justifyContent="center" my={5}> <Box display="flex" justifyContent="center" my={5}>
<CircularProgress /> <CircularProgress />
@ -241,28 +267,128 @@ export const VersionsExplorer = () => {
container container
spacing={3} spacing={3}
sx={{ sx={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
width: '100%', width: '100%',
overflowY: 'auto',
justifyContent: 'center',
}} }}
> >
{displayVersions.map((version) => ( {/* Показываем установленные версии или дефолтную, если она есть */}
<Grid key={version.id} size={{ xs: 12, sm: 6, md: 4 }}> {installedVersions.length > 0 ? (
<VersionCard installedVersions.map((version) => (
id={version.id} <Grid
name={version.name} key={version.id}
imageUrl={ size={{ xs: 'auto', sm: 'auto', md: 'auto' }}
version.imageUrl || >
'https://via.placeholder.com/300x140?text=Minecraft' <VersionCard
} id={version.id}
version={version.version} name={version.name}
onSelect={() => handleSelectVersion(version)} imageUrl={
/> version.imageUrl ||
'https://via.placeholder.com/300x140?text=Minecraft'
}
version={version.version}
onSelect={() => handleSelectVersion(version)}
/>
</Grid>
))
) : (
// Если нет ни одной версии, показываем карточку добавления
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
<AddVersionCard />
</Grid> </Grid>
))} )}
{/* Всегда добавляем карточку для добавления новых версий */}
{installedVersions.length > 0 && (
<Grid size={{ xs: 'auto', sm: 'auto', md: 'auto' }}>
<AddVersionCard />
</Grid>
)}
</Grid> </Grid>
)} )}
{/* Модальное окно для выбора версии для скачивания */}
<Modal
open={modalOpen}
onClose={handleCloseModal}
aria-labelledby="modal-versions"
aria-describedby="modal-available-versions"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 400,
maxHeight: '80vh',
overflowY: 'auto',
background: 'linear-gradient(45deg, #000000 10%, #3b4187 184.73%)',
border: '2px solid #000',
boxShadow: 24,
p: 4,
borderRadius: '3vw',
gap: '1vh',
display: 'flex',
flexDirection: 'column',
backdropFilter: 'blur(10px)',
}}
>
<Typography variant="h6" component="h2" sx={{ color: '#fff' }}>
Доступные версии для скачивания
</Typography>
{availableVersions.length === 0 ? (
<Typography sx={{ color: '#fff', mt: 2 }}>
Загрузка доступных версий...
</Typography>
) : (
<List sx={{ mt: 2 }}>
{availableVersions.map((version) => (
<ListItem
key={version.id}
sx={{
borderRadius: '8px',
mb: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
}}
onClick={() => handleSelectVersion(version)}
>
<ListItemText
primary={version.name}
secondary={version.version}
primaryTypographyProps={{ color: '#fff' }}
secondaryTypographyProps={{
color: 'rgba(255,255,255,0.7)',
}}
/>
</ListItem>
))}
</List>
)}
<Button
onClick={handleCloseModal}
variant="outlined"
sx={{
mt: 3,
alignSelf: 'center',
borderColor: '#fff',
color: '#fff',
'&:hover': {
borderColor: '#ccc',
backgroundColor: 'rgba(255,255,255,0.1)',
},
}}
>
Закрыть
</Button>
</Box>
</Modal>
</Box> </Box>
); );
}; };