2026-08-01 21:43:14 +03:00

354 lines
12 KiB
TypeScript

import {AvailableMods} from "../core/api";
import React from "react";
import { NavLink } from "react-router-dom";
import { IMod } from "../types/Imod";
import {
Box,
Container,
Typography,
Grid,
Card,
CardContent,
CardActions,
Chip,
Button,
Divider,
useTheme,
Paper,
LinearProgress,
} from "@mui/material";
import { styled } from '@mui/material/styles';
// Styled components
const PageHeader = styled(Paper)(({ theme }) => ({
padding: theme.spacing(6, 4),
marginBottom: theme.spacing(6),
background: theme.palette.mode === 'dark'
? 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)'
: 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 50%, #e0e0e0 100%)',
borderRadius: '16px',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle at 30% 50%, rgba(255, 255, 255, 0.05) 0%, #dee2e6 50%)',
pointerEvents: 'none',
},
}));
const ModCard = styled(Card)(({ theme }) => ({
height: '100%',
display: 'flex',
flexDirection: 'column',
background: theme.palette.mode === 'dark'
? 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(145deg, #ffffff 0%, #f5f5f5 100%)',
border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: '16px',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: theme.palette.mode === 'dark'
? 'linear-gradient(90deg, #dee2e6 0%, #cccccc 100%)'
: 'linear-gradient(90deg, #000000 0%, #333333 100%)',
transform: 'scaleX(0)',
transition: 'transform 0.3s ease',
},
'&:hover': {
boxShadow: theme.palette.mode === 'dark' ? '0 20px 40px rgba(255, 255, 255, 0.1)' : '0 20px 40px rgba(0, 0, 0, 0.2)',
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
'&::before': {
transform: 'scaleX(1)',
},
},
}));
const VersionLink = styled(NavLink)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(1.5, 2),
marginBottom: theme.spacing(1),
background: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)',
borderRadius: '10px',
textDecoration: 'none',
color: 'inherit',
transition: 'all 0.2s ease',
border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
'&:hover': {
background: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
},
}));
const ModTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 1,
background: theme.palette.mode === 'dark'
? 'linear-gradient(135deg, #dee2e6 0%, #e0e0e0 100%)'
: 'none',
WebkitBackgroundClip: theme.palette.mode === 'dark' ? 'text' : 'initial',
WebkitTextFillColor: theme.palette.mode === 'dark' ? '#dee2e6' : 'initial',
color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
}));
const ModDescription = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
lineHeight: 1.6,
}));
const VersionsChip = styled(Chip)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
fontWeight: 600,
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
my: 2,
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}));
const MainVersionsLabel = styled(Typography)(({ theme }) => ({
color: theme.palette.text.primary,
fontWeight: 600,
mb: 1.5,
}));
const VersionText = styled(Typography)(({ theme }) => ({
fontWeight: 600,
color: theme.palette.text.secondary,
}));
const VersionArrow = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const BetaVersionsLabel = styled(Typography)(({ theme }) => ({
color: 'rgba(255, 193, 7, 0.9)',
fontWeight: 600,
mb: 1,
}));
const OpenLatestButton = styled(Button)(({ theme }) => {
const isDark = theme.palette.mode === 'dark';
return {
width: '100%',
color: isDark ? '#FFD700' : '#1976d2',
borderColor: isDark ? 'rgba(255, 215, 0, 0.5)' : 'rgba(25, 118, 210, 0.5)',
fontWeight: 600,
borderRadius: '10px',
textTransform: 'none',
fontSize: '1rem',
py: 1.5,
'&:hover': {
borderColor: isDark ? '#FFD700' : '#1976d2',
backgroundColor: isDark ? 'rgba(255, 215, 0, 0.1)' : 'rgba(25, 118, 210, 0.1)',
boxShadow: isDark ? '0 8px 20px rgba(255, 215, 0, 0.2)' : '0 8px 20px rgba(25, 118, 210, 0.2)',
},
};
});
interface ModsProps {
mods: IMod[];
}
function Mods({ mods }: ModsProps) {
const theme = useTheme();
// Sort all mods by order, then group by name
const sortedMods = [...mods].sort((a, b) => a.order - b.order);
const mapWithModVersions = new Map<String, IMod[]>();
sortedMods.forEach(mod => {
const versionList = mapWithModVersions.get(mod.name);
if (versionList == null) {
mapWithModVersions.set(mod.name, [mod]);
} else {
versionList.push(mod);
}
});
const getLatestVersion = (modName: String) => {
const sameMods = mapWithModVersions.get(modName) ?? [];
// The latest version is the one with the maximum id
const latest = sameMods.reduce((max, mod) => mod.id > max.id ? mod : max, sameMods[0]);
const betaMods = sameMods.filter(m => m.isBeta);
return {
latest: latest,
allVersions: sameMods,
hasBeta: betaMods.length > 0,
betaVersion: betaMods[0]
};
};
function ModCardComponent(modName: String, index: number) {
const sameMods = mapWithModVersions.get(modName) ?? [];
const { latest, hasBeta, betaVersion } = getLatestVersion(modName);
return (
<ModCard>
<CardContent sx={{ flexGrow: 1, p: 3 }}>
<Box sx={{ mb: 2 }}>
<ModTitle variant="h5">
{modName}
</ModTitle>
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<VersionsChip
label={`${sameMods.length} versions`}
size="small"
/>
</Box>
{sameMods.filter(m => !m.isBeta).map(mod => (
<VersionLink key={mod.id} to={"/mod/" + mod.id} state={mod.id}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<VersionText variant="body2">
Version {mod.version}
</VersionText>
<VersionArrow variant="caption">
</VersionArrow>
</Box>
</VersionLink>
))}
{hasBeta && (
<Box sx={{ mt: 2 }}>
<BetaVersionsLabel variant="subtitle2">
Beta versions:
</BetaVersionsLabel>
{betaVersion && (
<VersionLink to={"/mod/" + betaVersion.id} state={betaVersion.id}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
Version {betaVersion.version} (Beta)
</Typography>
<VersionArrow variant="caption">
</VersionArrow>
</Box>
</VersionLink>
)}
</Box>
)}
</CardContent>
<CardActions sx={{ px: 3, pb: 3 }}>
<Button
size="medium"
variant="outlined"
component={NavLink}
to={"/mod/" + latest?.id}
state={latest?.id}
sx={{
width: '100%',
color: theme.palette.mode === 'dark' ? '#FFD700' : '#1976d2',
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 215, 0, 0.5)' : 'rgba(25, 118, 210, 0.5)',
fontWeight: 600,
borderRadius: '10px',
textTransform: 'none',
fontSize: '1rem',
py: 1.5,
'&:hover': {
borderColor: theme.palette.mode === 'dark' ? '#FFD700' : '#1976d2',
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 215, 0, 0.1)' : 'rgba(25, 118, 210, 0.1)',
boxShadow: theme.palette.mode === 'dark' ? '0 8px 20px rgba(255, 215, 0, 0.2)' : '0 8px 20px rgba(25, 118, 210, 0.2)',
},
}}
>
Open latest {latest?.version ?? ''}
</Button>
</CardActions>
</ModCard>
);
}
return (
<Box>
<Grid container spacing={3}>
{[...new Set(sortedMods.map(m => m.name))].map((modName, index) => (
<Grid item xs={12} sm={6} md={4} >
{ModCardComponent(modName, index)}
</Grid>
))}
</Grid>
</Box>
);
}
interface ModsPageState {
mods: IMod[];
loading: boolean;
}
class ModsPage extends React.Component<any, ModsPageState> {
constructor({props}: { props: any }) {
super(props);
this.state = {
mods: [],
loading: true
};
}
async componentDidMount() {
// Очистка кэша сайта при заходе на главную страницу (один раз за сессию)
if (!sessionStorage.getItem('cache_cleared')) {
sessionStorage.setItem('cache_cleared', '1');
try {
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(names.map(name => caches.delete(name)));
}
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map(r => r.unregister()));
}
} catch (e) {
console.error('Cache clear failed:', e);
}
}
try {
const response = await fetch(AvailableMods);
const data: IMod[] = await response.json();
this.setState({
mods: data.filter(mod => !mod.isHide),
loading: false
});
} catch (error) {
console.error('Error loading mods:', error);
this.setState({ loading: false });
}
}
render() {
if (this.state.loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
);
}
return <Mods mods={this.state.mods} />;
}
}
export default ModsPage;