Moved to next js ssr (with fixes)

This commit is contained in:
anibus 2026-08-15 18:42:06 +03:00
parent 2184f1642c
commit 23bfad8956
21 changed files with 1474 additions and 1660 deletions

4
.gitignore vendored
View File

@ -11,7 +11,9 @@
# production
/build
/deploy
dow-wiki-deploy.tar.gz
tsconfig.tsbuildinfo
# misc
.DS_Store
.env.local

4
KODA.md Normal file
View File

@ -0,0 +1,4 @@
#### Сборка
При сборке дистрибутива формируй архив dow-wiki-deploy.tar.gz
Предварительно удали всё что было до этого в папке deploy и собирай туда

View File

@ -1,4 +1,5 @@
import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers';
import '@/src/index.css';
import '@/src/App.css';
import '@/src/css/Unit.css';
@ -49,8 +50,12 @@ export default function RootLayout({
}: {
children: React.ReactNode;
}) {
// Читаем тему из cookie на сервере, чтобы сразу отрендерить нужную тему без мерцания.
const themeCookie = cookies().get('theme')?.value;
const initialMode = themeCookie === 'light' ? 'light' : 'dark';
return (
<html lang="en">
<html lang="en" data-theme={initialMode} style={{ colorScheme: initialMode }}>
<body>
{/* Yandex.Metrika */}
<script
@ -69,7 +74,7 @@ export default function RootLayout({
/>
</div>
</noscript>
<ThemeProvider>
<ThemeProvider initialMode={initialMode}>
<AppShell>{children}</AppShell>
</ThemeProvider>
</body>

View File

@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import ModPageClient from '@/components/pages/ModPageClient';
import { getMod } from '@/lib/api-server';
import { getMod, getUnitsForMod, getBuildingsForMod } from '@/lib/api-server';
import { IRaceBuildings } from '@/src/types/IBuildingShort';
export const dynamic = 'force-dynamic';
@ -28,6 +29,15 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
export default function ModPage() {
return <ModPageClient />;
export default async function ModPage({ params }: Props) {
const [mod, racesUnits] = await Promise.all([
getMod(params.modId),
getUnitsForMod(params.modId),
]);
const units = racesUnits ?? [];
let racesBuildings: IRaceBuildings[] = [];
if (units.length <= 10) {
racesBuildings = (await getBuildingsForMod(params.modId)) ?? [];
}
return <ModPageClient initialMod={mod} racesUnits={units} racesBuildings={racesBuildings} />;
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import BuildingPageClient from '@/components/pages/BuildingPageClient';
import { getBuilding, getMod } from '@/lib/api-server';
import { IBuilding } from '@/src/types/IBuilding';
import { IMod } from '@/src/types/Imod';
export const dynamic = 'force-dynamic';
@ -32,6 +34,16 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
export default function BuildingPage() {
return <BuildingPageClient />;
export default async function BuildingPage({ params }: Props) {
const [mod, building] = await Promise.all([
getMod(params.modId),
getBuilding(params.buildingId),
]);
return (
<BuildingPageClient
key={params.buildingId}
initialBuilding={building as IBuilding | null}
initialMod={mod as IMod | null}
/>
);
}

View File

@ -1,6 +1,6 @@
import type { Metadata } from 'next';
import RacePageClient from '@/components/pages/RacePageClient';
import { getMod, getRace } from '@/lib/api-server';
import { getMod, getRace, getRaceUnits, getRaceBuildings } from '@/lib/api-server';
export const dynamic = 'force-dynamic';
@ -28,6 +28,19 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
export default function RacePage() {
return <RacePageClient />;
export default async function RacePage({ params }: Props) {
const [mod, race, raceUnits, raceBuildings] = await Promise.all([
getMod(params.modId),
getRace(params.raceId),
getRaceUnits(params.modId, params.raceId),
getRaceBuildings(params.modId, params.raceId),
]);
return (
<RacePageClient
initialMod={mod}
initialRace={race}
raceUnits={raceUnits}
raceBuildings={raceBuildings}
/>
);
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import UnitPageClient from '@/components/pages/UnitPageClient';
import { getMod, getUnit } from '@/lib/api-server';
import { IUnit } from '@/src/types/IUnit';
import { IMod } from '@/src/types/Imod';
export const dynamic = 'force-dynamic';
@ -30,6 +32,16 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
export default function UnitPage() {
return <UnitPageClient />;
export default async function UnitPage({ params }: Props) {
const [mod, unit] = await Promise.all([
getMod(params.modId),
getUnit(params.unitId),
]);
return (
<UnitPageClient
key={params.unitId}
initialUnit={unit as IUnit | null}
initialMod={mod as IMod | null}
/>
);
}

View File

@ -1,16 +1,358 @@
'use client';
import dynamic from 'next/dynamic';
import { Box, LinearProgress } from '@mui/material';
import React, { useEffect, useState } from 'react';
import {
Box,
Container,
Divider,
Grid2,
Link,
ListItem,
Paper,
Table,
TableBody,
TableCell,
TableRow,
Tooltip,
Typography,
LinearProgress,
Theme,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { useParams } from 'next/navigation';
import { ArrowBack } from '@mui/icons-material';
import AvTimerOutlinedIcon from '@mui/icons-material/AvTimer';
import { AvailableBuildings, AvailableMods, AvailableUnits, IconUrl } from '@/src/core/api';
import { IShortWeapon } from '@/src/types/IUnit';
import { IRaceUnits } from '@/src/types/IUnitShort';
import { IRaceBuildings } from '@/src/types/IBuildingShort';
import '@/src/css/Building.css';
import ArmorType from '@/src/classes/ArmorType';
import WeaponSlot from '@/src/classes/WeaponSlot';
import UnitsTable from '@/src/classes/UnitsTable';
import { IMod } from '@/src/types/Imod';
import { IBuilding } from '@/src/types/IBuilding';
import Vision from '@/src/classes/Vision';
import BuildingAddon from '@/src/classes/building/BuildingAddon';
import { IUnitShort } from '@/src/types/IUnitShort';
import Research, { AffectedResearches } from '@/src/classes/building/Research';
import Required from '@/src/classes/Required';
import { ModifiersProvidesTable } from '@/src/classes/ModifiersProvideTable';
import Ability from '@/src/classes/Ability';
import DeathExplosion from '@/src/classes/DeathExplosion';
import { DescriptionBox } from '@/src/commons/DescriptionBox';
import { BackButton } from '@/src/commons/BackButton';
const BuildingPageLegacy = dynamic(() => import('@/src/legacy-pages/BuildingPage'), {
ssr: false,
loading: () => (
const SectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 2,
fontSize: '1.25rem',
}));
const StatsPaper = styled(Paper)(({ theme }) => ({
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: '12px',
overflow: 'hidden',
'& .MuiTableBody .MuiTableRow-root .MuiTableCell-root': {
color: theme.palette.text.primary,
},
'& .MuiTableBody .MuiTableRow-root .MuiTableCell-head': {
color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
fontWeight: 600,
},
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
my: 3,
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}));
const BuildingTitle = styled(Typography)(({ theme }) => ({
fontWeight: 800,
mb: 1,
display: 'flex',
alignItems: 'center',
gap: 2,
}));
const BuildingSubtitle = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
fontWeight: 500,
}));
const UnitLink = styled(Link)(({ theme }) => ({
color: theme.palette.text.primary,
textDecoration: 'none',
'&:hover': { color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)' },
}));
const UnitListItem = styled(ListItem)(({ theme }) => ({
color: theme.palette.text.primary,
padding: '8px 12px',
background: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)',
borderRadius: '8px',
border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
transition: 'all 0.2s ease',
'&:hover': { background: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)', borderColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)' },
}));
function Unit(unit: IUnitShort, modId: number, raceId: string, theme: Theme) {
return (<Grid2 key={unit.id} size={{ xs: 12, md: 3 }}>
<UnitLink href={"/mod/" + modId + "/race/" + raceId + "/unit/" + unit.id}>
<UnitListItem>
{unit.icon && <img className="unitIcon" src={IconUrl + unit.icon.replaceAll('\\', '/')} />}
{unit.name}
{unit.canDetect && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/DETECT_YES.webp" /></span>}
</UnitListItem>
</UnitLink>
</Grid2>)
}
function Building(building: IBuilding, mod: IMod, theme: Theme, racesUnits: IRaceUnits[], racesBuildings: IRaceBuildings[]) {
const isDark = theme.palette.mode === 'dark';
let mapBuildingWeapons: Map<number, Map<number, IShortWeapon>> = new Map();
building.weapons.forEach(weapon => {
const weaponMap = mapBuildingWeapons.get(weapon.hardpoint)
if (weaponMap == null) {
const weaponMap = new Map()
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
mapBuildingWeapons.set(weapon.hardpoint, weaponMap)
} else {
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
}
})
var buildingName = building.name;
if (building.name == null) {
buildingName = building.filename.replaceAll('_', ' ').replace('.rgd', '');
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<BuildingTitle variant="h4">
{building.icon &&
<img className="unitIcon" src={IconUrl + building.icon.replaceAll('\\', '/')} />}
{buildingName}
</BuildingTitle>
<BuildingSubtitle variant="subtitle1">
{mod.name} ({mod.version})
</BuildingSubtitle>
</Box>
<Grid2 container spacing={3}>
<Grid2 size={{ xs: 12, md: 4 }}>
<StatsPaper elevation={0}>
<Table size="small" aria-label="a dense table">
<TableBody id="unit-stats-table">
<TableRow>
<TableCell component="th" scope="row" >Cost</TableCell>
<TableCell>
{building.buildCostRequisition > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{building.buildCostRequisition.toFixed(0)}</span>}
{building.buildCostPower > 0 && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{building.buildCostPower.toFixed(0)}</span>}
{(building.buildCostPopulation !== undefined && building.buildCostPopulation > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_orksquadcap.gif" />&nbsp;
{building.buildCostPopulation.toFixed(0)}</span>}
{(building.buildCostFaith !== undefined && building.buildCostFaith > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{building.buildCostFaith}</span>}
{(building.buildCostSouls !== undefined && building.buildCostSouls > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_souls.gif" />&nbsp;
{building.buildCostSouls.toFixed(0)}</span>}
{(building.buildCostTime !== undefined && building.buildCostTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{ verticalAlign: "top", fontSize: "18px" }} />&nbsp;
{building.buildCostTime}s</span>}
</TableCell>
</TableRow>
{(building.requisitionIncome !== undefined && building.requisitionIncome > 0 || building.powerIncome !== undefined && building.powerIncome !== null || building.faithIncome !== undefined && building.faithIncome !== null) &&
<TableRow>
<TableCell >Resource income</TableCell>
<TableCell>
{building.requisitionIncome !== undefined && building.requisitionIncome != null &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{building.requisitionIncome}</span>}
{building.powerIncome !== undefined && building.powerIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{building.powerIncome}</span>}
{building.faithIncome !== undefined && building.faithIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{building.faithIncome}</span>}
</TableCell>
</TableRow>
}
<TableRow>
<TableCell component="th" scope="row" >Armor type</TableCell>
<TableCell>
{building.armorType2 == null ? <ArmorType name={building.armorType.name} compact={false} />
: <Tooltip title={"upgrade/debuff can turns to " + building.armorType2.name}><span><ArmorType name={building.armorType.name} compact={false} /></span></Tooltip>}
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row" >Health</TableCell>
<TableCell>
<span><img style={{ verticalAlign: "top" }}
src="/images/Health_icon.webp" />&nbsp;
{building.health} {building.healthRegeneration > 0 &&
<span>+{building.healthRegeneration}/s</span>} </span>
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row" >Sight</TableCell>
<TableCell>
<Vision sight={building.sightRadius} detect={building.detectRadius} />
</TableCell>
</TableRow>
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell component="th" scope="row" >Repair max</TableCell>
<TableCell>{building.repairMax}</TableCell>
</TableRow>
</TableBody>
</Table>
</StatsPaper>
</Grid2>
<Grid2 size={{ xs: 12, md: 8 }}>
<DescriptionBox>
{building.description}
</DescriptionBox>
</Grid2>
{building.requirements !== null &&
<Grid2 size={{ xs: 12, md: 12 }}>
<Required requirement={building.requirements} modId={building.modId} raceId={building.race.id} />
</Grid2>}
{building.modifiers.length > 0 &&
<Grid2 size={{ xs: 12, md: 12 }}>
<SectionTitle>Affected on</SectionTitle>
<ModifiersProvidesTable modifiers={building.modifiers} race={building.race} modId={building.modId} affectedData={building.affectedData} />
</Grid2>}
{building.units.length > 0 && <Grid2 size={12}>
<SectionTitle>Unit production</SectionTitle>
<Grid2 container spacing={2}>
{building.units.map(unit =>
Unit(unit, mod.id, building.race.id, theme)
)}
</Grid2>
</Grid2>}
{building.abilities.length > 0 && <Grid2 size={12}>
<SectionTitle>Abilities</SectionTitle>
{building.abilities.map(a =>
<Ability mod={mod} ability={a} race={building.race} />
)}
</Grid2>}
{building.deathExplosions.length > 0 &&
<Grid2 size={12}>
{building.deathExplosions.map(da =>
<DeathExplosion deathExplosion={da} mod={mod} />
)}
</Grid2>
}
{building.addons.length > 0 && <Grid2 size={12}>
<SectionTitle>Addons</SectionTitle>
{building.addons.map(b =>
<BuildingAddon mod={mod} addon={b} building={building} />
)}
</Grid2>}
{building.researches.length > 0 && <Grid2 size={12}>
<SectionTitle>Researches</SectionTitle>
{building.researches.map(r =>
<Research key={r.id} research={r} building={building} mod={mod} />
)}
</Grid2>}
<Grid2 size={12}>
{[...mapBuildingWeapons.keys()].sort(function (a, b) {
return a - b;
}).map(h => <WeaponSlot race={building.race} mod={mod} unitWeapons={mapBuildingWeapons.get(h)} hardpoint={h} />)}
</Grid2>
<Grid2 size={12}>
<AffectedResearches researches={building.affectedResearches} modId={mod.id} raceId={building.race.id} />
</Grid2>
<b className="hotkey" >Hotkey: {building.hotkey} <span style={{ fontSize: '12px', fontWeight: 400, color: 'rgba(128,128,128,0.7)' }}>Filename: {building.filename}</span></b>
</Grid2>
<StyledDivider />
<UnitsTable modId={mod.id} racesUnits={racesUnits} racesBuildings={racesBuildings} />
</Box>)
}
export default function BuildingPageClient({
initialBuilding,
initialMod,
}: {
initialBuilding: IBuilding | null;
initialMod: IMod | null;
}) {
const params = useParams() as Record<string, string>;
const { modId, raceId, buildingId } = params;
const theme = useTheme();
const [building, setBuilding] = useState<IBuilding | null>(initialBuilding);
const [mod, setMod] = useState<IMod | null>(initialMod);
const [racesUnits, setRacesUnits] = useState<IRaceUnits[]>([]);
const [racesBuildings, setRacesBuildings] = useState<IRaceBuildings[]>([]);
useEffect(() => {
let cancelled = false;
if (building == null) {
fetch(AvailableBuildings + "/" + buildingId)
.then(res => res.json())
.then((res: IBuilding) => { if (!cancelled) setBuilding(res); });
}
if (mod == null) {
fetch(AvailableMods + "/" + modId)
.then(res => res.json())
.then((res: IMod) => { if (!cancelled) setMod(res); });
}
fetch(AvailableUnits + "/mod/" + modId)
.then(res => res.json())
.then((resUnits: IRaceUnits[]) => {
if (cancelled) return;
setRacesUnits(resUnits);
if (resUnits.length <= 10) {
fetch(AvailableBuildings + "/mod/" + modId)
.then(res => res.json())
.then((resBuildings: IRaceBuildings[]) => {
if (!cancelled) setRacesBuildings(resBuildings);
});
}
});
return () => { cancelled = true; };
}, [modId, raceId, buildingId]);
if (building != null && mod != null) {
const backRef = "/mod/" + modId + "/race/" + raceId;
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack />}
href={backRef}
>
Back to race
</BackButton>
{Building(building, mod, theme, racesUnits, racesBuildings)}
</Container>
);
} else {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
),
});
export default function BuildingPageClient() {
return <BuildingPageLegacy />;
);
}
}

View File

@ -1,16 +1,71 @@
'use client';
import dynamic from 'next/dynamic';
import { Box, LinearProgress } from '@mui/material';
import React from 'react';
import { ArrowBack } from '@mui/icons-material';
import {
Box,
Container,
Typography,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { IMod } from '@/src/types/Imod';
import { IRaceUnits } from '@/src/types/IUnitShort';
import { IRaceBuildings } from '@/src/types/IBuildingShort';
import UnitsTable from '@/src/classes/UnitsTable';
import { BackButton } from '@/src/commons/BackButton';
const ModPageLegacy = dynamic(() => import('@/src/legacy-pages/ModPage'), {
ssr: false,
loading: () => (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
),
});
const ModNotFound = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
export default function ModPageClient() {
return <ModPageLegacy />;
interface ModPageClientProps {
initialMod: IMod | null;
racesUnits: IRaceUnits[];
racesBuildings: IRaceBuildings[];
}
export default function ModPageClient({ initialMod, racesUnits, racesBuildings }: ModPageClientProps) {
const theme = useTheme();
if (initialMod != null) {
const isDark = theme.palette.mode === 'dark';
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack />}
href="/"
>
Back to mods list
</BackButton>
<Typography variant="h3" component="h1" sx={{
fontWeight: 800,
mb: 1,
WebkitBackgroundClip: isDark ? 'text' : 'initial',
WebkitTextFillColor: isDark ? '#dee2e6' : 'initial',
color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
}}>
{initialMod.name} <Box component="span" sx={{
fontSize: '0.5em',
color: theme.palette.text.secondary,
fontWeight: 500,
}}>({initialMod.version})</Box>
</Typography>
<Box>
<UnitsTable modId={initialMod.id} racesUnits={racesUnits} racesBuildings={racesBuildings} />
</Box>
</Container>
);
} else {
return (
<Container maxWidth="lg" sx={{ py: 8, textAlign: 'center' }}>
<ModNotFound variant="h5">
Mod not found
</ModNotFound>
</Container>
);
}
}

View File

@ -1,14 +1,248 @@
'use client';
import dynamic from 'next/dynamic';
import { Box, LinearProgress } from '@mui/material';
import React, { useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardContent,
CardActions,
Chip,
Button,
useTheme,
Link,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { IMod } from '@/src/types/Imod';
const ModsPageLegacy = dynamic(() => import('@/src/legacy-pages/ModsPage'));
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(Link)(({ 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 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 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,
}));
interface ModsPageClientProps {
initialMods: IMod[];
}
export default function ModsPageClient({ initialMods }: ModsPageClientProps) {
return <ModsPageLegacy initialMods={initialMods} />;
const theme = useTheme();
// Очистка кэша сайта при заходе на главную страницу (один раз за сессию)
useEffect(() => {
if (!sessionStorage.getItem('cache_cleared')) {
sessionStorage.setItem('cache_cleared', '1');
(async () => {
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);
}
})();
}
}, []);
// Sort all mods by order, then group by name
const sortedMods = [...initialMods].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,
allVersions: sameMods,
hasBeta: betaMods.length > 0,
betaVersion: betaMods[0],
};
};
function ModCardComponent(modName: string) {
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} href={"/mod/" + 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 href={"/mod/" + 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"
href={"/mod/" + 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) => (
<Grid item xs={12} sm={6} md={4}>
{ModCardComponent(modName)}
</Grid>
))}
</Grid>
</Box>
);
}

View File

@ -1,16 +1,262 @@
'use client';
import dynamic from 'next/dynamic';
import { Box, LinearProgress } from '@mui/material';
import React from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Container,
Divider,
Grid2,
Link,
List,
ListItem,
Paper,
Typography,
Theme,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { ArrowBack, ExpandMore } from '@mui/icons-material';
import { IconUrl } from '@/src/core/api';
import { IMod } from '@/src/types/Imod';
import { Irace } from '@/src/types/Irace';
import '@/src/css/Unit.css';
import { IRaceUnits, IUnitShort } from '@/src/types/IUnitShort';
import { IBuildingShort, IRaceBuildings } from '@/src/types/IBuildingShort';
import { StyledLink } from '@/src/commons/StyledLink';
import { BackButton } from '@/src/commons/BackButton';
const RacePageLegacy = dynamic(() => import('@/src/legacy-pages/RacePageFast'), {
ssr: false,
loading: () => (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
),
});
const SectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 2,
fontSize: '1.25rem',
}));
export default function RacePageClient() {
return <RacePageLegacy />;
const UnitCard = styled(Paper)(({ theme }) => ({
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)',
'&: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)',
},
}));
const BuildingCard = styled(UnitCard)(({ theme }) => ({
padding: theme.spacing(1),
}));
const UnitLink = styled(Link)(({ theme }) => ({
color: theme.palette.text.primary,
textDecoration: 'none',
display: 'block',
'&:hover': { color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)' }
}));
const LoadingText = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
function Unit(unit: IUnitShort, modId: number, raceId: string, theme: Theme) { const isDark = theme.palette.mode === 'dark';
return (
<UnitLink href={"/mod/" + modId + "/race/" + raceId + "/unit/" + unit.id}>
<ListItem sx={{
color: theme.palette.text.primary,
padding: '1.5rem 2rem',
marginBottom: '0.5rem',
background: isDark
? 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(145deg, #ffffff 0%, #f5f5f5 100%)',
borderRadius: '16px',
border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
boxShadow: isDark ? '0 20px 40px rgba(255, 255, 255, 0.1)' : '0 20px 40px rgba(0, 0, 0, 0.2)',
borderColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
}
}}>
{unit.icon && <img className="unitIcon" src={IconUrl + unit.icon.replaceAll('\\', '/')} />}
{unit.name}
{unit.canDetect && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/DETECT_YES.webp" /></span>}
</ListItem>
</UnitLink>
)
}
function UnitSmall(unit: IUnitShort, modId: number, raceId: string) {
var unitName = ""
if (unit.name.length > 21) {
unitName = unit.name.substring(0, 19) + "...";
} else {
unitName = unit.name;
}
return (<StyledLink href={"/mod/" + modId + "/race/" + raceId + "/unit/" + unit.id} >
{unit.icon && <img className="unitIconSmall" src={IconUrl + unit.icon.replaceAll('\\', '/')} />}
&nbsp;<span style={{ fontSize: 14 }}>{unitName}</span>
{unit.canDetect && <span>&nbsp;<img
src="/images/DETECT_YES.webp" /></span>}<br /></StyledLink>)
}
function Building(building: IBuildingShort, modId: number, raceId: string, theme: Theme) {
const isDark = theme.palette.mode === 'dark';
return (<Grid2 size={{ xs: 12, md: 3 }}>
<Link href={"/mod/" + modId + "/race/" + raceId + "/building/" + building.id} sx={{
textDecoration: 'none',
display: 'block',
'&:hover': { color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)' }
}}>
<BuildingCard elevation={0}>
<ListItem sx={{}}>
{building.icon && <img className="unitIcon" src={IconUrl + building.icon.replaceAll('\\', '/')} />}
&nbsp;<StyledLink>{building.name}</StyledLink>
{building.canDetect && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/DETECT_YES.webp" /></span>}
</ListItem>
{building.units.map(unit => UnitSmall(unit, modId, raceId))}
</BuildingCard>
</Link>
</Grid2>)
}
interface UnitsProps {
raceId: string;
modId: number;
units: IRaceUnits | null;
buildings: IRaceBuildings | null;
}
function Units({ raceId, modId, units, buildings }: UnitsProps) {
const theme = useTheme();
const isDark = theme.palette.mode === 'dark';
if (units) {
const accordionSx = isDark ? {
background: 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '12px',
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: '0 0 8px 0' },
} : {
background: 'linear-gradient(145deg, #ffffff 0%, #f5f5f5 100%)',
border: '1px solid rgba(0, 0, 0, 0.1)',
borderRadius: '12px',
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: '0 0 8px 0' },
};
return (buildings != null ?
<Box>
<Grid2 container spacing={2}>
{buildings.buildings.map(building => Building(building, modId, raceId, theme))}
</Grid2>
{buildings.buildingsAdvanced.length > 0 && <Box sx={{ mt: 3 }}>
<Divider sx={{ my: 2, borderColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)' }} />
<SectionTitle>Advanced buildings</SectionTitle>
<Grid2 container spacing={2}>
{buildings.buildingsAdvanced.map(building => Building(building, modId, raceId, theme))}
</Grid2><br /></Box>}
<Box sx={{ mt: 3 }}>
<Accordion sx={accordionSx}>
<AccordionSummary
expandIcon={<ExpandMore sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)' }} />}
aria-controls="units-accordion"
id="units-accordion"
sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', '& .MuiAccordionSummary-content': { margin: '12px 0' }, '& .MuiAccordionSummary-content.Mui-expanded': { margin: '12px 0' } }}
>
<SectionTitle sx={{ mb: 0, display: 'flex', alignItems: 'center' }}>All units</SectionTitle>
</AccordionSummary>
<AccordionDetails>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, md: 4 }}>
<Typography variant="h6" sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', fontWeight: 600, mb: 2 }}>Infantry</Typography>
{units.infantry.map(unit => Unit(unit, modId, raceId, theme))}
</Grid2>
<Grid2 size={{ xs: 12, md: 4 }}>
<Typography variant="h6" sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', fontWeight: 600, mb: 2 }}>Tech</Typography>
<List>
{units.tech.map(unit => Unit(unit, modId, raceId, theme))}
</List>
</Grid2>
<Grid2 size={{ xs: 12, md: 4 }}>
<Typography variant="h6" sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', fontWeight: 600, mb: 2 }}>Support</Typography>
<List>
{units.support.map(unit => Unit(unit, modId, raceId, theme))}
</List>
</Grid2>
</Grid2>
</AccordionDetails>
</Accordion>
</Box>
</Box> : <LoadingText>Loading</LoadingText>
)
} else {
return <LoadingText>Loading...</LoadingText>;
}
}
interface RacePageClientProps {
initialMod: IMod | null;
initialRace: Irace | null;
raceUnits: IRaceUnits | null;
raceBuildings: IRaceBuildings | null;
}
export default function RacePageClient({ initialMod, initialRace, raceUnits, raceBuildings }: RacePageClientProps) {
const theme = useTheme();
if (initialMod != null && initialRace != null) {
const backRef = "/mod/" + initialMod.id;
const isDark = theme.palette.mode === 'dark';
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack />}
href={backRef}
>
Back to mod
</BackButton>
<Box sx={{ mb: 4 }}>
<Typography variant="h3" component="h1" sx={{
fontWeight: 800,
mb: 1,
background: isDark
? 'linear-gradient(135deg, #dee2e6 0%, #e0e0e0 100%)'
: 'none',
WebkitBackgroundClip: isDark ? 'text' : 'initial',
WebkitTextFillColor: isDark ? '#dee2e6' : 'initial',
color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
}}>
{initialRace.name}
</Typography>
<Typography variant="h6" sx={{
color: theme.palette.text.secondary,
fontWeight: 500,
}}>
{initialMod.name} ({initialMod.version})
</Typography>
</Box>
<Units raceId={initialRace.id} modId={initialMod.id} units={raceUnits} buildings={raceBuildings} />
</Container>
);
} else {
return (
<Container maxWidth="lg">
<LoadingText variant="h5" sx={{ py: 8, textAlign: 'center' }}>
Race not found
</LoadingText>
</Container>
);
}
}

View File

@ -1,16 +1,443 @@
'use client';
import dynamic from 'next/dynamic';
import { Box, LinearProgress } from '@mui/material';
import React, { useEffect, useState } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Container,
Divider,
Grid2,
LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableRow,
Tooltip,
Typography,
Theme,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { useParams } from 'next/navigation';
import { ArrowBack, ExpandMore } from '@mui/icons-material';
import AvTimerOutlinedIcon from '@mui/icons-material/AvTimer';
import { AvailableBuildings, AvailableMods, AvailableUnits, IconUrl } from '@/src/core/api';
import { IShortWeapon, IUnit } from '@/src/types/IUnit';
import { IRaceUnits } from '@/src/types/IUnitShort';
import { IRaceBuildings } from '@/src/types/IBuildingShort';
import '@/src/css/Unit.css';
import ArmorType from '@/src/classes/ArmorType';
import Sergeant from '@/src/classes/Sergeant';
import WeaponSlot from '@/src/classes/WeaponSlot';
import UnitsTable from '@/src/classes/UnitsTable';
import { IMod } from '@/src/types/Imod';
import Vision from '@/src/classes/Vision';
import Required from '@/src/classes/Required';
import { AffectedResearches } from '@/src/classes/building/Research';
import { ModifiersProvidesTable } from '@/src/classes/ModifiersProvideTable';
import Ability from '@/src/classes/Ability';
import DeathExplosion from '@/src/classes/DeathExplosion';
import Jump from '@/src/classes/Jump';
import { DescriptionBox } from '@/src/commons/DescriptionBox';
import { BackButton } from '@/src/commons/BackButton';
const UnitPageLegacy = dynamic(() => import('@/src/legacy-pages/UnitPage'), {
ssr: false,
loading: () => (
const SectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 2,
fontSize: '1.25rem',
}));
const StatsPaper = styled(Paper)(({ theme }) => ({
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: '12px',
overflow: 'hidden',
'& .MuiTableBody .MuiTableRow-root': {
'&:last-child td, &:last-child th': { border: 0 },
},
'& .MuiTableBody .MuiTableRow-root .MuiTableCell-head': {
color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
fontWeight: 600,
},
}));
const SergeantAccordion = styled(Accordion)(({ theme }) => ({
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: '12px',
mb: 1,
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: '0 0 8px 0' },
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
my: 3,
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}));
const UnitTitle = styled(Typography)(({ theme }) => ({
fontWeight: 800,
mb: 1,
display: 'flex',
alignItems: 'center',
gap: 2,
}));
const UnitSubtitle = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
fontWeight: 500,
}));
function Unit(unit: IUnit, mod: IMod, theme: Theme, racesUnits: IRaceUnits[], racesBuildings: IRaceBuildings[]) {
const isDark = theme.palette.mode === 'dark';
const morale = (unit.moraleMax !== null) ? <span>
<img style={{ verticalAlign: "top" }} src="/images/ARM_Morale.webp" />&nbsp;{unit.moraleMax}
+{unit.moraleRegeneration}/s
{unit.moraleDeathPenalty > 0 && <span> <img style={{ verticalAlign: "top" }}
src="/images/Kills_icon.webp" /> -{unit.moraleDeathPenalty}</span>}
</span> : "-"
let mapWithUnitWeapons: Map<number, Map<number, IShortWeapon>> = new Map();
unit.weapons.forEach(weapon => {
const weaponMap = mapWithUnitWeapons.get(weapon.hardpoint)
if (weaponMap == null) {
const weaponMap = new Map()
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
mapWithUnitWeapons.set(weapon.hardpoint, weaponMap)
} else {
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
}
})
type sergeantProps = {
name: string
icon: String
canDetect: Boolean
}
const SergeantShort = (props: sergeantProps) => {
return (
<span style={{ fontSize: 20, color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', display: 'flex', alignItems: 'center' }}>
{props.icon && <img className="sergeantIcon" src={IconUrl + props.icon.replaceAll('\\', '/')} />}
&nbsp; {props.name}
{props.canDetect && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/DETECT_YES.webp" /></span>}
</span>
)
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<UnitTitle variant="h4">
{unit.icon &&
<img className="unitIcon" src={IconUrl + unit.icon.replaceAll('\\', '/')} />}
{unit.name}
</UnitTitle>
<UnitSubtitle variant="subtitle1">
{mod.name} ({mod.version})
</UnitSubtitle>
</Box>
<Grid2 container spacing={3}>
<Grid2 size={{ xs: 12, md: 4 }}>
<StatsPaper>
<Table size="small" aria-label="a dense table">
<TableBody id="unit-stats-table">
<TableRow>
<TableCell component="th" scope="row" >Cost</TableCell>
<TableCell>
{unit.buildCostRequisition > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{unit.buildCostRequisition.toFixed(0)}</span>}
{unit.buildCostPower > 0 && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{unit.buildCostPower.toFixed(0)}</span>}
{(unit.buildCostPopulation !== undefined && unit.buildCostPopulation > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_orksquadcap.gif" />&nbsp;
{unit.buildCostPopulation.toFixed(0)}</span>}
{(unit.buildCostFaith !== undefined && unit.buildCostFaith > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{unit.buildCostFaith}</span>}
{(unit.buildCostSouls !== undefined && unit.buildCostSouls > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_souls.gif" />&nbsp;
{unit.buildCostSouls.toFixed(0)}</span>}
{unit.capInfantry > 0 && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_cap_infantry.gif" />&nbsp;
{unit.capInfantry}</span>}
{unit.capSupport > 0 && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_cap_vehicle.gif" />&nbsp;
{unit.capSupport}</span>}
{(unit.buildCostTime !== undefined && unit.buildCostTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{ verticalAlign: "top", fontSize: "18px" }} />&nbsp;
{unit.buildCostTime}s</span>}
</TableCell>
</TableRow>
{(unit?.reinforceTime !== 0 && unit.reinforceTime !== null && unit.squadMaxSize > 1) &&
<TableRow>
<TableCell>Reinforce cost</TableCell>
<TableCell>
{unit.reinforceCostRequisition && unit.reinforceCostRequisition > 0 ?
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{unit.reinforceCostRequisition.toFixed(0)}</span> : <span />}
{unit.reinforceCostPower !== undefined && unit.reinforceCostPower > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{unit.reinforceCostPower.toFixed(0)}</span>}
{(unit.reinforceCostPopulation !== undefined && unit.reinforceCostPopulation > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_orksquadcap.gif" />&nbsp;
{unit.reinforceCostPopulation.toFixed(0)}</span>}
{(unit.reinforceCostFaith !== undefined && unit.reinforceCostFaith > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{unit.reinforceCostFaith}</span>}
{(unit.reinforceCostSouls !== undefined && unit.reinforceCostSouls > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_souls.gif" />&nbsp;
{unit.reinforceCostSouls.toFixed(0)}</span>}
{(unit.reinforceTime !== undefined && unit.reinforceTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{ verticalAlign: "top", fontSize: "18px" }} />&nbsp;
{unit.reinforceTime}s</span>}
</TableCell>
</TableRow>
}
{(unit.requisitionIncome !== undefined && unit.requisitionIncome !== null || unit.powerIncome !== undefined && unit.powerIncome !== null || unit.faithIncome !== undefined && unit.faithIncome !== null) &&
<TableRow>
<TableCell >Resource income</TableCell>
<TableCell>
{unit.requisitionIncome !== undefined && unit.requisitionIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{unit.requisitionIncome}</span>}
{unit.powerIncome !== undefined && unit.powerIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{unit.powerIncome}</span>}
{unit.faithIncome !== undefined && unit.faithIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{unit.faithIncome}</span>}
</TableCell>
</TableRow>
}
{unit.squadMaxSize > 1 &&
<TableRow>
<TableCell >Squad size</TableCell>
<TableCell>{unit.squadStartSize} / {unit.squadMaxSize}</TableCell>
</TableRow>
}
<TableRow>
<TableCell >Armor type</TableCell>
<TableCell>
{unit.armorType2 == null ? <ArmorType name={unit.armorType.name} compact={false} /> : <Tooltip title={"upgrade/debuff can turns to " + unit.armorType2.name}><span><ArmorType name={unit.armorType.name} compact={false} /></span></Tooltip>}
</TableCell>
</TableRow>
<TableRow>
<TableCell >Health</TableCell>
<TableCell>
<img style={{ verticalAlign: "top" }}
src="/images/Health_icon.webp" />&nbsp;
{unit.health} {unit.healthRegeneration > 0 &&
<span>+{unit.healthRegeneration}/s</span>}
{unit.armour !== undefined && unit.armour !== 0 && <span><img style={{ height: 20, verticalAlign: "top" }} src="/images/defence.png" />{unit.armour} </span>}
</TableCell>
</TableRow>
<TableRow>
<TableCell >Move speed</TableCell>
<TableCell>{unit.moveSpeed}</TableCell>
</TableRow>
<TableRow>
<TableCell >Morale</TableCell>
<TableCell>{morale}</TableCell>
</TableRow>
<TableRow>
<TableCell >Mass</TableCell>
<TableCell>{unit.mass}</TableCell>
</TableRow>
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Vision</TableCell>
<TableCell><Vision sight={unit.sightRadius} detect={unit.detectRadius} /></TableCell>
</TableRow>
{unit.repairMax !== undefined && unit.repairMax !== null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Repair max</TableCell>
<TableCell>{unit.repairMax}</TableCell>
</TableRow>
}
{unit.repairSpeed !== undefined && unit.repairSpeed !== null && unit.repairCostPercent !== null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Repair</TableCell>
<TableCell>{unit.repairSpeed} hp/s; {unit.repairCostPercent}% cost</TableCell>
</TableRow>
}
{unit.mobValue != null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Mob value</TableCell>
<TableCell><span> <img style={{ height: 20, verticalAlign: "top" }} src="/images/Mob_bonus.gif" /> {unit.mobValue} </span></TableCell>
</TableRow>
}
{unit.squadLimit !== undefined && unit.squadLimit !== null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Limit</TableCell>
<TableCell>{unit.squadLimit}</TableCell>
</TableRow>
}
</TableBody>
</Table>
</StatsPaper>
</Grid2>
<Grid2 size={{ xs: 12, md: 8 }}>
<DescriptionBox>
{unit.description}
</DescriptionBox>
</Grid2>
{unit.requirements !== null &&
<Grid2 size={{ xs: 12, md: 12 }}>
<Required requirement={unit.requirements} modId={unit.modId} raceId={unit.race.id} />
</Grid2>}
<Grid2 size={12}>
{unit.sergeants.map(s =>
<SergeantAccordion key={s.id} TransitionProps={{ unmountOnExit: true, timeout: 100 }}>
<AccordionSummary
sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', '& .MuiAccordionSummary-content': { margin: '12px 0' }, '& .MuiAccordionSummary-content.Mui-expanded': { margin: '12px 0' } }}>
<SergeantShort name={s.name} icon={s.icon} canDetect={s.detectRadius > 0} />
</AccordionSummary>
<AccordionDetails sx={{ color: theme.palette.text.primary }}>
<Sergeant mod={mod} sergeant={s} race={unit.race} />
</AccordionDetails>
</SergeantAccordion>)}
</Grid2>
{unit.moraleMax !== null && !(unit.moraleBrakeModifiers.find(m => m.reference.includes("accuracy_weapon_modifier") && m.value === 0.2) !== undefined &&
unit.moraleBrakeModifiers.find(m => m.reference.includes("speed_maximum_modifier") && m.value === 1.2) !== undefined &&
unit.moraleBrakeModifiers.length === 2)
&& <Grid2 size={12}>
<SectionTitle><img style={{ verticalAlign: "top" }} src="/images/ARM_Morale.webp" /> Morale broken <img style={{ verticalAlign: "top" }} src="/images/ARM_Morale.webp" /></SectionTitle>
<ModifiersProvidesTable modifiers={unit.moraleBrakeModifiers} modId={unit.modId} race={unit.race} affectedData={unit.affectedData} />
</Grid2>}
{unit.modifiers.length > 0 && <Grid2 size={12}>
<SectionTitle>Affected on</SectionTitle>
<ModifiersProvidesTable modifiers={unit.modifiers} modId={unit.modId} race={unit.race} affectedData={unit.affectedData} />
</Grid2>}
{unit.abilities.length > 0 && <Grid2 size={12}>
<SectionTitle>Abilities</SectionTitle>
{unit.abilities.map(a =>
<Ability mod={mod} ability={a} race={unit.race} />
)}
</Grid2>}
{unit.jumps != null && <Grid2 size={12}>
<Jump jumps={unit.jumps} race={unit.race} mod={mod} />
</Grid2>}
<Grid2 size={12}>
{[...mapWithUnitWeapons.keys()].sort(function (a, b) {
return a - b;
}).map(h => <WeaponSlot haveReinforceMenu={unit.haveReinforceMenu} key={h} race={unit.race} mod={mod} unitWeapons={mapWithUnitWeapons.get(h)} hardpoint={h} />)}
</Grid2>
{unit.deathExplosions.length > 0 &&
<Grid2 size={12}>
{unit.deathExplosions.map(da =>
<DeathExplosion deathExplosion={da} mod={mod} />
)}
</Grid2>
}
<Grid2 size={12}>
<AffectedResearches researches={unit.affectedResearches} modId={mod.id} raceId={unit.race.id} />
</Grid2>
</Grid2>
<Box sx={{ mt: 3, mb: 2, fontWeight: 600, fontSize: '0.85rem' }}>
Hotkey: {unit.hotkey} <span style={{ fontSize: '12px', fontWeight: 400, color: 'rgba(128,128,128,0.7)' }}>Filename: {unit.filename}</span>
</Box>
<StyledDivider />
<UnitsTable modId={mod.id} racesUnits={racesUnits} racesBuildings={racesBuildings} />
</Box>
)
}
export default function UnitPageClient({
initialUnit,
initialMod,
}: {
initialUnit: IUnit | null;
initialMod: IMod | null;
}) {
const params = useParams() as Record<string, string>;
const { modId, raceId, unitId } = params;
const theme = useTheme();
const [unit, setUnit] = useState<IUnit | null>(initialUnit);
const [mod, setMod] = useState<IMod | null>(initialMod);
const [racesUnits, setRacesUnits] = useState<IRaceUnits[]>([]);
const [racesBuildings, setRacesBuildings] = useState<IRaceBuildings[]>([]);
useEffect(() => {
let cancelled = false;
if (unit == null) {
fetch(AvailableUnits + "/" + unitId)
.then(res => res.json())
.then((res: IUnit) => { if (!cancelled) setUnit(res); });
}
if (mod == null) {
fetch(AvailableMods + "/" + modId)
.then(res => res.json())
.then((res: IMod) => { if (!cancelled) setMod(res); });
}
fetch(AvailableUnits + "/mod/" + modId)
.then(res => res.json())
.then((resUnits: IRaceUnits[]) => {
if (cancelled) return;
setRacesUnits(resUnits);
if (resUnits.length <= 10) {
fetch(AvailableBuildings + "/mod/" + modId)
.then(res => res.json())
.then((resBuildings: IRaceBuildings[]) => {
if (!cancelled) setRacesBuildings(resBuildings);
});
}
});
return () => { cancelled = true; };
}, [modId, raceId, unitId]);
if (unit != null && mod != null) {
const backRef = "/mod/" + modId + "/race/" + raceId;
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack />}
href={backRef}
>
Back to race
</BackButton>
{Unit(unit, mod, theme, racesUnits, racesBuildings)}
</Container>
);
} else {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
),
});
export default function UnitPageClient() {
return <UnitPageLegacy />;
);
}
}

View File

@ -2,6 +2,11 @@
// Runs only on the server (Node.js) — uses NEXT_PUBLIC_HOST_URL which is inlined at build time.
import { IMod } from '@/src/types/Imod';
import { Irace } from '@/src/types/Irace';
import { IRaceUnits } from '@/src/types/IUnitShort';
import { IRaceBuildings } from '@/src/types/IBuildingShort';
import { IUnit } from '@/src/types/IUnit';
import { IBuilding } from '@/src/types/IBuilding';
const API = process.env.NEXT_PUBLIC_HOST_URL || '';
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://dow-wiki.example.com';
@ -17,19 +22,19 @@ async function fetchJson<T>(url: string): Promise<T | null> {
}
export async function getMod(modId: string) {
return fetchJson<{ name: string; version: string }>(`${API}/api/v1/mods/${modId}`);
return fetchJson<IMod>(`${API}/api/v1/mods/${modId}`);
}
export async function getRace(raceId: string) {
return fetchJson<{ name: string }>(`${API}/api/v1/races/${raceId}`);
return fetchJson<Irace>(`${API}/api/v1/races/${raceId}`);
}
export async function getUnit(unitId: string) {
return fetchJson<{ name: string; description: string }>(`${API}/api/v1/units/${unitId}`);
return fetchJson<IUnit>(`${API}/api/v1/units/${unitId}`);
}
export async function getBuilding(buildingId: string) {
return fetchJson<{ name: string; filename: string }>(`${API}/api/v1/buildings/${buildingId}`);
return fetchJson<IBuilding>(`${API}/api/v1/buildings/${buildingId}`);
}
export async function getMods() {
@ -41,9 +46,17 @@ export async function getRacesForMod(modId: string | number) {
}
export async function getUnitsForMod(modId: string | number) {
return fetchJson<Array<{ race: { id: string }; infantry: Array<{ id: number }>; support: Array<{ id: number }>; tech: Array<{ id: number }> }>>(`${API}/api/v1/units/mod/${modId}`);
return fetchJson<IRaceUnits[]>(`${API}/api/v1/units/mod/${modId}`);
}
export async function getBuildingsForMod(modId: string | number) {
return fetchJson<Array<{ race: { id: string }; buildings: Array<{ id: number }>; buildingsAdvanced: Array<{ id: number }> }>>(`${API}/api/v1/buildings/mod/${modId}`);
return fetchJson<IRaceBuildings[]>(`${API}/api/v1/buildings/mod/${modId}`);
}
export async function getRaceUnits(modId: string | number, raceId: string) {
return fetchJson<IRaceUnits>(`${API}/api/v1/units/${modId}/${raceId}`);
}
export async function getRaceBuildings(modId: string | number, raceId: string) {
return fetchJson<IRaceBuildings>(`${API}/api/v1/buildings/${modId}/${raceId}`);
}

View File

@ -1,6 +1,5 @@
import React, {useEffect, useState} from "react";
import {AvailableBuildings, AvailableUnits} from "../core/api";
import {Grid2, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper} from "@mui/material";
import React from "react";
import {Grid2, Table, TableBody, TableCell, TableHead, TableRow} from "@mui/material";
import {styled} from '@mui/material/styles';
import {IRaceUnits, IUnitShort} from "../types/IUnitShort";
import {IBuildingShort, IRaceBuildings} from "../types/IBuildingShort";
@ -37,13 +36,13 @@ interface IUnitsTable {
racesBuildings: IRaceBuildings[]
}
export default function UnitsTable(prop: {modId: number}) {
export default function UnitsTable(prop: {modId: number, racesUnits: IRaceUnits[], racesBuildings: IRaceBuildings[]}) {
const [unitsTable, setUnitsTable] = useState<IUnitsTable>({
racesUnits: [],
racesBuildings: []
});
const unitsTable: IUnitsTable = {
racesUnits: prop.racesUnits,
racesBuildings: prop.racesBuildings
};
function getUnitRef(modId: number, raceId: string, unit: IUnitShort) {
@ -62,29 +61,6 @@ export default function UnitsTable(prop: {modId: number}) {
</span>
}
useEffect(() => {
fetch(AvailableUnits + "/mod/" + prop.modId)
.then(resUnits => resUnits.json())
.then((resUnits: IRaceUnits[]) => {
if(resUnits.length <= 10){
fetch(AvailableBuildings + "/mod/" + prop.modId)
.then(resBuildings => resBuildings.json())
.then((resBuildings: IRaceBuildings[]) => {
setUnitsTable({
racesUnits : resUnits,
racesBuildings: resBuildings
});
});
}else {
setUnitsTable({
racesUnits : resUnits,
racesBuildings: []
});
}
})
}, []);
function armourTypePriority(armorTypeId: string): number {
switch(armorTypeId) {
case 'Infantry Low':

View File

@ -11,23 +11,32 @@ interface ThemeContextType {
toggleTheme: () => void;
}
interface ThemeProviderProps {
initialMode?: ThemeMode;
children: ReactNode;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 год
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [mode, setMode] = useState<ThemeMode>('dark');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved === 'light') {
setMode('light');
/**
* Синхронизирует выбранную тему с <html>, cookie и localStorage.
* Cookie нужен, чтобы сервер (SSR) сразу отрендерил правильную тему без мерцания.
*/
function applyModeToDocument(mode: ThemeMode) {
document.cookie = `theme=${mode}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; SameSite=Lax`;
}
}, []);
export const ThemeProvider = ({ initialMode = 'dark', children }: ThemeProviderProps) => {
const [mode, setMode] = useState<ThemeMode>(initialMode);
// Применяем тему к <html> и синхронизируем с cookie/localStorage.
useEffect(() => {
localStorage.setItem('theme', mode);
applyModeToDocument(mode);
}, [mode]);
const toggleTheme = () => {
setMode(prev => prev === 'dark' ? 'light' : 'dark');
};

View File

@ -15,7 +15,7 @@
--scrollbar-track: #1a1a2e;
}
body.light-theme {
html[data-theme="light"] {
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--bg-tertiary: #e8e8e8;
@ -41,7 +41,7 @@ body {
color: var(--text-primary);
}
body.light-theme code {
html[data-theme="light"] code {
background: rgba(0, 0, 0, 0.1);
}

View File

@ -1,344 +0,0 @@
import {AvailableBuildings, AvailableMods, IconUrl} from "../core/api";
import React from "react";
import {withRouter} from "../core/withrouter";
import {IShortWeapon} from "../types/IUnit";
import '../css/Building.css'
import {
Button,
Box,
Container,
Divider,
Grid2,
Link,
ListItem,
Paper,
Table,
TableBody,
TableCell,
TableRow,
Tooltip,
Typography, LinearProgress, Theme,
} from "@mui/material";
import ArmorType from "../classes/ArmorType";
import AvTimerOutlinedIcon from '@mui/icons-material/AvTimer';
import WeaponSlot from "../classes/WeaponSlot";
import UnitsTable from "../classes/UnitsTable";
import {IMod} from "../types/Imod";
import {IBuilding} from "../types/IBuilding";
import Vision from "../classes/Vision";
import BuildingAddon from "../classes/building/BuildingAddon";
import {IUnitShort} from "../types/IUnitShort";
import Research, {AffectedResearches} from "../classes/building/Research";
import Required from "../classes/Required";
import {ModifiersProvidesTable} from "../classes/ModifiersProvideTable";
import Ability from "../classes/Ability";
import DeathExplosion from "../classes/DeathExplosion";
import {styled} from '@mui/material/styles';
import {withTheme} from "../core/api";
import {DescriptionBox} from "../commons/DescriptionBox";
import {ArrowBack} from "@mui/icons-material";
import {BackButton} from "../commons/BackButton";
const SectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 2,
fontSize: '1.25rem',
}));
const StatsPaper = styled(Paper)(({ theme }) => ({
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: '12px',
overflow: 'hidden',
'& .MuiTableBody .MuiTableRow-root .MuiTableCell-root': {
color: theme.palette.text.primary,
},
'& .MuiTableBody .MuiTableRow-root .MuiTableCell-head': {
color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
fontWeight: 600,
},
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
my: 3,
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}));
const BuildingTitle = styled(Typography)(({ theme }) => ({
fontWeight: 800,
mb: 1,
display: 'flex',
alignItems: 'center',
gap: 2,
}));
const BuildingSubtitle = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
fontWeight: 500,
}));
const UnitLink = styled(Link)(({ theme }) => ({
color: theme.palette.text.primary,
textDecoration: 'none',
'&:hover': { color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)' },
}));
const UnitListItem = styled(ListItem)(({ theme }) => ({
color: theme.palette.text.primary,
padding: '8px 12px',
background: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)',
borderRadius: '8px',
border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}`,
transition: 'all 0.2s ease',
'&:hover': { background: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)', borderColor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)' },
}));
interface UintPageState {
building: IBuilding,
mod: IMod,
}
function Unit (unit: IUnitShort, modId: number, raceId: String, theme: Theme) {
return (<Grid2 key={unit.id} size={{xs: 12, md: 3}}>
<UnitLink href={"/mod/" + modId + "/race/" + raceId + "/unit/" + unit.id}>
<UnitListItem>
{unit.icon && <img className="unitIcon" src={IconUrl + unit.icon.replaceAll('\\', '/')}/> }
{unit.name}
{unit.canDetect && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/DETECT_YES.webp"/></span>}
</UnitListItem>
</UnitLink>
</Grid2>)
}
function Building(building: IBuilding, mod: IMod, theme: Theme) {
const isDark = theme.palette.mode === 'dark';
let mapBuildingWeapons: Map<number, Map<number, IShortWeapon>> = new Map();
building.weapons.forEach(weapon => {
const weaponMap = mapBuildingWeapons.get(weapon.hardpoint)
if (weaponMap == null) {
const weaponMap = new Map()
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
mapBuildingWeapons.set(weapon.hardpoint, weaponMap)
} else {
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
}
})
var buildingName = building.name;
if(building.name == null){
buildingName = building.filename.replaceAll('_', ' ').replace('.rgd', '');
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<BuildingTitle variant="h4">
{building.icon &&
<img className="unitIcon" src={IconUrl + building.icon.replaceAll('\\', '/')}/>}
{buildingName}
</BuildingTitle>
<BuildingSubtitle variant="subtitle1">
{mod.name} ({mod.version})
</BuildingSubtitle>
</Box>
<Grid2 container spacing={3}>
<Grid2 size={{xs: 12, md: 4}}>
<StatsPaper elevation={0}>
<Table size="small" aria-label="a dense table">
<TableBody id="unit-stats-table">
<TableRow>
<TableCell component="th" scope="row" >Cost</TableCell>
<TableCell>
{building.buildCostRequisition > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_requisition.gif"/>&nbsp;
{building.buildCostRequisition.toFixed(0)}</span>}
{building.buildCostPower > 0 && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_power.gif"/>&nbsp;
{building.buildCostPower.toFixed(0)}</span>}
{(building.buildCostPopulation !== undefined && building.buildCostPopulation > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_orksquadcap.gif"/>&nbsp;
{building.buildCostPopulation.toFixed(0)}</span>}
{(building.buildCostFaith !== undefined && building.buildCostFaith > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_faith.gif"/>&nbsp;
{building.buildCostFaith}</span>}
{(building.buildCostSouls !== undefined && building.buildCostSouls > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_souls.gif"/>&nbsp;
{building.buildCostSouls.toFixed(0)}</span>}
{(building.buildCostTime !== undefined && building.buildCostTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{verticalAlign: "top", fontSize: "18px"}}/>&nbsp;
{building.buildCostTime}s</span>}
</TableCell>
</TableRow>
{(building.requisitionIncome !== undefined && building.requisitionIncome > 0 || building.powerIncome !== undefined && building.powerIncome !== null || building.faithIncome !== undefined && building.faithIncome !== null) &&
<TableRow>
<TableCell >Resource income</TableCell>
<TableCell>
{building.requisitionIncome !== undefined && building.requisitionIncome != null &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_requisition.gif"/>&nbsp;
{building.requisitionIncome}</span>}
{building.powerIncome !== undefined && building.powerIncome > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_power.gif"/>&nbsp;
{building.powerIncome}</span>}
{building.faithIncome !== undefined && building.faithIncome > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_faith.gif"/>&nbsp;
{building.faithIncome}</span>}
</TableCell>
</TableRow>
}
<TableRow>
<TableCell component="th" scope="row" >Armor type</TableCell>
<TableCell>
{building.armorType2 == null ? <ArmorType name={building.armorType.name} compact={false}/>
: <Tooltip title={"upgrade/debuff can turns to " + building.armorType2.name}><span><ArmorType name={building.armorType.name} compact={false}/></span></Tooltip> }
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row" >Health</TableCell>
<TableCell>
<span><img style={{verticalAlign: "top"}}
src="/images/Health_icon.webp"/>&nbsp;
{building.health} {building.healthRegeneration > 0 &&
<span>+{building.healthRegeneration}/s</span>} </span>
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row" >Sight</TableCell>
<TableCell>
<Vision sight={building.sightRadius} detect={building.detectRadius}/>
</TableCell>
</TableRow>
<TableRow sx={{'&:last-child td, &:last-child th': {border: 0}}}>
<TableCell component="th" scope="row" >Repair max</TableCell>
<TableCell>{building.repairMax}</TableCell>
</TableRow>
</TableBody>
</Table>
</StatsPaper>
</Grid2>
<Grid2 size={{xs: 12, md: 8}}>
<DescriptionBox>
{building.description}
</DescriptionBox>
</Grid2>
{building.requirements !== null &&
<Grid2 size={{xs: 12, md: 12}}>
<Required requirement={building.requirements} modId={building.modId} raceId={building.race.id}/>
</Grid2>}
{building.modifiers.length > 0 &&
<Grid2 size={{xs: 12, md: 12}}>
<SectionTitle>Affected on</SectionTitle>
<ModifiersProvidesTable modifiers={building.modifiers} race={building.race} modId={building.modId} affectedData={building.affectedData} />
</Grid2>}
{building.units.length > 0 && <Grid2 size={12}>
<SectionTitle>Unit production</SectionTitle>
<Grid2 container spacing={2}>
{building.units.map(unit =>
Unit(unit, mod.id, building.race.id, theme)
)}
</Grid2>
</Grid2>}
{building.abilities.length > 0 && <Grid2 size={12}>
<SectionTitle>Abilities</SectionTitle>
{building.abilities.map(a =>
<Ability mod={mod} ability={a} race={building.race}/>
)}
</Grid2>}
{building.deathExplosions.length > 0 &&
<Grid2 size={12}>
{building.deathExplosions.map(da =>
<DeathExplosion deathExplosion={da} mod={mod}/>
)}
</Grid2>
}
{building.addons.length > 0 && <Grid2 size={12}>
<SectionTitle>Addons</SectionTitle>
{building.addons.map(b =>
<BuildingAddon mod={mod} addon={b} building={building}/>
)}
</Grid2> }
{building.researches.length > 0 && <Grid2 size={12}>
<SectionTitle>Researches</SectionTitle>
{building.researches.map(r =>
<Research key={r.id} research={r} building={building} mod={mod}/>
)}
</Grid2> }
<Grid2 size={12}>
{[...mapBuildingWeapons.keys()].sort(function (a, b) {
return a - b;
}).map(h => <WeaponSlot race={building.race} mod={mod} unitWeapons={mapBuildingWeapons.get(h)} hardpoint={h}/>)}
</Grid2>
<Grid2 size={12}>
<AffectedResearches researches={building.affectedResearches} modId={mod.id} raceId={building.race.id} />
</Grid2>
<b className="hotkey" >Hotkey: {building.hotkey} <span style={{ fontSize: '12px', fontWeight: 400, color: 'rgba(128,128,128,0.7)' }}>Filename: {building.filename}</span></b>
</Grid2>
<StyledDivider />
<UnitsTable modId={mod.id}/>
</Box>)
}
class BuildingPage extends React.Component<any, UintPageState> {
async componentDidMount() {
const buildingResponse = await fetch(AvailableBuildings + "/" + this.props.match.params.buildingId);
const buildingData: IBuilding = await buildingResponse.json();
this.setState({
building: buildingData
});
const responseMod = await fetch(AvailableMods + "/" + this.props.match.params.modId);
const modData: IMod = await responseMod.json();
this.setState({
mod: modData
});
}
render() {
if (this.state != null && this.state.building != null && this.state.mod != null) {
const backRef = "/mod/" + this.props.match.params.modId + "/race/" + this.props.match.params.raceId
const { theme } = this.props;
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack/>}
href={backRef}
>
Back to race
</BackButton>
{Building(this.state.building, this.state.mod, theme)}
</Container>
);
} else {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
);
}
}
}
export default withRouter(withTheme(BuildingPage));

View File

@ -1,114 +0,0 @@
import {AvailableMods, AvailableRacesPart, AvailableUnits, IconUrl} from "../core/api";
import React, { useState } from "react";
import {withRouter} from "../core/withrouter";
import {withTheme} from "../core/api";
import {IMod} from "../types/Imod";
import {
ArrowBack,
} from "@mui/icons-material";
import {
Box,
Button,
Container,
Paper,
Typography,
Divider,
LinearProgress
} from "@mui/material";
import {styled} from '@mui/material/styles';
import UnitsTable from "../classes/UnitsTable";
import {BackButton} from "../commons/BackButton";
const ModNotFound = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
interface ModPageState {
mod: IMod | null,
loading: boolean,
}
class ModPage extends React.Component<any, ModPageState> {
constructor(props: any) {
super(props);
this.state = {
mod: null,
loading: true
};
fetch(AvailableMods + "/" + this.props.match.params.modId)
.then(res => res.json())
.then((res: IMod) => {
this.setState({
mod: res,
loading: false
});
})
.catch(() => {
this.setState({ loading: false });
});
}
render() {
if (this.state.loading) {
return (
<Container maxWidth="lg" >
<LinearProgress sx={{ width: '200px', mx: 'auto', display: 'block' }} />
</Container>
);
}
if (this.state != null && this.state.mod != null) {
const isDark = this.props.theme.palette.mode === 'dark';
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack/>}
href="/"
>
Back to mods list
</BackButton>
<Typography variant="h3" component="h1" sx={{
fontWeight: 800,
mb: 1,
WebkitBackgroundClip: isDark ? 'text' : 'initial',
WebkitTextFillColor: isDark ? '#dee2e6' : 'initial',
color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
}}>
{this.state.mod.name} <Box component="span" sx={{
fontSize: '0.5em',
color: this.props.theme.palette.text.secondary,
fontWeight: 500,
}}>({this.state.mod.version})</Box>
</Typography>
<Box>
<UnitsTable modId={this.state.mod.id}/>
</Box>
</Container>
);
} else {
return (
<Container maxWidth="lg" sx={{ py: 8, textAlign: 'center' }}>
<ModNotFound variant="h5">
Mod not found
</ModNotFound>
</Container>
);
}
}
}
export default withRouter(withTheme(ModPage));

View File

@ -1,321 +0,0 @@
import React from "react";
import {
Box,
Container,
Typography,
Grid,
Card,
CardContent,
CardActions,
Chip,
Button,
Divider,
useTheme,
Paper,
LinearProgress,
Link,
} from "@mui/material";
import { styled } from '@mui/material/styles';
import { IMod } from "../types/Imod";
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(Link)(({ 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} href={"/mod/" + 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 href={"/mod/" + 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"
href={"/mod/" + 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;
}
interface ModsPageProps {
initialMods?: IMod[];
}
class ModsPage extends React.Component<ModsPageProps, ModsPageState> {
constructor(props: ModsPageProps) {
super(props);
this.state = {
mods: props.initialMods ?? [],
loading: false
};
}
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);
}
}
}
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;

View File

@ -1,332 +0,0 @@
import {AvailableBuildings, AvailableMods, AvailableRacesPart, AvailableUnits, IconUrl} from "../core/api";
import React from "react";
import {withRouter} from "../core/withrouter";
import {IMod} from "../types/Imod";
import {Irace} from "../types/Irace";
import '../css/Unit.css'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Container,
Divider,
Grid2, LinearProgress,
Link,
List,
ListItem,
Paper,
Typography,
Theme,
} from "@mui/material";
import {ArrowBack, ExpandMore} from "@mui/icons-material";
import {IRaceUnits, IUnitShort} from "../types/IUnitShort";
import {IBuildingShort, IRaceBuildings} from "../types/IBuildingShort";
import {styled} from '@mui/material/styles';
import {withTheme} from "../core/api";
import {StyledLink} from "../commons/StyledLink";
import {BackButton} from "../commons/BackButton";
const SectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 2,
fontSize: '1.25rem',
}));
const UnitCard = styled(Paper)(({ theme }) => ({
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)',
'&: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)',
},
}));
const BuildingCard = styled(UnitCard)(({ theme }) => ({
padding: theme.spacing(1),
}));
const UnitLink = styled(Link)(({ theme }) => ({
color: theme.palette.text.primary,
textDecoration: 'none',
display: 'block',
'&:hover': { color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)' }
}));
const LoadingText = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
interface RacePageState {
mod: IMod,
race: Irace,
units: IUnitShort[],
}
function Unit(unit: IUnitShort, modId: number, raceId: String, theme: Theme) {
const isDark = theme.palette.mode === 'dark';
const borderColor = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)';
const bgColor = isDark ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.02)';
return (
<UnitLink href={"/mod/" + modId + "/race/" + raceId + "/unit/" + unit.id}>
<ListItem sx={{
color: theme.palette.text.primary,
padding: '1.5rem 2rem',
marginBottom: '0.5rem',
background: isDark
? 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(145deg, #ffffff 0%, #f5f5f5 100%)',
borderRadius: '16px',
border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
boxShadow: isDark ? '0 20px 40px rgba(255, 255, 255, 0.1)' : '0 20px 40px rgba(0, 0, 0, 0.2)',
borderColor: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',
}
}}>
{unit.icon && <img className="unitIcon" src={IconUrl + unit.icon.replaceAll('\\', '/')}/>}
{unit.name}
{unit.canDetect && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/DETECT_YES.webp"/></span>}
</ListItem>
</UnitLink>
)
}
function UnitSmall(unit: IUnitShort, modId: number, raceId: String) {
var unitName = ""
if (unit.name.length > 21) {
unitName = unit.name.substring(0, 19) + "...";
} else {
unitName = unit.name;
}
return (<StyledLink href={"/mod/" + modId + "/race/" + raceId + "/unit/" + unit.id} >
{unit.icon && <img className="unitIconSmall" src={IconUrl + unit.icon.replaceAll('\\', '/')}/>}
&nbsp;<span style={{fontSize: 14}}>{unitName}</span>
{unit.canDetect && <span>&nbsp;<img
src="/images/DETECT_YES.webp"/></span>}<br/></StyledLink>)
}
function Building(building: IBuildingShort, modId: number, raceId: String, theme: Theme) {
const isDark = theme.palette.mode === 'dark';
return (<Grid2 size={{xs: 12, md: 3}}>
<Link href={"/mod/" + modId + "/race/" + raceId + "/building/" + building.id} sx={{
textDecoration: 'none',
display: 'block',
'&:hover': { color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)' }
}}>
<BuildingCard elevation={0}>
<ListItem sx={{}}>
{building.icon && <img className="unitIcon" src={IconUrl + building.icon.replaceAll('\\', '/')}/>}
&nbsp;<StyledLink>{building.name}</StyledLink>
{building.canDetect && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/DETECT_YES.webp"/></span>}
</ListItem>
{building.units.map(unit => UnitSmall(unit, modId, raceId))}
</BuildingCard>
</Link>
</Grid2>)
}
interface UnitsProps {
raceId: string,
modId: number,
}
interface UnitsState {
selectedUnits: String | null,
units: IRaceUnits | null,
buildings: IRaceBuildings | null,
}
class Units extends React.Component<UnitsProps & { theme: Theme }, UnitsState> {
constructor(props: any) {
super(props);
let urlUnits = AvailableUnits + "/" + this.props.modId + "/" + this.props.raceId;
fetch(urlUnits)
.then(res => res.json())
.then((res: IRaceUnits) => {
this.setState({
units: res,
})
})
let urlBuildings = AvailableBuildings + "/" + this.props.modId + "/" + this.props.raceId;
fetch(urlBuildings)
.then(res => res.json())
.then((res: IRaceBuildings) => {
this.setState({
buildings: res,
})
})
}
render() {
const { theme } = this.props;
const isDark = theme.palette.mode === 'dark';
if (this.state && this.state.units) {
if(this.state.buildings?.race.name !== undefined){
// title managed by Helmet in parent component
}
const accordionSx = isDark ? {
background: 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '12px',
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: '0 0 8px 0' },
} : {
background: 'linear-gradient(145deg, #ffffff 0%, #f5f5f5 100%)',
border: '1px solid rgba(0, 0, 0, 0.1)',
borderRadius: '12px',
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: '0 0 8px 0' },
};
return (this.state.buildings != null ?
<Box>
<Grid2 container spacing={2}>
{this.state.buildings.buildings.map(building => Building(building, this.props.modId, this.props.raceId, theme))}
</Grid2>
{this.state.buildings.buildingsAdvanced.length > 0 && <Box sx={{ mt: 3 }}>
<Divider sx={{ my: 2, borderColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)' }} />
<SectionTitle>Advanced buildings</SectionTitle>
<Grid2 container spacing={2}>
{this.state.buildings.buildingsAdvanced.map(building => Building(building, this.props.modId, this.props.raceId, theme))}
</Grid2><br/></Box>}
<Box sx={{ mt: 3 }}>
<Accordion sx={accordionSx}>
<AccordionSummary
expandIcon={<ExpandMore sx={{color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)'}}/>}
aria-controls="units-accordion"
id="units-accordion"
sx={{color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', '& .MuiAccordionSummary-content': { margin: '12px 0' }, '& .MuiAccordionSummary-content.Mui-expanded': { margin: '12px 0' }}}
>
<SectionTitle sx={{ mb: 0, display: 'flex', alignItems: 'center' }}>All units</SectionTitle>
</AccordionSummary>
<AccordionDetails>
<Grid2 container spacing={2}>
<Grid2 size={{xs: 12, md: 4}}>
<Typography variant="h6" sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', fontWeight: 600, mb: 2 }}>Infantry</Typography>
{this.state.units.infantry.map(unit => Unit(unit, this.props.modId, this.props.raceId, theme))}
</Grid2>
<Grid2 size={{xs: 12, md: 4}}>
<Typography variant="h6" sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', fontWeight: 600, mb: 2 }}>Tech</Typography>
<List>
{this.state.units.tech.map(unit => Unit(unit, this.props.modId, this.props.raceId, theme))}
</List>
</Grid2>
<Grid2 size={{xs: 12, md: 4}}>
<Typography variant="h6" sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', fontWeight: 600, mb: 2 }}>Support</Typography>
<List>
{this.state.units.support.map(unit => Unit(unit, this.props.modId, this.props.raceId, theme))}
</List>
</Grid2>
</Grid2>
</AccordionDetails>
</Accordion>
</Box>
</Box> : <LoadingText>Loading</LoadingText>
)
} else {
return <LoadingText>Loading...</LoadingText>;
}
}
}
const UnitsWithTheme = withTheme(Units) as React.ComponentType<UnitsProps>;
class RacePageFast extends React.Component<any, RacePageState> {
constructor(props: any) {
super(props);
}
async componentDidMount() {
const responseMod = await fetch(AvailableMods + "/" + this.props.match.params.modId);
const modData: IMod = await responseMod.json();
this.setState({
mod: modData
});
const response = await fetch(AvailableRacesPart + "/" + this.props.match.params.raceId);
const racesData: Irace = await response.json();
this.setState({
race: racesData
});
}
render() {
if (this.state != null && this.state.mod != null && this.state.race != null) {
const backRef = "/mod/" + this.state.mod.id
const theme = this.props.theme;
const isDark = theme.palette.mode === 'dark';
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack/>}
href={backRef}
>
Back to mod
</BackButton>
<Box sx={{ mb: 4 }}>
<Typography variant="h3" component="h1" sx={{
fontWeight: 800,
mb: 1,
background: isDark
? 'linear-gradient(135deg, #dee2e6 0%, #e0e0e0 100%)'
: 'none',
WebkitBackgroundClip: isDark ? 'text' : 'initial',
WebkitTextFillColor: isDark ? '#dee2e6' : 'initial',
color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
}}>
{this.state.race.name}
</Typography>
<Typography variant="h6" sx={{
color: theme.palette.text.secondary,
fontWeight: 500,
}}>
{this.state.mod.name} ({this.state.mod.version})
</Typography>
</Box>
<UnitsWithTheme raceId={this.state.race.id} modId={this.state.mod.id}/>
</Container>
);
} else {
return (
<Container maxWidth="lg">
<LinearProgress sx={{ width: '200px', mx: 'auto', display: 'block' }} />
</Container>
);
}
}
}
export default withRouter(withTheme(RacePageFast));

View File

@ -1,435 +0,0 @@
import {AvailableMods, AvailableUnits, IconUrl} from "../core/api";
import React from "react";
import {withRouter} from "../core/withrouter";
import {IShortWeapon, IUnit} from "../types/IUnit";
import '../css/Unit.css'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Container,
Divider,
Grid2, LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableRow,
Tooltip,
Typography, Theme,
} from "@mui/material";
import {ArrowBack, ExpandMore} from "@mui/icons-material";
import ArmorType from "../classes/ArmorType";
import AvTimerOutlinedIcon from '@mui/icons-material/AvTimer';
import Sergeant from "../classes/Sergeant";
import WeaponSlot from "../classes/WeaponSlot";
import UnitsTable from "../classes/UnitsTable";
import {IMod} from "../types/Imod";
import Vision from "../classes/Vision";
import Required from "../classes/Required";
import {AffectedResearches} from "../classes/building/Research";
import {ModifiersProvidesTable} from "../classes/ModifiersProvideTable";
import Ability from "../classes/Ability";
import DeathExplosion from "../classes/DeathExplosion";
import Jump from "../classes/Jump";
import {styled} from '@mui/material/styles';
import {withTheme} from "../core/api";
import {DescriptionBox} from "../commons/DescriptionBox";
import {BackButton} from "../commons/BackButton";
const SectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 2,
fontSize: '1.25rem',
}));
const StatsPaper = styled(Paper)(({ theme }) => ({
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: '12px',
overflow: 'hidden',
'& .MuiTableBody .MuiTableRow-root': {
'&:last-child td, &:last-child th': { border: 0 },
},
'& .MuiTableBody .MuiTableRow-root .MuiTableCell-head': {
color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
fontWeight: 600,
},
}));
const SergeantAccordion = styled(Accordion)(({ theme }) => ({
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: '12px',
mb: 1,
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: '0 0 8px 0' },
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
my: 3,
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}));
const UnitTitle = styled(Typography)(({ theme }) => ({
fontWeight: 800,
mb: 1,
display: 'flex',
alignItems: 'center',
gap: 2,
}));
const UnitSubtitle = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
fontWeight: 500,
}));
interface UintPageState {
unit: IUnit,
mod: IMod,
}
function Unit(unit: IUnit, mod: IMod, theme: Theme) {
const isDark = theme.palette.mode === 'dark';
const morale = (unit.moraleMax !== null) ? <span>
<img style={{verticalAlign: "top"}} src="/images/ARM_Morale.webp"/>&nbsp;{unit.moraleMax}
+{unit.moraleRegeneration}/s
{unit.moraleDeathPenalty > 0 && <span> <img style={{verticalAlign: "top"}}
src="/images/Kills_icon.webp"/> -{unit.moraleDeathPenalty}</span>}
</span> : "-"
let mapWithUnitWeapons: Map<number, Map<number, IShortWeapon>> = new Map();
unit.weapons.forEach(weapon => {
const weaponMap = mapWithUnitWeapons.get(weapon.hardpoint)
if (weaponMap == null) {
const weaponMap = new Map()
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
mapWithUnitWeapons.set(weapon.hardpoint, weaponMap)
} else {
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
}
})
type sergeantProps = {
name: string
icon: String
canDetect: Boolean
}
const SergeantShort = (props: sergeantProps) => {
return (
<span style={{fontSize: 20, color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', display: 'flex', alignItems: 'center'}}>
{props.icon && <img className="sergeantIcon" src={IconUrl + props.icon.replaceAll('\\', '/')}/> }
&nbsp; {props.name}
{props.canDetect && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/DETECT_YES.webp"/></span>}
</span>
)
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<UnitTitle variant="h4">
{unit.icon &&
<img className="unitIcon" src={IconUrl + unit.icon.replaceAll('\\', '/')}/>}
{unit.name}
</UnitTitle>
<UnitSubtitle variant="subtitle1">
{mod.name} ({mod.version})
</UnitSubtitle>
</Box>
<Grid2 container spacing={3}>
<Grid2 size={{xs: 12, md: 4}}>
<StatsPaper >
<Table size="small" aria-label="a dense table">
<TableBody id="unit-stats-table" >
<TableRow>
<TableCell component="th" scope="row" >Cost</TableCell>
<TableCell>
{unit.buildCostRequisition > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_requisition.gif"/>&nbsp;
{unit.buildCostRequisition.toFixed(0)}</span>}
{unit.buildCostPower > 0 && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_power.gif"/>&nbsp;
{unit.buildCostPower.toFixed(0)}</span>}
{(unit.buildCostPopulation !== undefined && unit.buildCostPopulation > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_orksquadcap.gif"/>&nbsp;
{unit.buildCostPopulation.toFixed(0)}</span>}
{(unit.buildCostFaith !== undefined && unit.buildCostFaith > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_faith.gif"/>&nbsp;
{unit.buildCostFaith}</span>}
{(unit.buildCostSouls !== undefined && unit.buildCostSouls > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_souls.gif"/>&nbsp;
{unit.buildCostSouls.toFixed(0)}</span>}
{unit.capInfantry > 0 && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_cap_infantry.gif"/>&nbsp;
{unit.capInfantry}</span>}
{unit.capSupport > 0 && <span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_cap_vehicle.gif"/>&nbsp;
{unit.capSupport}</span>}
{(unit.buildCostTime !== undefined && unit.buildCostTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{verticalAlign: "top", fontSize: "18px"}}/>&nbsp;
{unit.buildCostTime}s</span>}
</TableCell>
</TableRow>
{(unit?.reinforceTime !== 0 && unit.reinforceTime !== null && unit.squadMaxSize > 1) &&
<TableRow>
<TableCell>Reinforce cost</TableCell>
<TableCell>
{unit.reinforceCostRequisition && unit.reinforceCostRequisition > 0 ?
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_requisition.gif"/>&nbsp;
{unit.reinforceCostRequisition.toFixed(0)}</span>:<span/>}
{unit.reinforceCostPower !== undefined && unit.reinforceCostPower > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_power.gif"/>&nbsp;
{unit.reinforceCostPower.toFixed(0)}</span>}
{(unit.reinforceCostPopulation !== undefined && unit.reinforceCostPopulation > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_orksquadcap.gif"/>&nbsp;
{unit.reinforceCostPopulation.toFixed(0)}</span>}
{(unit.reinforceCostFaith !== undefined && unit.reinforceCostFaith > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_faith.gif"/>&nbsp;
{unit.reinforceCostFaith}</span>}
{(unit.reinforceCostSouls !== undefined && unit.reinforceCostSouls > 0) &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_souls.gif"/>&nbsp;
{unit.reinforceCostSouls.toFixed(0)}</span>}
{(unit.reinforceTime !== undefined && unit.reinforceTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{verticalAlign: "top", fontSize: "18px"}}/>&nbsp;
{unit.reinforceTime}s</span>}
</TableCell>
</TableRow>
}
{(unit.requisitionIncome !== undefined && unit.requisitionIncome !== null || unit.powerIncome !== undefined && unit.powerIncome !== null || unit.faithIncome !== undefined && unit.faithIncome !== null) &&
<TableRow>
<TableCell >Resource income</TableCell>
<TableCell>
{unit.requisitionIncome !== undefined && unit.requisitionIncome > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_requisition.gif"/>&nbsp;
{unit.requisitionIncome}</span>}
{unit.powerIncome !== undefined && unit.powerIncome > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_power.gif"/>&nbsp;
{unit.powerIncome}</span>}
{unit.faithIncome !== undefined && unit.faithIncome > 0 &&
<span>&nbsp;<img style={{verticalAlign: "top"}}
src="/images/Resource_faith.gif"/>&nbsp;
{unit.faithIncome}</span>}
</TableCell>
</TableRow>
}
{unit.squadMaxSize > 1 &&
<TableRow>
<TableCell >Squad size</TableCell>
<TableCell>{unit.squadStartSize} / {unit.squadMaxSize}</TableCell>
</TableRow>
}
<TableRow>
<TableCell >Armor type</TableCell>
<TableCell>
{unit.armorType2 == null ? <ArmorType name={unit.armorType.name} compact={false}/> : <Tooltip title={"upgrade/debuff can turns to " + unit.armorType2.name}><span><ArmorType name={unit.armorType.name} compact={false}/></span></Tooltip> }
</TableCell>
</TableRow>
<TableRow>
<TableCell >Health</TableCell>
<TableCell>
<img style={{verticalAlign: "top"}}
src="/images/Health_icon.webp"/>&nbsp;
{unit.health} {unit.healthRegeneration > 0 &&
<span>+{unit.healthRegeneration}/s</span>}
{unit.armour !== undefined && unit.armour !== 0 && <span><img style={{height: 20, verticalAlign: "top"}} src="/images/defence.png"/>{unit.armour} </span> }
</TableCell>
</TableRow>
<TableRow>
<TableCell >Move speed</TableCell>
<TableCell>{unit.moveSpeed}</TableCell>
</TableRow>
<TableRow>
<TableCell >Morale</TableCell>
<TableCell>{morale}</TableCell>
</TableRow>
<TableRow>
<TableCell >Mass</TableCell>
<TableCell>{unit.mass}</TableCell>
</TableRow>
<TableRow sx={{'&:last-child td, &:last-child th': {border: 0}}}>
<TableCell >Vision</TableCell>
<TableCell><Vision sight={unit.sightRadius} detect={unit.detectRadius}/></TableCell>
</TableRow>
{unit.repairMax !== undefined && unit.repairMax !== null &&
<TableRow sx={{'&:last-child td, &:last-child th': {border: 0}}}>
<TableCell >Repair max</TableCell>
<TableCell>{unit.repairMax}</TableCell>
</TableRow>
}
{unit.repairSpeed !== undefined && unit.repairSpeed !== null && unit.repairCostPercent !== null &&
<TableRow sx={{'&:last-child td, &:last-child th': {border: 0}}}>
<TableCell >Repair</TableCell>
<TableCell>{unit.repairSpeed} hp/s; {unit.repairCostPercent}% cost</TableCell>
</TableRow>
}
{unit.mobValue != null &&
<TableRow sx={{'&:last-child td, &:last-child th': {border: 0}}}>
<TableCell >Mob value</TableCell>
<TableCell><span> <img style={{height: 20, verticalAlign: "top"}} src="/images/Mob_bonus.gif"/> {unit.mobValue} </span></TableCell>
</TableRow>
}
{unit.squadLimit !== undefined && unit.squadLimit !== null &&
<TableRow sx={{'&:last-child td, &:last-child th': {border: 0}}}>
<TableCell >Limit</TableCell>
<TableCell>{unit.squadLimit}</TableCell>
</TableRow>
}
</TableBody>
</Table>
</StatsPaper>
</Grid2>
<Grid2 size={{xs: 12, md: 8}}>
<DescriptionBox>
{unit.description}
</DescriptionBox>
</Grid2>
{unit.requirements !== null &&
<Grid2 size={{xs: 12, md: 12}}>
<Required requirement={unit.requirements} modId={unit.modId} raceId={unit.race.id}/>
</Grid2>}
<Grid2 size={12}>
{unit.sergeants.map(s =>
<SergeantAccordion key={s.id}>
<AccordionSummary
sx={{color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', '& .MuiAccordionSummary-content': { margin: '12px 0' }, '& .MuiAccordionSummary-content.Mui-expanded': { margin: '12px 0' }}}>
<SergeantShort name={s.name} icon={s.icon} canDetect={s.detectRadius > 0}/>
</AccordionSummary>
<AccordionDetails sx={{color: theme.palette.text.primary}}>
<Sergeant mod={mod} sergeant={s} race={unit.race}/>
</AccordionDetails>
</SergeantAccordion>)}
</Grid2>
{unit.moraleMax !== null && !(unit.moraleBrakeModifiers.find(m => m.reference.includes("accuracy_weapon_modifier") && m.value === 0.2) !== undefined &&
unit.moraleBrakeModifiers.find(m => m.reference.includes("speed_maximum_modifier") && m.value === 1.2) !== undefined &&
unit.moraleBrakeModifiers.length === 2)
&& <Grid2 size={12}>
<SectionTitle><img style={{verticalAlign: "top"}} src="/images/ARM_Morale.webp"/> Morale broken <img style={{verticalAlign: "top"}} src="/images/ARM_Morale.webp"/></SectionTitle>
<ModifiersProvidesTable modifiers={unit.moraleBrakeModifiers} modId={unit.modId} race={unit.race} affectedData={unit.affectedData}/>
</Grid2>}
{unit.modifiers.length > 0 && <Grid2 size={12}>
<SectionTitle>Affected on</SectionTitle>
<ModifiersProvidesTable modifiers={unit.modifiers} modId={unit.modId} race={unit.race} affectedData={unit.affectedData}/>
</Grid2>}
{unit.abilities.length > 0 && <Grid2 size={12}>
<SectionTitle>Abilities</SectionTitle>
{unit.abilities.map(a =>
<Ability mod={mod} ability={a} race={unit.race}/>
)}
</Grid2>}
{unit.jumps != null && <Grid2 size={12}>
<Jump jumps={unit.jumps} race={unit.race} mod={mod}/>
</Grid2>}
<Grid2 size={12}>
{[...mapWithUnitWeapons.keys()].sort(function (a, b) {
return a - b;
}).map(h => <WeaponSlot haveReinforceMenu={unit.haveReinforceMenu} key ={h} race={unit.race} mod={mod} unitWeapons={mapWithUnitWeapons.get(h)} hardpoint={h}/>)}
</Grid2>
{unit.deathExplosions.length > 0 &&
<Grid2 size={12}>
{unit.deathExplosions.map(da =>
<DeathExplosion deathExplosion={da} mod={mod}/>
)}
</Grid2>
}
<Grid2 size={12}>
<AffectedResearches researches={unit.affectedResearches} modId={mod.id} raceId={unit.race.id} />
</Grid2>
</Grid2>
<Box sx={{ mt: 3, mb: 2, fontWeight: 600, fontSize: '0.85rem' }}>
Hotkey: {unit.hotkey} <span style={{ fontSize: '12px', fontWeight: 400, color: 'rgba(128,128,128,0.7)' }}>Filename: {unit.filename}</span>
</Box>
<StyledDivider />
<UnitsTable modId={mod.id}/>
</Box>
)
}
class UnitPage extends React.Component<any, UintPageState> {
async componentDidMount() {
const unitsResponse = await fetch(AvailableUnits + "/" + this.props.match.params.unitId);
const unitsData: IUnit = await unitsResponse.json();
this.setState({
unit: unitsData
});
const responseMod = await fetch(AvailableMods + "/" + this.props.match.params.modId);
const modData: IMod = await responseMod.json();
this.setState({
mod: modData
});
}
render() {
if (this.state != null && this.state.unit != null && this.state.mod != null) {
const backRef = "/mod/" + this.props.match.params.modId + "/race/" + this.props.match.params.raceId
const { theme } = this.props;
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack/>}
href={backRef}
>
Back to race
</BackButton>
{Unit(this.state.unit, this.state.mod, theme)}
</Container>
);
} else {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
);
}
}
}
export default withRouter(withTheme(UnitPage));