mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
feat: start of monorepo
This commit is contained in:
1 parent
14f39d71ac
commit
73c7bdf0c0
260 files changed
+1236
-1235
No files matched your search
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@blitzkit/core",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^20.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"@prisma/client": "^5.19.1",
|
||||
"@octokit/plugin-throttling": "^8.2.0",
|
||||
"lz4js": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/lz4js": "^0.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Region } from '../../constants/regions';
|
||||
|
||||
export const MIN_REGION_IDS: Record<Region, number> = {
|
||||
asia: 20e8,
|
||||
com: 10e8,
|
||||
eu: 5e8,
|
||||
};
|
||||
|
||||
export const MAX_REGION_IDS: Record<Region, number> = {
|
||||
asia: 31e8 - 1,
|
||||
com: MIN_REGION_IDS.asia - 1,
|
||||
eu: MIN_REGION_IDS.com - 1,
|
||||
};
|
||||
|
||||
export function idToRegion(id: number): Region {
|
||||
if (id >= 42e8) {
|
||||
throw new Error('bot id');
|
||||
} else if (id >= 31e8) {
|
||||
throw new Error('china id');
|
||||
} else if (id >= 20e8) {
|
||||
return 'asia';
|
||||
} else if (id >= 10e8) {
|
||||
return 'com';
|
||||
} else if (id >= 5e8) {
|
||||
return 'eu';
|
||||
} else if (id >= 0) {
|
||||
throw new Error('ru id');
|
||||
} else {
|
||||
throw new Error(`id ${id} is out of range`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './idToRegion';
|
||||
@@ -0,0 +1,4 @@
|
||||
export function assertSecret(secret?: string) {
|
||||
if (secret === undefined) throw new Error('Missing secret');
|
||||
return secret;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { asset } from '../../../../src/core/blitzkit/asset';
|
||||
import { fetchCdonLz4 } from '../../../../src/core/blitzkit/fetchCdonLz4';
|
||||
|
||||
interface GameMode {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GameDefinitions {
|
||||
version: string;
|
||||
nations: string[];
|
||||
gameModes: Record<number, GameMode>;
|
||||
roles: Record<
|
||||
number,
|
||||
{
|
||||
provisions: number[];
|
||||
consumables: number[];
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export const gameDefinitions = fetchCdonLz4<GameDefinitions>(
|
||||
asset('definitions/game.cdon.lz4'),
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './assertSecret';
|
||||
export * from './gameDefinitions';
|
||||
export * from './superCompress';
|
||||
export * from './superDecompress';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { compress } from 'lz4js';
|
||||
import { CdonWriteStream } from '../../../../src/core/streams/cdon';
|
||||
|
||||
export function superCompress(object: any) {
|
||||
const write = new CdonWriteStream().cdon(object);
|
||||
const compressed = compress(write.uint8Array);
|
||||
return Buffer.from(compressed).toString('base64');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { decompress } from 'lz4js';
|
||||
import { CdonReadStream } from '../../../../src/core/streams/cdon';
|
||||
|
||||
export function superDecompress<Type>(buffer: ArrayBuffer) {
|
||||
const decompressed = decompress(new Uint8Array(buffer)).buffer as ArrayBuffer;
|
||||
const read = new CdonReadStream(decompressed);
|
||||
return read.cdon() as Type;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export enum ImgurSize {
|
||||
/**
|
||||
* The default size
|
||||
*/
|
||||
Default = '',
|
||||
/**
|
||||
* Unknown size but really really small
|
||||
*/
|
||||
UltraSmall = 'ss',
|
||||
/**
|
||||
* 90x90
|
||||
*/
|
||||
SmallSquare = 's',
|
||||
/**
|
||||
* 160x160
|
||||
*/
|
||||
BigSquare = 'b',
|
||||
/**
|
||||
* 160x160
|
||||
*/
|
||||
SmallThumbnail = 't',
|
||||
/**
|
||||
* 320x320
|
||||
*/
|
||||
MediumThumbnail = 'm',
|
||||
/**
|
||||
* 640x640
|
||||
*/
|
||||
LargeThumbnail = 'l',
|
||||
/**
|
||||
* 1024x1024
|
||||
*/
|
||||
HugeThumbnail = 'h',
|
||||
}
|
||||
|
||||
interface ImgurOptions {
|
||||
format: 'jpeg' | 'png';
|
||||
size: ImgurSize;
|
||||
}
|
||||
|
||||
const defaultImgurOptions: ImgurOptions = {
|
||||
format: 'png',
|
||||
size: ImgurSize.Default,
|
||||
};
|
||||
|
||||
export function imgur(id: string, imgurOptions?: Partial<ImgurOptions>) {
|
||||
const options = { ...defaultImgurOptions, ...imgurOptions };
|
||||
|
||||
return `https://i.imgur.com/${id}${options.size}.${options.format}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './imgur';
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './blitz';
|
||||
export * from './blitzkit';
|
||||
export * from './imgur';
|
||||
export * from './patreon';
|
||||
export * from './zustand';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './patreonLoginUrl';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { assertSecret } from '../blitzkit/assertSecret';
|
||||
|
||||
export function patreonLoginUrl() {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: assertSecret(process.env.NEXT_PUBLIC_PATREON_CLIENT_ID),
|
||||
redirect_uri: assertSecret(process.env.NEXT_PUBLIC_PATREON_REDIRECT_URI),
|
||||
scope: 'identity',
|
||||
});
|
||||
|
||||
return `https://www.patreon.com/oauth2/authorize?${params}`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Draft, produce } from 'immer';
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { StoreApi, useStore as useStoreZustand } from 'zustand';
|
||||
|
||||
type StoreProviderProps<InitData> = {
|
||||
children: ReactNode;
|
||||
} & (InitData extends unknown ? { data?: InitData } : { data: InitData });
|
||||
|
||||
// idk why zustand doesn't export this
|
||||
type ExtractState<S> = S extends {
|
||||
getState: () => infer T;
|
||||
}
|
||||
? T
|
||||
: never;
|
||||
|
||||
export function createNextSafeStore<Store extends StoreApi<unknown>, InitData>(
|
||||
initialize: (data: InitData) => Store,
|
||||
) {
|
||||
type Type = Store extends StoreApi<infer T> ? T : never;
|
||||
|
||||
const Context = createContext<Store | undefined>(undefined);
|
||||
|
||||
function Provider(props: StoreProviderProps<InitData>) {
|
||||
const storeRef = useRef<Store>();
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = initialize((props as { data: InitData }).data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Context.Provider value={storeRef.current}>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useStore() {
|
||||
const store = useContext(Context);
|
||||
if (!store) throw new Error('useStore must be used within Provider');
|
||||
return store as Store;
|
||||
}
|
||||
|
||||
function use<Slice = Type>(
|
||||
selector: (store: Type) => Slice = (state) => state as Slice,
|
||||
) {
|
||||
const store = useStore();
|
||||
return useStoreZustand(
|
||||
store,
|
||||
selector as (state: ExtractState<Store>) => Slice,
|
||||
);
|
||||
}
|
||||
|
||||
function useDeferred<Slice = Type>(
|
||||
initial: Slice,
|
||||
selector: (store: Type) => Slice = (state) => state as Slice,
|
||||
) {
|
||||
const [state, setState] = useState(initial);
|
||||
const trueState = use(selector);
|
||||
|
||||
useEffect(() => {
|
||||
setState(trueState);
|
||||
}, [trueState]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function useMutation() {
|
||||
const counterStoreContext = useStore();
|
||||
|
||||
return function (recipe: (draft: Draft<Type>) => void) {
|
||||
counterStoreContext.setState(
|
||||
produce<Type>((draft) => {
|
||||
recipe(draft);
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return { Provider, use, useMutation, useStore, useDeferred };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './createNextSafeStore';
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@blitzkit/scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^3.10.1",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.7"
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createVanillaExtractPlugin } from '@vanilla-extract/next-plugin';
|
||||
|
||||
const withVanillaExtract = createVanillaExtractPlugin({ tests: /\.css$/ });
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
...withVanillaExtract({
|
||||
webpack: (config, { isServer }) => {
|
||||
config.module.rules.push(
|
||||
{
|
||||
test: /\.glsl$/,
|
||||
type: 'asset/source',
|
||||
},
|
||||
{
|
||||
test: /\.proto$/,
|
||||
type: 'asset/source',
|
||||
},
|
||||
);
|
||||
|
||||
// if (isServer) {
|
||||
// writeFileSync(
|
||||
// 'public/assets/versions.json',
|
||||
// JSON.stringify(
|
||||
// readdirSync('docs/changelogs').map((file) =>
|
||||
// file.replace('.md', ''),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
return config;
|
||||
},
|
||||
}),
|
||||
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)?',
|
||||
headers: [{ key: 'X-Frame-Options', value: 'DENY' }],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
distDir: 'dist/website',
|
||||
reactStrictMode: true,
|
||||
|
||||
experimental: {
|
||||
workerThreads: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@blitzkit/website",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/themes": "^3.1.3",
|
||||
"@blitzkit/core": "workspace:*",
|
||||
"@nivo/bar": "^0.87.0",
|
||||
"three": "0.159.0",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^14.2.7",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@react-three/drei": "^9.111.5",
|
||||
"@react-three/fiber": "^8.17.6",
|
||||
"react": "^18.3.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-fuzzysort": "^1.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"zustand": "^4.5.5",
|
||||
"react-promise-suspense": "^0.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@vanilla-extract/next-plugin": "^2.4.5",
|
||||
"@types/react": "^18.3.5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -0,0 +1 @@
|
||||
["1.10.0","1.10.1","1.10.2","1.11.0","1.12.0","1.12.1","1.12.2","1.12.3","1.12.4","1.12.5","1.12.6","1.12.7","1.12.8","1.12.9","1.13.0","1.13.1","1.13.2","1.13.3","1.14.0","1.14.1","1.14.2","1.14.3","1.14.4","1.15.0","1.15.1","1.15.10","1.15.11","1.15.12","1.15.13","1.15.2","1.15.3","1.15.4","1.15.5","1.15.6","1.15.7","1.15.8","1.15.9","1.16.0","1.16.1","1.16.2","1.16.3","1.16.4","1.17.0","1.17.1","1.17.2","1.18.0","1.18.1","1.18.2","1.18.3","1.19.0","1.19.1","1.19.2","1.19.3","1.19.4","1.19.5","1.19.6","1.20.0","1.20.1","1.20.2","1.20.3","1.20.4","1.20.5","1.21.0","1.21.1","1.21.2","1.21.3","1.22.0","1.22.1","1.22.2","1.22.3","1.22.4","1.22.5","1.22.6","1.3.0","1.3.1","1.3.2","1.3.3","1.4.0","1.4.1","1.5.0","1.5.1","1.5.2","1.5.3","1.5.4","1.6.0","1.7.0","1.7.1","1.7.2","1.7.3-1.7.4","1.7.5","1.7.6","1.7.7","1.8.0","1.8.1","1.9.0","1.9.1","1.9.2","1.9.3","1.9.4","1.9.5"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 692 B |
@@ -0,0 +1,298 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package BattleResultsGenerated;
|
||||
|
||||
import "PlayerAvatarImage.proto";
|
||||
|
||||
message Int32List {
|
||||
repeated int32 values = 1;
|
||||
}
|
||||
|
||||
message PlayerInfo {
|
||||
message AvatarEntry {
|
||||
int32 key = 1;
|
||||
.PlayerAvatarInfo value = 2;
|
||||
}
|
||||
|
||||
string name = 1;
|
||||
int32 prebattleID = 2;
|
||||
int32 team = 3;
|
||||
int64 clanID = 4;
|
||||
string clanTag = 5;
|
||||
repeated int32 chatMessageCount = 6;
|
||||
repeated .BattleResultsGenerated.PlayerInfo.AvatarEntry avatar = 7;
|
||||
int32 profileBackground = 8;
|
||||
int32 vehicleRank = 9;
|
||||
}
|
||||
|
||||
message TeamInfo {
|
||||
string title = 1;
|
||||
string icon = 2;
|
||||
}
|
||||
|
||||
message VehiclePublicResults {
|
||||
message ExtVehAchievementsEntry {
|
||||
int32 key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
message ExtAccAchievementsEntry {
|
||||
int32 key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
int32 health = 1;
|
||||
int32 credits = 2;
|
||||
int32 xp = 3;
|
||||
int32 shots = 4;
|
||||
int32 directHits = 5;
|
||||
int32 explosionHits = 6;
|
||||
int32 piercings = 7;
|
||||
int32 damageDealt = 8;
|
||||
int32 damageAssistedRadio = 9;
|
||||
int32 damageAssistedTrack = 10;
|
||||
int32 damageReceived = 11;
|
||||
int32 directHitsReceived = 12;
|
||||
int32 noDamageDirectHitsReceived = 13;
|
||||
int32 explosionHitsReceived = 14;
|
||||
int32 piercingsReceived = 15;
|
||||
int32 spotted = 16;
|
||||
int32 damaged = 17;
|
||||
int32 kills = 18;
|
||||
int32 tdamageDealt = 19;
|
||||
int32 tkills = 20;
|
||||
int32 capturePoints = 21;
|
||||
int32 droppedCapturePoints = 22;
|
||||
int32 mileage = 23;
|
||||
int32 lifeTime = 24;
|
||||
int32 killerID = 25;
|
||||
repeated int32 achievements = 26;
|
||||
repeated .BattleResultsGenerated.VehiclePublicResults.ExtVehAchievementsEntry extVehAchievements = 27;
|
||||
repeated .BattleResultsGenerated.VehiclePublicResults.ExtAccAchievementsEntry extAccAchievements = 28;
|
||||
int32 xpDamage = 29;
|
||||
int32 xpAssist = 30;
|
||||
int32 xpTeamBonus = 31;
|
||||
int32 wpEarned = 32;
|
||||
int32 wpStolen = 33;
|
||||
int32 heroCreditsBonus = 34;
|
||||
int32 heroXpBonus = 35;
|
||||
int64 accountDBID = 101;
|
||||
int32 team = 102;
|
||||
int32 serializedVehType = 103;
|
||||
int32 gold = 104;
|
||||
int32 deathReason = 105;
|
||||
int32 camouflage = 106;
|
||||
float mmr = 107;
|
||||
float mmrDiff = 108;
|
||||
int32 calibrationBattlesLeft = 109;
|
||||
int32 healedHP = 114;
|
||||
int32 wasHealedFor = 115;
|
||||
int32 xpActivity = 36;
|
||||
int32 customization = 116;
|
||||
int32 potentialEnemyDamage = 117;
|
||||
int32 rammingDamage = 118;
|
||||
int32 helpedToKill = 119;
|
||||
}
|
||||
|
||||
message BattleResultsFinalXp {
|
||||
int32 xpDamagePure = 1;
|
||||
int32 xpDamagePrem = 2;
|
||||
int32 xpAssistPure = 3;
|
||||
int32 xpAssistPrem = 4;
|
||||
int32 xpActivityPure = 5;
|
||||
int32 xpActivityPrem = 6;
|
||||
int32 xpTeamBonusPure = 7;
|
||||
int32 xpTeamBonusPrem = 8;
|
||||
int32 healingXpPure = 9;
|
||||
int32 healingXpPrem = 10;
|
||||
int32 heroXpBonusPure = 11;
|
||||
int32 heroXpBonusPrem = 12;
|
||||
int32 earnedFreeXpPure = 13;
|
||||
int32 earnedFreeXpPrem = 14;
|
||||
int32 earnedAvatarXpPure = 15;
|
||||
int32 earnedAvatarXpPrem = 16;
|
||||
int32 xpNoModifiers = 17;
|
||||
int32 xpPure = 18;
|
||||
int32 xpPrem = 19;
|
||||
int32 totalFreeXpPure = 20;
|
||||
int32 totalFreeXpPrem = 21;
|
||||
int32 totalAvatarXpPure = 22;
|
||||
int32 totalAvatarXpPrem = 23;
|
||||
}
|
||||
|
||||
message Ribbons {
|
||||
int32 id = 1;
|
||||
int32 grade = 2;
|
||||
int32 amount = 3;
|
||||
int32 value = 4;
|
||||
}
|
||||
|
||||
message BattleResultsUpdate {
|
||||
message ExcellentBattleDataEntry {
|
||||
string key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
message StuffEntry {
|
||||
int32 key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
message RatingBattleStuffBonusEntry {
|
||||
int32 key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
int32 avatarXP = 1;
|
||||
int32 eventCredits = 2;
|
||||
int32 eventGold = 3;
|
||||
int32 eventXP = 4;
|
||||
int32 eventFreeXP = 5;
|
||||
int32 eventAvatarXP = 6;
|
||||
int32 autoRepairCost = 7;
|
||||
repeated int32 autoLoadCost = 8;
|
||||
repeated int32 autoEquipCost = 9;
|
||||
repeated int32 autoProvCost = 10;
|
||||
bool isPremium = 12;
|
||||
int32 premiumXPFactor10 = 13;
|
||||
int32 premiumCreditsFactor10 = 14;
|
||||
int32 xpFactor10 = 15;
|
||||
int32 creditsFactor10 = 16;
|
||||
int32 aogasFactor10 = 17;
|
||||
int32 markOfMastery = 18;
|
||||
repeated .BattleResultsGenerated.BattleResultsUpdate.ExcellentBattleDataEntry excellentBattleData = 19;
|
||||
bytes vehDossier = 20;
|
||||
bytes accDossier = 21;
|
||||
bytes vehDossierPrev = 22;
|
||||
bytes accDossierPrev = 23;
|
||||
int32 autoRepairDiscount = 24;
|
||||
repeated int32 autoEquipDiscount = 25;
|
||||
repeated int32 autoProvDiscount = 26;
|
||||
repeated .BattleResultsGenerated.BattleResultsUpdate.StuffEntry stuff = 27;
|
||||
int32 boosterCredits = 29;
|
||||
int32 boosterXp = 30;
|
||||
int32 boosterFreeXp = 31;
|
||||
int32 boosterAvatarXp = 32;
|
||||
int32 complimentsLimit = 33;
|
||||
repeated int32 autoAbilsCost = 34;
|
||||
repeated int32 autoAbilsDiscount = 35;
|
||||
bool hasHealingReward = 36;
|
||||
int32 healingCredits = 37;
|
||||
int32 healingXp = 38;
|
||||
int32 healingFreeXp = 39;
|
||||
int32 healingAvatarXp = 40;
|
||||
float mmr = 107;
|
||||
float mmrDiff = 108;
|
||||
int32 calibrationBattlesLeft = 109;
|
||||
int32 ratingBattleXp = 110;
|
||||
int32 ratingBattleFreeXp = 111;
|
||||
int32 ratingBattleAvatarXp = 112;
|
||||
repeated .BattleResultsGenerated.BattleResultsUpdate.RatingBattleStuffBonusEntry ratingBattleStuffBonus = 113;
|
||||
repeated .BattleResultsGenerated.Ribbons ribbons = 114;
|
||||
}
|
||||
|
||||
message DropData {
|
||||
message StuffEntry {
|
||||
int32 key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
int32 credits = 1;
|
||||
int32 gold = 2;
|
||||
int32 xp = 3;
|
||||
int32 freeXP = 4;
|
||||
int32 avatarXP = 5;
|
||||
repeated .BattleResultsGenerated.DropData.StuffEntry stuff = 27;
|
||||
}
|
||||
|
||||
message VehicleInteractionDetails {
|
||||
message DamagedModulesEntry {
|
||||
int32 key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
message DestroyedModulesEntry {
|
||||
int32 key = 1;
|
||||
int32 value = 2;
|
||||
}
|
||||
|
||||
bool spotted = 1;
|
||||
int32 deathReason = 2;
|
||||
int32 directHits = 3;
|
||||
int32 directHitWithoutDamage = 4;
|
||||
int32 explosionHits = 5;
|
||||
int32 piercings = 6;
|
||||
int32 damageDealt = 7;
|
||||
int32 damageAssistedTrack = 8;
|
||||
int32 damageAssistedRadio = 9;
|
||||
int32 fire = 10;
|
||||
repeated .BattleResultsGenerated.VehicleInteractionDetails.DamagedModulesEntry damagedModules = 11;
|
||||
repeated .BattleResultsGenerated.VehicleInteractionDetails.DestroyedModulesEntry destroyedModules = 12;
|
||||
int32 ricochetHitWithoutDamage = 13;
|
||||
}
|
||||
|
||||
message InteractionDetails {
|
||||
message VehiclesEntry {
|
||||
int32 key = 1;
|
||||
.BattleResultsGenerated.VehicleInteractionDetails value = 2;
|
||||
}
|
||||
|
||||
repeated .BattleResultsGenerated.InteractionDetails.VehiclesEntry vehicles = 1;
|
||||
}
|
||||
|
||||
message PrebattleStats {
|
||||
int32 freeXp = 1;
|
||||
int32 vehicleXp = 2;
|
||||
}
|
||||
|
||||
message BattleResultsClient {
|
||||
message QuestsProgressEntry {
|
||||
string key = 1;
|
||||
.BattleResultsGenerated.Int32List value = 2;
|
||||
}
|
||||
|
||||
message DropCategoriesEntry {
|
||||
string key = 1;
|
||||
.BattleResultsGenerated.DropData value = 2;
|
||||
}
|
||||
|
||||
message PlayersEntry {
|
||||
int64 key = 1;
|
||||
.BattleResultsGenerated.PlayerInfo value = 2;
|
||||
}
|
||||
|
||||
message VehiclesEntry {
|
||||
int32 key = 1;
|
||||
.BattleResultsGenerated.VehiclePublicResults value = 2;
|
||||
}
|
||||
|
||||
int32 arenaTypeID = 1;
|
||||
int32 arenaCreateTime = 2;
|
||||
int32 winnerTeam = 3;
|
||||
int32 finishReason = 4;
|
||||
int32 duration = 5;
|
||||
int32 bonusType = 6;
|
||||
int32 vehLockMode = 7;
|
||||
.BattleResultsGenerated.VehiclePublicResults public = 8;
|
||||
int32 gameModeID = 9;
|
||||
int32 observerMode = 10;
|
||||
.BattleResultsGenerated.BattleResultsFinalXp finalXp = 11;
|
||||
int32 repair = 136;
|
||||
int32 freeXP = 137;
|
||||
int32 xpPenalty = 145;
|
||||
int32 creditsPenalty = 146;
|
||||
int32 creditsContributionIn = 147;
|
||||
int32 creditsContributionOut = 148;
|
||||
.BattleResultsGenerated.BattleResultsUpdate update = 150;
|
||||
int32 originalXP = 181;
|
||||
int32 originalFreeXP = 182;
|
||||
int32 originalCredits = 183;
|
||||
repeated .BattleResultsGenerated.BattleResultsClient.QuestsProgressEntry questsProgress = 184;
|
||||
repeated .BattleResultsGenerated.BattleResultsClient.DropCategoriesEntry dropCategories = 185;
|
||||
.BattleResultsGenerated.TeamInfo teamInfo = 186;
|
||||
repeated .BattleResultsGenerated.BattleResultsClient.PlayersEntry players = 201;
|
||||
repeated .BattleResultsGenerated.BattleResultsClient.VehiclesEntry vehicles = 301;
|
||||
.BattleResultsGenerated.InteractionDetails interactionDetails = 302;
|
||||
.BattleResultsGenerated.PrebattleStats prebattleStats = 303;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
syntax = "proto3";
|
||||
|
||||
message PlayerAvatarInfo {
|
||||
uint32 id = 1;
|
||||
string image = 2;
|
||||
string image_retina = 3;
|
||||
string grade = 4;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package blitzkit;
|
||||
|
||||
message AverageDefinitions {
|
||||
map<uint32, AverageDefinitionsEntry> averages = 1;
|
||||
Samples samples = 2;
|
||||
uint64 time = 3;
|
||||
}
|
||||
|
||||
message AverageDefinitionsEntry {
|
||||
AverageDefinitionsAllStats mu = 1;
|
||||
AverageDefinitionsAllStats sigma = 2;
|
||||
AverageDefinitionsAllStats r = 3;
|
||||
Samples samples = 4;
|
||||
}
|
||||
|
||||
message AverageDefinitionsAllStats {
|
||||
float battles = 1;
|
||||
float capture_points = 2;
|
||||
float damage_dealt = 3;
|
||||
float damage_received = 4;
|
||||
float dropped_capture_points = 5;
|
||||
float frags = 6;
|
||||
float frags8p = 7;
|
||||
float hits = 8;
|
||||
float losses = 9;
|
||||
float max_frags = 10;
|
||||
float max_xp = 11;
|
||||
float shots = 12;
|
||||
float spotted = 13;
|
||||
float survived_battles = 14;
|
||||
float win_and_survived = 15;
|
||||
float wins = 16;
|
||||
float xp = 17;
|
||||
float battle_life_time = 18;
|
||||
}
|
||||
|
||||
message Samples {
|
||||
uint32 d_1 = 1;
|
||||
uint32 d_7 = 2;
|
||||
uint32 d_30 = 3;
|
||||
uint32 d_60 = 4;
|
||||
uint32 d_90 = 5;
|
||||
uint32 d_120 = 6;
|
||||
uint32 total = 7;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package blitzkit;
|
||||
|
||||
message Reviews {
|
||||
map<uint32, Review> reviews = 1;
|
||||
}
|
||||
|
||||
message Review {
|
||||
float last_updated = 1;
|
||||
repeated Video videos = 2;
|
||||
}
|
||||
|
||||
message Video {
|
||||
string id = 1;
|
||||
string author = 2;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Host: https://blitzkit.app
|
||||
|
||||
Sitemap: https://blitzkit.app/sitemap.txt
|
||||
Sitemap: https://blitzkit.app/sitemap.xml
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { Region } from '../../../../../../constants/regions';
|
||||
import getRatingInfo from '../../../../../../core/blitz/getRatingInfo';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { region: Region } },
|
||||
) {
|
||||
return NextResponse.json(await getRatingInfo(params.region));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { Region } from '../../../../../../../constants/regions';
|
||||
import { getRatingLeague } from '../../../../../../../core/blitz/getRatingLeague';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { region: Region; league: number } },
|
||||
) {
|
||||
return NextResponse.json(
|
||||
await getRatingLeague(params.region, params.league, true),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { Region } from '../../../../../../../constants/regions';
|
||||
import { searchCurrentRatingPlayers } from '../../../../../../../core/blitz/searchCurrentRatingPlayers';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { region: Region; username: string } },
|
||||
) {
|
||||
return NextResponse.json(
|
||||
await searchCurrentRatingPlayers(params.region, params.username),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertSecret } from '../../../../../core/blitzkit/secret';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { code: string } },
|
||||
) {
|
||||
const response = await fetch(
|
||||
`https://www.patreon.com/api/oauth2/token?code=${
|
||||
params.code
|
||||
}&grant_type=authorization_code&client_id=${assertSecret(
|
||||
process.env.NEXT_PUBLIC_PATREON_CLIENT_ID,
|
||||
)}&client_secret=${assertSecret(
|
||||
process.env.PATREON_CLIENT_SECRET,
|
||||
)}&redirect_uri=${assertSecret(process.env.NEXT_PUBLIC_PATREON_REDIRECT_URI)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
},
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
return NextResponse.json(json);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertSecret } from '../../../../../core/blitzkit/secret';
|
||||
|
||||
interface PatreonMembershipResponse {
|
||||
data: {
|
||||
attributes: unknown;
|
||||
id: `${number}`;
|
||||
relationships: {
|
||||
memberships: {
|
||||
data: {
|
||||
id: string; // uuid
|
||||
type: 'member';
|
||||
}[];
|
||||
};
|
||||
};
|
||||
type: 'user';
|
||||
};
|
||||
included: (
|
||||
| {
|
||||
attributes: {
|
||||
patron_status:
|
||||
| 'active_patron'
|
||||
| 'declined_patron'
|
||||
| 'former_patron'
|
||||
| null; // null = never a paid member
|
||||
};
|
||||
id: string; // uuid
|
||||
relationships: {
|
||||
campaign: {
|
||||
data: {
|
||||
id: `${number}`; // id of my campaign to check against
|
||||
type: 'campaign';
|
||||
};
|
||||
links: {
|
||||
related: string; // url
|
||||
};
|
||||
};
|
||||
};
|
||||
type: 'member';
|
||||
}
|
||||
| {
|
||||
attributes: {};
|
||||
id: `${number}`;
|
||||
type: 'campaign'; // ignore this discriminator
|
||||
}
|
||||
)[];
|
||||
links: {
|
||||
self: 'https://www.patreon.com/api/oauth2/v2/user/124201389';
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { token: string } },
|
||||
) {
|
||||
const response = await fetch(
|
||||
encodeURI(
|
||||
'https://www.patreon.com/api/oauth2/v2/identity?include=memberships.campaign&fields[member]=patron_status',
|
||||
),
|
||||
{ headers: { Authorization: `Bearer ${params.token}` } },
|
||||
);
|
||||
|
||||
if (!response.ok) return NextResponse.json(false);
|
||||
|
||||
const json = (await response.json()) as PatreonMembershipResponse;
|
||||
|
||||
return NextResponse.json(
|
||||
json.included.some(
|
||||
(item) =>
|
||||
item.type === 'member' &&
|
||||
item.relationships.campaign.data.id ===
|
||||
assertSecret(process.env.PATREON_CAMPAIGN_ID) &&
|
||||
item.attributes.patron_status === 'active_patron',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { assertSecret } from '../../../../../core/blitzkit/secret';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { token: string } },
|
||||
) {
|
||||
const response = await fetch(
|
||||
`https://www.patreon.com/api/oauth2/token?grant_type=refresh_token&refresh_token=${
|
||||
params.token
|
||||
}&client_id=${assertSecret(
|
||||
process.env.NEXT_PUBLIC_PATREON_CLIENT_ID,
|
||||
)}&client_secret=${assertSecret(process.env.PATREON_CLIENT_SECRET)}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
return NextResponse.json(json);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { tankAverages } from '../../../core/blitzstars/tankAverages';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await tankAverages);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
|
||||
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
|
||||
import { usersDatabase } from '../../../../../databases/users';
|
||||
import {
|
||||
BlitzkitResponse,
|
||||
BlitzkitResponseError,
|
||||
} from '../../../../../hooks/useTankVotes';
|
||||
import { TankVoteCategory } from '../cast/route';
|
||||
|
||||
export interface TankVotes {
|
||||
categories: Record<TankVoteCategory, number | null>;
|
||||
votes: number;
|
||||
last_updated?: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
const id = Number(params.id);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
|
||||
if (isNaN(id) || !awaitedTankDefinitions[id]) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'INVALID_TANK_ID',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
const blitzIdString = request.nextUrl.searchParams.get('player');
|
||||
const blitzId = Number(blitzIdString);
|
||||
const blitzToken = request.nextUrl.searchParams.get('token');
|
||||
|
||||
if ((blitzIdString === null) !== (blitzToken === null)) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'UNDEFINED_TOKEN_OR_PLAYER_ID',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
if (
|
||||
blitzIdString !== null &&
|
||||
(!(await isValidBlitzId(blitzId, blitzToken ?? undefined)) ||
|
||||
isNaN(blitzId))
|
||||
) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'INVALID_TOKEN_OR_PLAYER_ID',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
try {
|
||||
const aggregation = await usersDatabase.tankVote.aggregate({
|
||||
where: { id },
|
||||
_count: { id: true },
|
||||
_avg: {
|
||||
easiness: true,
|
||||
firepower: true,
|
||||
maneuverability: true,
|
||||
survivability: true,
|
||||
},
|
||||
});
|
||||
const userVote = await usersDatabase.tankVote.findUnique({
|
||||
where: { id, user_blitz_id: blitzId },
|
||||
select: { last_updated: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
data: {
|
||||
categories: aggregation._avg,
|
||||
votes: aggregation._count.id,
|
||||
last_updated: userVote?.last_updated.getTime(),
|
||||
},
|
||||
} satisfies BlitzkitResponse<TankVotes>);
|
||||
} catch (message) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'DATABASE_ERROR',
|
||||
message,
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
|
||||
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
|
||||
import { usersDatabase } from '../../../../../databases/users';
|
||||
import {
|
||||
BlitzkitResponse,
|
||||
BlitzkitResponseError,
|
||||
} from '../../../../../hooks/useTankVotes';
|
||||
import { StarsInt } from '../../../../tools/tankopedia/[id]/components/Model/MetaSection/components/Stars';
|
||||
|
||||
export const TANK_VOTE_CATEGORIES = [
|
||||
'easiness',
|
||||
'firepower',
|
||||
'maneuverability',
|
||||
'survivability',
|
||||
];
|
||||
|
||||
export type TankVoteCategory = (typeof TANK_VOTE_CATEGORIES)[number];
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
const tankId = Number(params.id);
|
||||
const awaitedTankDefinitions = await tankDefinitions;
|
||||
|
||||
if (isNaN(tankId) || !awaitedTankDefinitions[tankId]) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'INVALID_TANK_ID',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
const blitzIdString = request.nextUrl.searchParams.get('player');
|
||||
const blitzId = Number(blitzIdString);
|
||||
const blitzToken = request.nextUrl.searchParams.get('token');
|
||||
|
||||
if (blitzIdString === null || blitzToken === null) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'UNDEFINED_TOKEN_OR_PLAYER_ID',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
if (
|
||||
blitzIdString !== null &&
|
||||
(!(await isValidBlitzId(blitzId, blitzToken ?? undefined)) ||
|
||||
isNaN(blitzId))
|
||||
) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'INVALID_TOKEN_OR_PLAYER_ID',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
const votes: Partial<Record<TankVoteCategory, StarsInt>> = {};
|
||||
let recognizedVoteCategories = 0;
|
||||
|
||||
for (const category of TANK_VOTE_CATEGORIES) {
|
||||
const voteRaw = request.nextUrl.searchParams.get(category);
|
||||
|
||||
if (voteRaw === null) continue;
|
||||
|
||||
const vote = Number(voteRaw);
|
||||
|
||||
if (isNaN(vote) || !Number.isInteger(vote) || vote < 1 || vote > 5) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'INVALID_VOTE',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
votes[category] = vote as StarsInt;
|
||||
recognizedVoteCategories++;
|
||||
}
|
||||
|
||||
if (recognizedVoteCategories === 0) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'NO_VOTES',
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
|
||||
try {
|
||||
await usersDatabase.user.upsert({
|
||||
where: { blitz_id: blitzId },
|
||||
update: {
|
||||
last_used: new Date(),
|
||||
tank_votes: {
|
||||
upsert: {
|
||||
where: { id: tankId },
|
||||
update: { ...votes, last_updated: new Date() },
|
||||
create: { ...votes, id: tankId, last_updated: new Date() },
|
||||
},
|
||||
},
|
||||
},
|
||||
create: {
|
||||
blitz_id: blitzId,
|
||||
last_used: new Date(),
|
||||
tank_votes: {
|
||||
create: { ...votes, id: tankId, last_updated: new Date() },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
data: undefined,
|
||||
} satisfies BlitzkitResponse);
|
||||
} catch (message) {
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
error: 'DATABASE_ERROR',
|
||||
message,
|
||||
} satisfies BlitzkitResponseError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import * as App from '../../../stores/app';
|
||||
|
||||
export interface PatreonAuthResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: 'Bearer';
|
||||
scope: string;
|
||||
refresh_token: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { provider: string };
|
||||
searchParams: { [key: string]: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const mutateApp = App.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
switch (params.provider) {
|
||||
case 'wargaming': {
|
||||
if (
|
||||
!searchParams.expires_at ||
|
||||
!searchParams.access_token ||
|
||||
!searchParams.account_id
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
mutateApp((draft) => {
|
||||
draft.logins.wargaming = {
|
||||
id: Number(searchParams.account_id),
|
||||
token: searchParams.access_token,
|
||||
expires: Number(searchParams.expires_at) * 1000,
|
||||
};
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'patreon': {
|
||||
if (!searchParams.code) break;
|
||||
|
||||
// cannot await here because async breaks router.push :(
|
||||
fetch(`/api/patreon/auth/${searchParams.code}`)
|
||||
.then((response) => response.json() as Promise<PatreonAuthResponse>)
|
||||
.then((data) => {
|
||||
mutateApp((draft) => {
|
||||
draft.logins.patreon = {
|
||||
token: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expires: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.error(`Unknown provider: ${params.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.push(searchParams.return ?? '/');
|
||||
});
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Flex
|
||||
style={{ flex: 1 }}
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="2"
|
||||
>
|
||||
<Heading>Verifying you...</Heading>
|
||||
<Text color="gray">You'll be on your way shortly.</Text>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { idToRegion } from '@blitzkit/core';
|
||||
import * as App from '../../stores/app';
|
||||
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants';
|
||||
import { PatreonAuthResponse } from '../auth/[provider]/page';
|
||||
|
||||
type Extension =
|
||||
| {
|
||||
status: 'ok';
|
||||
data: {
|
||||
access_token: string;
|
||||
account_id: number;
|
||||
expires_at: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
};
|
||||
|
||||
/**
|
||||
* Wargaming: 14 (refresh: 7)
|
||||
* Patreon: 31 (refresh: 15)
|
||||
*/
|
||||
export function Checks() {
|
||||
const mutateApp = App.useMutation();
|
||||
const logins = App.use((state) => state.logins);
|
||||
const appStore = App.useStore();
|
||||
const policiesAgreementIndex = App.use(
|
||||
(state) => state.policiesAgreementIndex,
|
||||
);
|
||||
const pathname = usePathname();
|
||||
const isLegal = pathname.startsWith('/legal');
|
||||
const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function unsubscribe() {
|
||||
window.removeEventListener('scroll', show);
|
||||
window.removeEventListener('pointermove', show);
|
||||
window.removeEventListener('pointerdown', show);
|
||||
}
|
||||
|
||||
function show() {
|
||||
setShowPoliciesAgreement(true);
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
if (
|
||||
policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX &&
|
||||
!isLegal
|
||||
) {
|
||||
window.addEventListener('scroll', show);
|
||||
window.addEventListener('pointermove', show);
|
||||
window.addEventListener('pointerdown', show);
|
||||
}
|
||||
|
||||
return unsubscribe;
|
||||
}, [policiesAgreementIndex, isLegal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (logins.wargaming) {
|
||||
const expiresIn = logins.wargaming.expires - Date.now();
|
||||
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
|
||||
|
||||
if (expiresInDays < 0) {
|
||||
mutateApp((draft) => {
|
||||
draft.logins.wargaming = undefined;
|
||||
});
|
||||
} else if (expiresInDays < 7) {
|
||||
const { wargaming } = appStore.getState().logins;
|
||||
|
||||
if (!wargaming) return;
|
||||
|
||||
fetch(
|
||||
`https://api.worldoftanks.${idToRegion(
|
||||
wargaming.id,
|
||||
)}/wot/auth/prolongate/?application_id=${assertSecret(process.env.WARGAMING_APPLICATION_ID)}&access_token=${
|
||||
wargaming.token
|
||||
}`,
|
||||
)
|
||||
.then((response) => response.json() as Promise<Extension>)
|
||||
.then((json) => {
|
||||
mutateApp((draft) => {
|
||||
if (json.status === 'error') {
|
||||
draft.logins.wargaming = undefined;
|
||||
} else {
|
||||
draft.logins.wargaming = {
|
||||
id: wargaming.id,
|
||||
token: json.data.access_token,
|
||||
expires: json.data.expires_at * 1000,
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (logins.patreon) {
|
||||
const expiresIn = logins.patreon.expires - Date.now();
|
||||
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
|
||||
|
||||
if (expiresInDays < 0) {
|
||||
mutateApp((draft) => {
|
||||
draft.logins.patreon = undefined;
|
||||
});
|
||||
} else if (expiresInDays < 15) {
|
||||
const { patreon } = appStore.getState().logins;
|
||||
|
||||
if (!patreon) return;
|
||||
|
||||
fetch(`/api/patreon/refresh/${patreon.refreshToken}`)
|
||||
.then((response) => response.json() as Promise<PatreonAuthResponse>)
|
||||
.then((data) => {
|
||||
mutateApp((draft) => {
|
||||
draft.logins.patreon = {
|
||||
token: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expires: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!showPoliciesAgreement) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="end"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'var(--color-overlay)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
p="8"
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'var(--color-panel-solid)',
|
||||
}}
|
||||
justify="center"
|
||||
>
|
||||
<Flex
|
||||
align="start"
|
||||
gap="4"
|
||||
style={{
|
||||
maxWidth: 640 * 2,
|
||||
}}
|
||||
direction="column"
|
||||
>
|
||||
<Flex direction="column" gap="2">
|
||||
<Heading>
|
||||
{policiesAgreementIndex === -1
|
||||
? "BlitzKit's policies"
|
||||
: "BlitzKit's policies have changed"}
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
This website utilizes cookies to perform analytics and personalize
|
||||
your experience. You can learn more through{' '}
|
||||
<Link href="/docs/legal/privacy-policy">our privacy policy</Link>.
|
||||
You may opt out of personalized advertizement by visiting{' '}
|
||||
<Link href="https://optout.aboutads.info/">WebChoices</Link>. By
|
||||
using BlitzKit, you also agree to our{' '}
|
||||
<Link href="/docs/legal/terms-of-service">terms of service</Link>.
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
mt="2"
|
||||
onClick={() => {
|
||||
appStore.setState({
|
||||
policiesAgreementIndex: CURRENT_POLICIES_AGREEMENT_INDEX,
|
||||
});
|
||||
setShowPoliciesAgreement(false);
|
||||
}}
|
||||
>
|
||||
I agree
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const BRANCH_NAMES: Record<string, string> = {
|
||||
dev: 'dev',
|
||||
opentest: 'opentest',
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { imgur } from '@blitzkit/core';
|
||||
import { Code, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import { BRANCH_NAMES } from './constants';
|
||||
|
||||
export function Hero() {
|
||||
const isBranchNamed =
|
||||
process.env.NEXT_PUBLIC_ASSET_BRANCH &&
|
||||
BRANCH_NAMES[process.env.NEXT_PUBLIC_ASSET_BRANCH];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: `url(${imgur('rUPie9G', { format: 'jpeg' })})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
px="4"
|
||||
py="9"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background:
|
||||
'linear-gradient(var(--color-panel-translucent), var(--color-background))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="6"
|
||||
align="center"
|
||||
justify="center"
|
||||
position="relative"
|
||||
width="100%"
|
||||
>
|
||||
<Flex direction="column" align="center" gap="1">
|
||||
<Heading size="9" weight="bold" align="center">
|
||||
<Flex
|
||||
align="center"
|
||||
gap={{ initial: '1', xs: '4' }}
|
||||
direction={{ initial: 'column', xs: 'row' }}
|
||||
>
|
||||
BlitzKit
|
||||
{isBranchNamed && (
|
||||
<Code size="8" color="gray" highContrast variant="outline">
|
||||
{BRANCH_NAMES[process.env.NEXT_PUBLIC_ASSET_BRANCH!]}
|
||||
</Code>
|
||||
)}
|
||||
</Flex>
|
||||
</Heading>
|
||||
|
||||
{!isBranchNamed && (
|
||||
<Text color="gray" align="center" size="4">
|
||||
Everything World of Tanks Blitz
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Flex, Spinner } from '@radix-ui/themes';
|
||||
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<Flex align="center" justify="center" flexGrow="1">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { assertSecret, patreonLoginUrl } from '@blitzkit/core';
|
||||
import { ArrowRightIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Flex,
|
||||
Link,
|
||||
Separator,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { PatreonIcon } from '../../components/PatreonIcon';
|
||||
import { useAdExempt } from '../../hooks/useAdExempt';
|
||||
import * as App from '../../stores/app';
|
||||
|
||||
export function Plugs() {
|
||||
const [hasPatreon, setHasPatreon] = useState(false);
|
||||
const exempt = useAdExempt();
|
||||
const patreon = App.use((state) => state.logins.patreon);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setHasPatreon(patreon !== undefined);
|
||||
}, [patreon]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
direction="column"
|
||||
justify="center"
|
||||
px="4"
|
||||
py="8"
|
||||
gap="6"
|
||||
>
|
||||
{assertSecret(process.env.NEXT_PUBLIC_PROMOTE_OPENTEST) === 'true' &&
|
||||
assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH) !== 'opentest' && (
|
||||
<>
|
||||
<Flex direction="column" gap="3" align="center">
|
||||
<Flex align="center" gap="2">
|
||||
<Badge color="green">NEW</Badge>
|
||||
<Text size="5">
|
||||
BlitzKit{' '}
|
||||
<Code variant="outline" size="4" color="gray" highContrast>
|
||||
opentest
|
||||
</Code>{' '}
|
||||
is available now{' '}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Link href="https://opentest.blitzkit.app/" target="_blank">
|
||||
<Button color="green">
|
||||
BlitzKit opentest <ArrowRightIcon />
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
<Separator size="3" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Flex direction="column" gap="3" align="center">
|
||||
<Flex gap="1" align="center">
|
||||
<Text size="4" align="center">
|
||||
{exempt
|
||||
? 'You are supporting BlitzKit, thank you!'
|
||||
: 'Active Patreon supports go ad-free'}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="2" wrap="wrap" justify="center">
|
||||
<Link href="https://www.patreon.com/tresabhi" target="_blank">
|
||||
<Button>
|
||||
<PatreonIcon width={15} height={15} />{' '}
|
||||
{exempt ? 'Manage membership' : 'Support on Patreon'}
|
||||
</Button>
|
||||
</Link>
|
||||
{!hasPatreon && !exempt && (
|
||||
<Link href={patreonLoginUrl()}>
|
||||
<Button variant="outline">Log in with Patreon</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { assertSecret, imgur, ImgurSize } from '@blitzkit/core';
|
||||
import { CaretRightIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Flex,
|
||||
FlexProps,
|
||||
Heading,
|
||||
Text,
|
||||
} from '@radix-ui/themes';
|
||||
import { Link } from '../../components/Link';
|
||||
import { Tool } from '../../constants/tools';
|
||||
|
||||
type ToolCardProps = FlexProps & {
|
||||
tool: Tool;
|
||||
};
|
||||
|
||||
export function ToolCard({ tool, style, ...props }: ToolCardProps) {
|
||||
const size = tool.significant
|
||||
? ImgurSize.HugeThumbnail
|
||||
: ImgurSize.LargeThumbnail;
|
||||
const unavailableOnBranch = tool.branches?.every(
|
||||
(branch) => branch !== assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH),
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
style={{
|
||||
opacity: unavailableOnBranch ? 0.25 : 1,
|
||||
position: 'relative',
|
||||
borderRadius: 'var(--radius-2)',
|
||||
overflow: 'hidden',
|
||||
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size })})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
height: tool.significant ? 'min(320px, 50vh)' : '256px',
|
||||
...style,
|
||||
}}
|
||||
flexGrow={tool.significant ? undefined : '1'}
|
||||
flexBasis={tool.significant ? undefined : { initial: undefined, sm: '0' }}
|
||||
{...props}
|
||||
>
|
||||
<Link
|
||||
disabled={unavailableOnBranch}
|
||||
href={tool.href ?? `/tools/${tool.id}`}
|
||||
target={tool.href ? '_blank' : undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
flexGrow="1"
|
||||
style={{
|
||||
backgroundImage: `url(${imgur(tool.image, { format: 'jpeg', size })})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
px={{ initial: '6', md: tool.significant ? '8' : '6' }}
|
||||
py={{ initial: '4', sm: tool.significant ? '6' : '4' }}
|
||||
gap="4"
|
||||
align="center"
|
||||
justify="between"
|
||||
width="100%"
|
||||
direction={{ initial: 'column', xs: 'row' }}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel-translucent)',
|
||||
backdropFilter: 'blur(64px)',
|
||||
WebkitBackdropFilter: 'blur(64px)',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
justify="center"
|
||||
align={{ initial: 'center', xs: 'start' }}
|
||||
>
|
||||
<Heading
|
||||
align={{ initial: 'center', sm: 'left' }}
|
||||
size={{ initial: '6', sm: tool.significant ? '7' : '5' }}
|
||||
weight="medium"
|
||||
>
|
||||
{tool.title}
|
||||
</Heading>
|
||||
<Text
|
||||
align={{ initial: 'center', sm: 'left' }}
|
||||
size={{ initial: '3', sm: tool.significant ? '4' : '3' }}
|
||||
color="gray"
|
||||
>
|
||||
{unavailableOnBranch ? (
|
||||
<>
|
||||
Unavailable on{' '}
|
||||
<Code>
|
||||
{assertSecret(process.env.NEXT_PUBLIC_ASSET_BRANCH)}
|
||||
</Code>
|
||||
</>
|
||||
) : (
|
||||
tool.description
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{!unavailableOnBranch && (
|
||||
<Button
|
||||
highContrast={tool.button.highContrast}
|
||||
size={{
|
||||
initial: undefined,
|
||||
sm: tool.significant ? '3' : undefined,
|
||||
}}
|
||||
color={tool.button.color}
|
||||
style={{ cursor: 'inherit' }}
|
||||
>
|
||||
{tool.button.text}
|
||||
<CaretRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Link>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Table } from '@radix-ui/themes';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import { use } from 'react';
|
||||
import { Link } from '../../../../components/Link';
|
||||
import PageWrapper from '../../../../components/PageWrapper';
|
||||
import { assertSecret } from '../../../../core/blitzkit/secret';
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: { document: string; directory: string };
|
||||
}) {
|
||||
const markdown = use(
|
||||
fetch(
|
||||
`https://raw.githubusercontent.com/tresabhi/blitzkit/${assertSecret(
|
||||
process.env.NEXT_PUBLIC_ASSET_BRANCH,
|
||||
)}/docs/${params.directory}/${params.document}.md`,
|
||||
).then((response) => response.text()),
|
||||
);
|
||||
const title = markdown.split('\n')[0].replaceAll(/[^a-zA-Z0-9 \.&]/g, '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{title}</title>
|
||||
|
||||
<PageWrapper>
|
||||
<Markdown
|
||||
options={{
|
||||
overrides: {
|
||||
a: { component: Link },
|
||||
table: { component: Table.Root },
|
||||
thead: { component: Table.Header },
|
||||
tbody: { component: Table.Body },
|
||||
tr: { component: Table.Row },
|
||||
th: { component: Table.ColumnHeaderCell },
|
||||
td: { component: Table.Cell },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</Markdown>
|
||||
</PageWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import versions from '../../../../public/assets/versions.json';
|
||||
import { Link } from '../../../components/Link';
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<>
|
||||
<title>BlitzKit - Patch Notes</title>
|
||||
<PageWrapper>
|
||||
<Heading>BlitzKit patch notes</Heading>
|
||||
|
||||
<Flex wrap="wrap" gap="4">
|
||||
{versions
|
||||
.sort(compareVersions)
|
||||
.reverse()
|
||||
.map((version) => {
|
||||
return (
|
||||
<Link key={version} href={`/docs/changelogs/${version}`}>
|
||||
{version}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</PageWrapper>{' '}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
*,
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.radix-themes {
|
||||
--default-font-family: var(--font-inter);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Theme } from '@radix-ui/themes';
|
||||
import '@radix-ui/themes/styles.css';
|
||||
import { Roboto_Flex } from 'next/font/google';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode, Suspense } from 'react';
|
||||
import { Footer } from '../components/Footer';
|
||||
import { Navbar } from '../components/Navbar';
|
||||
import { Party3 } from '../components/Party3';
|
||||
import * as App from '../stores/app';
|
||||
import { Checks } from './components/Checks';
|
||||
import { PageLoader } from './components/PageLoader';
|
||||
import './layout.css';
|
||||
|
||||
const robotoFlex = Roboto_Flex({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-roboto-flex',
|
||||
});
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const hideNav = false;
|
||||
const isRoot = pathname === '/';
|
||||
|
||||
return (
|
||||
<App.Provider>
|
||||
<html lang="en" className={robotoFlex.className}>
|
||||
<head>
|
||||
{isRoot && (
|
||||
<>
|
||||
<title>BlitzKit</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="🎉 Tools for everything World of Tanks Blitz"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<meta property="og:site_name" content="BlitzKit" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.png" />
|
||||
|
||||
<Party3 />
|
||||
</head>
|
||||
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
backgroundColor: hideNav ? 'transparent' : undefined,
|
||||
}}
|
||||
>
|
||||
<Theme
|
||||
appearance="dark"
|
||||
panelBackground="solid"
|
||||
radius="full"
|
||||
accentColor="amber"
|
||||
suppressHydrationWarning
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
// paddingTop: isEmbed ? undefined : NAVBAR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{!hideNav && <Navbar />}
|
||||
|
||||
<Checks />
|
||||
|
||||
<Suspense fallback={<PageLoader />}>{children}</Suspense>
|
||||
|
||||
{!hideNav && <Footer />}
|
||||
</Flex>
|
||||
</Theme>
|
||||
</body>
|
||||
</html>
|
||||
</App.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { DiscordLogoIcon, HomeIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { redirect, usePathname } from 'next/navigation';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
|
||||
const redirects = new Map([
|
||||
['/tools/ratings', '/tools/rating'],
|
||||
['/tools/tank-performance', '/tools/performance'],
|
||||
]);
|
||||
|
||||
export default function Page() {
|
||||
const pathname = usePathname();
|
||||
|
||||
redirects.forEach((to, from) => {
|
||||
if (pathname.startsWith(from)) {
|
||||
redirect(to);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Flex direction="column" justify="center" align="center" flexGrow="1">
|
||||
<Heading>404: page not found</Heading>
|
||||
<Text color="gray">Where would you like to go?</Text>
|
||||
|
||||
<Flex mt="4" gap="2">
|
||||
<Link href="/">
|
||||
<Button>
|
||||
<HomeIcon /> Home
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://discord.gg/nDt7AjGJQH">
|
||||
<Button variant="outline">
|
||||
<DiscordLogoIcon /> Discord
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Heading } from '@radix-ui/themes';
|
||||
import { AdMidSectionResponsive } from '../components/AdMidSectionResponsive';
|
||||
import {
|
||||
compareTool,
|
||||
discordTool,
|
||||
moreTool,
|
||||
ratingTool,
|
||||
sessionTool,
|
||||
tankopediaTool,
|
||||
tankPerformanceTool,
|
||||
} from '../constants/tools';
|
||||
import { useAdExempt } from '../hooks/useAdExempt';
|
||||
import { Hero } from './components/Hero';
|
||||
import { Plugs } from './components/Plugs';
|
||||
import { ToolCard } from './components/ToolCard';
|
||||
|
||||
export default function Page() {
|
||||
const exempt = useAdExempt();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
|
||||
<Plugs />
|
||||
|
||||
<Flex p="4" justify="center" gap="4">
|
||||
<Flex maxWidth="1024px" flexGrow="1" gap="4" direction="column">
|
||||
<ToolCard tool={tankopediaTool} />
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
{/* <ToolCard tool={playerStatsTool} /> */}
|
||||
<ToolCard tool={compareTool} />
|
||||
<ToolCard tool={tankPerformanceTool} />
|
||||
</Flex>
|
||||
|
||||
{!exempt && (
|
||||
<Flex justify="center">
|
||||
<AdMidSectionResponsive />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<ToolCard tool={sessionTool} />
|
||||
{/* <ToolCard tool={embedTool} /> */}
|
||||
<ToolCard tool={ratingTool} />
|
||||
</Flex>
|
||||
|
||||
{!exempt && (
|
||||
<Flex
|
||||
justify="center"
|
||||
display={{
|
||||
initial: 'flex',
|
||||
md: 'none',
|
||||
}}
|
||||
>
|
||||
<AdMidSectionResponsive />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<ToolCard tool={discordTool} />
|
||||
<ToolCard tool={moreTool} />
|
||||
</Flex>
|
||||
|
||||
{!exempt && <AdMidSectionResponsive />}
|
||||
|
||||
<Heading>Other cool projects</Heading>
|
||||
|
||||
<Flex gap="4" direction={{ initial: 'column', sm: 'row' }}>
|
||||
<ToolCard
|
||||
tool={{
|
||||
button: {
|
||||
text: 'Analyze',
|
||||
color: 'blue',
|
||||
},
|
||||
description: 'Rich historic average stats',
|
||||
id: 'blitz-analysis',
|
||||
image: 'j76YXGl',
|
||||
title: 'BlitzAnalysis[]',
|
||||
href: 'https://blitzanalysiz.com/',
|
||||
}}
|
||||
/>
|
||||
<ToolCard
|
||||
tool={{
|
||||
button: {
|
||||
color: 'purple',
|
||||
text: 'Install',
|
||||
highContrast: true,
|
||||
},
|
||||
image: 'zYrrYbR',
|
||||
description: 'In-game session stats',
|
||||
id: 'blitz-insider',
|
||||
title: 'Blitz Insider',
|
||||
href: 'https://discord.gg/UjWf9eGgtR',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Heading, Switch, Text } from '@radix-ui/themes';
|
||||
import PageWrapper from '../../components/PageWrapper';
|
||||
import * as App from '../../stores/app';
|
||||
|
||||
export default function Page() {
|
||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||
const appStore = App.useStore();
|
||||
|
||||
return (
|
||||
<PageWrapper justify="center" align="center">
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex gap="3" direction="column">
|
||||
<Heading size="5">Advanced</Heading>
|
||||
|
||||
<Flex align="center" gap="2">
|
||||
<Text>Developer mode</Text>
|
||||
<Switch
|
||||
checked={developerMode}
|
||||
onCheckedChange={(checked) =>
|
||||
appStore.setState({ developerMode: checked })
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { Flex, Heading, IconButton, Table } from '@radix-ui/themes';
|
||||
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
|
||||
import { asset } from '../../../../core/blitzkit/asset';
|
||||
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
import { CompareRow } from './CompareRow';
|
||||
import { CompareSectionTitle } from './CompareSectionTitle';
|
||||
|
||||
interface BodyProps {
|
||||
stats: TankCharacteristics[];
|
||||
}
|
||||
|
||||
export function Body({ stats }: BodyProps) {
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const hasNonRegularGun = members.some(({ gun }) => gun.type !== 'regular');
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<StickyColumnHeaderCell top={137} style={{ left: 0, zIndex: 2 }}>
|
||||
<Flex height="100%" align="center">
|
||||
<Heading size="4">Firepower</Heading>
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
{members.map(({ gun, shell, key }, index) => (
|
||||
<StickyColumnHeaderCell key={key} top={137}>
|
||||
<Flex justify="center">
|
||||
<Flex>
|
||||
{gun.shells.map((thisShell, shellIndex) => (
|
||||
<IconButton
|
||||
color={thisShell.id === shell.id ? undefined : 'gray'}
|
||||
variant="soft"
|
||||
key={thisShell.id}
|
||||
style={{
|
||||
borderTopLeftRadius: shellIndex === 0 ? undefined : 0,
|
||||
borderBottomLeftRadius:
|
||||
shellIndex === 0 ? undefined : 0,
|
||||
borderTopRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
borderBottomRightRadius:
|
||||
shellIndex === gun.shells.length - 1 ? undefined : 0,
|
||||
marginLeft: shellIndex === 0 ? 0 : -1,
|
||||
}}
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].shell = thisShell;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={thisShell.name}
|
||||
width={16}
|
||||
height={16}
|
||||
src={asset(`icons/shells/${thisShell.icon}.webp`)}
|
||||
/>
|
||||
</IconButton>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<CompareRow stats={stats} name="DPM" value="dpm" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Reload"
|
||||
deltaType="lowerIsBetter"
|
||||
value={(stats) =>
|
||||
stats.shellReload ??
|
||||
stats.shellReloads!.reduce((a, b) => a + b, 0) /
|
||||
stats.shellReloads!.length
|
||||
}
|
||||
display={(stats) =>
|
||||
stats.shellReload?.toFixed(2) ??
|
||||
stats.shellReloads!.map((reload) => reload.toFixed(2)).join(', ')
|
||||
}
|
||||
deltaNominalDisplay={(delta) => delta.toFixed(2)}
|
||||
/>
|
||||
{hasNonRegularGun && (
|
||||
<>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Intra-clip"
|
||||
deltaType="lowerIsBetter"
|
||||
value="intraClip"
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Shells"
|
||||
value="shells"
|
||||
decimals={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Penetration"
|
||||
value="penetration"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow stats={stats} name="Damage" value="damage" decimals={0} />
|
||||
{hasNonRegularGun && (
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="clipping potential"
|
||||
indent
|
||||
value="clipDamage"
|
||||
decimals={0}
|
||||
/>
|
||||
)}
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Module damage"
|
||||
value="moduleDamage"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow stats={stats} name="Caliber" value="caliber" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Normalization"
|
||||
value="shellNormalization"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Ricochet"
|
||||
value="shellRicochet"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Shell velocity"
|
||||
value="shellVelocity"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Aim time"
|
||||
value="aimTime"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Dispersion"
|
||||
value="dispersion"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="moving"
|
||||
indent
|
||||
value="dispersionMoving"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="hull traversing"
|
||||
indent
|
||||
value="dispersionHullTraversing"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="turret traversing"
|
||||
indent
|
||||
value="dispersionTurretTraversing"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="shooting"
|
||||
indent
|
||||
value="dispersionShooting"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="gun damaged"
|
||||
indent
|
||||
value="dispersionGunDamaged"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="maximum"
|
||||
indent
|
||||
value="maxDispersion"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={3}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Gun depression"
|
||||
value="gunDepression"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="elevation"
|
||||
indent
|
||||
value="gunElevation"
|
||||
decimals={1}
|
||||
/>
|
||||
</Table.Body>
|
||||
|
||||
<CompareSectionTitle>Maneuverability</CompareSectionTitle>
|
||||
<Table.Body>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Speed forwards"
|
||||
value="speedForwards"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="backwards"
|
||||
indent
|
||||
value="speedBackwards"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Engine power"
|
||||
value="enginePower"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow stats={stats} name="Weight" value="weight" decimals={1} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Terrain coefficient"
|
||||
value="hardTerrainCoefficient"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="medium terrain"
|
||||
indent
|
||||
value="mediumTerrainCoefficient"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="soft terrain"
|
||||
indent
|
||||
value="softTerrainCoefficient"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Raw terrain coefficient"
|
||||
value="hardTerrainCoefficientRaw"
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="medium terrain"
|
||||
indent
|
||||
value="mediumTerrainCoefficientRaw"
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="soft terrain"
|
||||
indent
|
||||
value="softTerrainCoefficientRaw"
|
||||
decimals={2}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Power to weight ratio"
|
||||
value="powerToWeightRatioHardTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="medium terrain"
|
||||
indent
|
||||
value="powerToWeightRatioMediumTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="soft terrain"
|
||||
indent
|
||||
value="powerToWeightRatioSoftTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Turret traverse speed"
|
||||
value="turretTraverseSpeed"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Traverse speed"
|
||||
value="hullTraverseHardTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="medium terrain"
|
||||
indent
|
||||
value="hullTraverseMediumTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="soft terrain"
|
||||
indent
|
||||
value="hullTraverseSoftTerrain"
|
||||
decimals={1}
|
||||
/>
|
||||
</Table.Body>
|
||||
|
||||
<CompareSectionTitle>Survivability</CompareSectionTitle>
|
||||
<Table.Body>
|
||||
<CompareRow stats={stats} name="Health" value="health" decimals={0} />
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Fire chance"
|
||||
value="fireChance"
|
||||
deltaType="lowerIsBetter"
|
||||
display={(stats) => (stats.fireChance * 100).toFixed(0)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="View range"
|
||||
value="viewRange"
|
||||
decimals={0}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Camouflage"
|
||||
value="camouflageStill"
|
||||
display={(stats) => (stats.camouflageStill * 100).toFixed(0)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="moving"
|
||||
indent
|
||||
value="camouflageMoving"
|
||||
display={(stats) => (stats.camouflageMoving * 100).toFixed(0)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="shooting"
|
||||
indent
|
||||
value="camouflageShootingStill"
|
||||
display={(stats) => (stats.camouflageShootingStill * 100).toFixed(0)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="shooting & moving"
|
||||
indent
|
||||
value="camouflageShootingMoving"
|
||||
display={(stats) => (stats.camouflageShootingMoving * 100).toFixed(0)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="caught on fire"
|
||||
indent
|
||||
value="camouflageCaughtOnFire"
|
||||
display={(stats) => (stats.camouflageCaughtOnFire * 100).toFixed(0)}
|
||||
deltaNominalDisplay={(delta) => (delta * 100).toFixed(0)}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="Volume"
|
||||
value="volume"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="width"
|
||||
indent
|
||||
value="width"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="height"
|
||||
indent
|
||||
value="height"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={1}
|
||||
/>
|
||||
<CompareRow
|
||||
stats={stats}
|
||||
name="length"
|
||||
indent
|
||||
value="length"
|
||||
deltaType="lowerIsBetter"
|
||||
decimals={1}
|
||||
/>
|
||||
</Table.Body>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
CaretSortIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Flex, IconButton, Table, Text } from '@radix-ui/themes';
|
||||
import { StickyRowHeaderCell } from '../../../../components/StickyRowHeaderCell';
|
||||
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
import * as ComparePersistent from '../../../../stores/comparePersistent';
|
||||
|
||||
interface CompareRowProps {
|
||||
name: string;
|
||||
value:
|
||||
| keyof Awaited<TankCharacteristics>
|
||||
| ((member: Awaited<TankCharacteristics>) => number | undefined);
|
||||
display?: (
|
||||
member: Awaited<TankCharacteristics>,
|
||||
) => number | string | undefined;
|
||||
deltaType?: 'higherIsBetter' | 'lowerIsBetter';
|
||||
decimals?: number;
|
||||
deltaNominalDisplay?: (delta: number) => string | number;
|
||||
indent?: boolean;
|
||||
stats: TankCharacteristics[];
|
||||
}
|
||||
|
||||
export function CompareRow({
|
||||
value,
|
||||
name,
|
||||
display,
|
||||
deltaType = 'higherIsBetter',
|
||||
decimals,
|
||||
deltaNominalDisplay,
|
||||
indent,
|
||||
stats,
|
||||
}: CompareRowProps) {
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
const sorting = CompareEphemeral.use((state) => state.sorting);
|
||||
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
|
||||
|
||||
const values = stats.map((stat) =>
|
||||
typeof value === 'function' ? value(stat)! : (stat[value] as number),
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<StickyRowHeaderCell>
|
||||
<Flex
|
||||
align="center"
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
gap={indent ? { initial: '4', sm: '6' } : { initial: '1', sm: '2' }}
|
||||
>
|
||||
<IconButton
|
||||
color={sorting?.by === name ? undefined : 'gray'}
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members.sort((memberA, memberB) => {
|
||||
const indexA = draft.members.indexOf(memberA);
|
||||
const indexB = draft.members.indexOf(memberB);
|
||||
const valueA = values[indexA];
|
||||
const valueB = values[indexB];
|
||||
|
||||
return draft.sorting?.direction === 'ascending' &&
|
||||
draft.sorting.by === name
|
||||
? valueA - valueB
|
||||
: valueB - valueA;
|
||||
});
|
||||
|
||||
draft.sorting = {
|
||||
by: name,
|
||||
direction:
|
||||
draft.sorting?.direction === 'ascending' &&
|
||||
draft.sorting.by === name
|
||||
? 'descending'
|
||||
: 'ascending',
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(sorting === undefined || sorting.by !== name) && (
|
||||
<CaretSortIcon style={{ transform: 'rotate(90deg)' }} />
|
||||
)}
|
||||
{sorting?.by === name && (
|
||||
<>
|
||||
{sorting.direction === 'ascending' && <CaretRightIcon />}
|
||||
{sorting.direction === 'descending' && <CaretLeftIcon />}
|
||||
</>
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<Text size={{ initial: '1', sm: '2' }}>{name}</Text>
|
||||
</Flex>
|
||||
</StickyRowHeaderCell>
|
||||
|
||||
{values.map((value, index) => {
|
||||
const delta = value - values[0];
|
||||
const deltaPercentage = value / values[0] - 1;
|
||||
const normalizedDeltaPercentage = Math.round(
|
||||
Math.min(100, Math.abs(deltaPercentage) * 2 * 100 + 25),
|
||||
);
|
||||
const resolvedDisplayValue = display
|
||||
? display(stats[index])
|
||||
: decimals === undefined
|
||||
? value
|
||||
: value?.toFixed(decimals);
|
||||
|
||||
return (
|
||||
<Table.Cell
|
||||
key={index}
|
||||
style={{
|
||||
backgroundColor:
|
||||
index === 0 ||
|
||||
value === undefined ||
|
||||
values[0] === undefined ||
|
||||
value === values[0]
|
||||
? undefined
|
||||
: (
|
||||
deltaType === 'higherIsBetter'
|
||||
? value > values[0]
|
||||
: value < values[0]
|
||||
)
|
||||
? `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_green} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_green}00)`
|
||||
: `color-mix(in srgb, ${theme.colors.componentCallToActionInteractive_red} ${normalizedDeltaPercentage}%, ${theme.colors.componentCallToActionInteractive_red}00)`,
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="2"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Text style={{ textAlign: 'center' }} wrap="nowrap">
|
||||
{resolvedDisplayValue}
|
||||
</Text>
|
||||
|
||||
{delta !== 0 &&
|
||||
resolvedDisplayValue !== undefined &&
|
||||
values[0] !== undefined && (
|
||||
<>
|
||||
{deltaMode === 'absolute' && (
|
||||
<Text color="gray">
|
||||
(
|
||||
{`${delta > 0 ? '+' : ''}${
|
||||
deltaNominalDisplay
|
||||
? deltaNominalDisplay(delta)
|
||||
: decimals === undefined
|
||||
? delta
|
||||
: delta.toFixed(decimals)
|
||||
}`}
|
||||
)
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{deltaMode === 'percentage' && (
|
||||
<Text color="gray">
|
||||
(
|
||||
{`${deltaPercentage > 0 ? '+' : ''}${Math.round(
|
||||
deltaPercentage * 100,
|
||||
)}%`}
|
||||
)
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
);
|
||||
})}
|
||||
</Table.Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Heading, Table } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
|
||||
export function CompareSectionTitle({ children }: { children: string }) {
|
||||
const memberCount = CompareEphemeral.use((state) => state.members.length);
|
||||
|
||||
return (
|
||||
<Table.Header style={{ height: 48 }}>
|
||||
<Table.Row align="center">
|
||||
<StickyColumnHeaderCell top={137} style={{ left: 0, zIndex: 2 }}>
|
||||
<Heading size="4">{children}</Heading>
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
{times(memberCount, (index) => (
|
||||
<StickyColumnHeaderCell key={index} top={137} />
|
||||
))}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import { ComponentPlaceholderIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Flex, Popover, Slider, Table, Text } from '@radix-ui/themes';
|
||||
import { debounce, times } from 'lodash';
|
||||
import { use } from 'react';
|
||||
import { ConsumablesManager } from '../../../../components/ConsumablesManager';
|
||||
import { CrewSkillManager } from '../../../../components/CrewSkillManager';
|
||||
import { EquipmentManager } from '../../../../components/EquipmentManager';
|
||||
import { ModuleManager } from '../../../../components/ModuleManager';
|
||||
import { ProvisionsManager } from '../../../../components/ProvisionsManager';
|
||||
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
|
||||
import { StickyTableRoot } from '../../../../components/StickyTableRoot';
|
||||
import { asset } from '../../../../core/blitzkit/asset';
|
||||
import { availableProvisions } from '../../../../core/blitzkit/availableProvisions';
|
||||
import { checkConsumableProvisionInclusivity } from '../../../../core/blitzkit/checkConsumableProvisionInclusivity';
|
||||
import { consumableDefinitions } from '../../../../core/blitzkit/consumableDefinitions';
|
||||
import { equipmentDefinitions } from '../../../../core/blitzkit/equipmentDefinitions';
|
||||
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
|
||||
import { skillDefinitions } from '../../../../core/blitzkit/skillDefinitions';
|
||||
import { TankCharacteristics } from '../../../../core/blitzkit/tankCharacteristics';
|
||||
import { BlitzkitButtonGray } from '../../../../icons/BlitzkitButtonGray';
|
||||
import { theme } from '../../../../stitches.config';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
import { EquipmentMatrix } from '../../../../stores/duel';
|
||||
import { permanentSkills } from '../../tankopedia/[id]/components/Characteristics/components/Skills/constants';
|
||||
import { Body } from './Body';
|
||||
import { InsertionMarker } from './IntersectionMarker';
|
||||
import { TankCard } from './TankCard';
|
||||
|
||||
interface CompareTableProps {
|
||||
stats: TankCharacteristics[];
|
||||
}
|
||||
|
||||
export function CompareTable({ stats }: CompareTableProps) {
|
||||
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const awaitedSkillDefinitions = use(skillDefinitions);
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
const awaitedConsumableDefinitions = use(consumableDefinitions);
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
|
||||
return (
|
||||
<StickyTableRoot
|
||||
size="1"
|
||||
variant="surface"
|
||||
style={{ maxWidth: '100%', height: 'calc(100vh - 128px)' }}
|
||||
>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<StickyColumnHeaderCell width="0" style={{ height: '100%' }}>
|
||||
<Flex
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<BlitzkitButtonGray opacity="0.25" width={64} height={64} />
|
||||
<InsertionMarker index={0} />
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
|
||||
{members.map(({ tank, key }, index) => {
|
||||
return <TankCard index={index} key={key} tank={tank} />;
|
||||
})}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
<Table.Row>
|
||||
<Table.Cell style={{ height: '100%' }}>
|
||||
<Flex
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="ghost" radius="large">
|
||||
<Flex
|
||||
direction="column"
|
||||
style={{
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{Object.entries(awaitedSkillDefinitions.classes).map(
|
||||
([tankClass, skills]) => (
|
||||
<Flex key={tankClass} style={{ gap: 2 }}>
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill}
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 2,
|
||||
backgroundColor:
|
||||
crewSkills[skill] === 0
|
||||
? theme.colors.textLowContrast
|
||||
: permanentSkills.includes(skill)
|
||||
? theme.colors.textLowContrast_crimson
|
||||
: theme.colors.textLowContrast_amber,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<CrewSkillManager
|
||||
skillLevels={crewSkills}
|
||||
onChange={(skills) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.crewSkills = skills;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4" gap="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
Object.keys(draft.crewSkills).forEach((skill) => {
|
||||
draft.crewSkills[skill] = 0;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
Object.keys(draft.crewSkills).forEach((skill) => {
|
||||
draft.crewSkills[skill] = 7;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Maximize
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
|
||||
{members.map(
|
||||
(
|
||||
{
|
||||
equipmentMatrix,
|
||||
tank,
|
||||
key,
|
||||
provisions,
|
||||
gun,
|
||||
shell,
|
||||
turret,
|
||||
engine,
|
||||
track,
|
||||
consumables,
|
||||
crewMastery,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
const equipmentPreset =
|
||||
awaitedEquipmentDefinitions.presets[tank.equipment];
|
||||
const provisionsList = availableProvisions(
|
||||
tank,
|
||||
gun,
|
||||
awaitedProvisionDefinitions,
|
||||
);
|
||||
const consumablesList = Object.values(
|
||||
awaitedConsumableDefinitions,
|
||||
).filter((consumable) =>
|
||||
checkConsumableProvisionInclusivity(consumable, tank, gun),
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Cell key={key}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
gap="2"
|
||||
>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="ghost">
|
||||
<Flex align="center">
|
||||
{(
|
||||
['turret', 'gun', 'engine', 'chassis'] as const
|
||||
).map((module, index) => (
|
||||
<img
|
||||
alt={module}
|
||||
width={24}
|
||||
height={24}
|
||||
src={asset(`icons/modules/${module}.webp`)}
|
||||
style={{
|
||||
marginLeft: index > 0 ? -8 : 0,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<ModuleManager
|
||||
tank={tank}
|
||||
turret={turret}
|
||||
gun={gun}
|
||||
shell={shell}
|
||||
engine={engine}
|
||||
track={track}
|
||||
onChange={(modules) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index] = {
|
||||
...draft.members[index],
|
||||
...modules,
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4" gap="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
const member = draft.members[index];
|
||||
|
||||
member.turret = member.tank.turrets[0];
|
||||
member.gun = member.turret.guns[0];
|
||||
member.shell = member.gun.shells[0];
|
||||
member.engine = member.tank.engines[0];
|
||||
member.track = member.tank.tracks[0];
|
||||
});
|
||||
}}
|
||||
>
|
||||
Stock
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
const member = draft.members[index];
|
||||
|
||||
member.turret = member.tank.turrets.at(-1)!;
|
||||
member.gun = member.turret.guns.at(-1)!;
|
||||
member.shell = member.gun.shells[0];
|
||||
member.engine = member.tank.engines.at(-1)!;
|
||||
member.track = member.tank.tracks.at(-1)!;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Flex align="center" justify="center" gap="3">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
radius="large"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: 12,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" justify="center">
|
||||
{provisions.map((provision, index) => (
|
||||
<img
|
||||
alt={
|
||||
awaitedProvisionDefinitions[provision].name
|
||||
}
|
||||
key={provision}
|
||||
src={asset(
|
||||
`/icons/provisions/${provision}.webp`,
|
||||
)}
|
||||
style={{
|
||||
left: '50%',
|
||||
/**
|
||||
* max = 100% - 16px - 4px
|
||||
* min = 4px
|
||||
* diff = max - min
|
||||
* = 100% - 16px - 4px - 4px
|
||||
* = 100% - 24px
|
||||
* coefficient = index / length - 1
|
||||
* position = min + coefficient * diff
|
||||
*/
|
||||
top: `calc(4px + ${
|
||||
provisions.length === 1
|
||||
? 0.5
|
||||
: index / (provisions.length - 1)
|
||||
} * (100% - 24px))`,
|
||||
transform: 'translateX(-50%)',
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{provisions.length === 0 && (
|
||||
<ComponentPlaceholderIcon />
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<ProvisionsManager
|
||||
provisions={provisionsList.map(
|
||||
(provision) => provision.id,
|
||||
)}
|
||||
selected={provisions}
|
||||
disabled={tank.provisions === provisions.length}
|
||||
onChange={(provisions) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].provisions = provisions;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].provisions = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="ghost" radius="large">
|
||||
<Flex gap="1" direction="column">
|
||||
{times(3, (y) => (
|
||||
<Flex
|
||||
key={y}
|
||||
style={{
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{times(3, (x) => {
|
||||
const equipment = equipmentMatrix[y][x];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={x}
|
||||
style={{
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 4,
|
||||
height: 8,
|
||||
backgroundColor:
|
||||
equipment === -1
|
||||
? theme.colors
|
||||
.textLowContrast_crimson
|
||||
: theme.colors.textLowContrast,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 4,
|
||||
height: 8,
|
||||
backgroundColor:
|
||||
equipment === 1
|
||||
? theme.colors
|
||||
.textLowContrast_crimson
|
||||
: theme.colors.textLowContrast,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<EquipmentManager
|
||||
matrix={equipmentMatrix}
|
||||
preset={equipmentPreset}
|
||||
onChange={(matrix) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].equipmentMatrix = matrix;
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex justify="end" gap="4" mt="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].equipmentMatrix = times(
|
||||
3,
|
||||
() => times(3, () => 0),
|
||||
) as EquipmentMatrix;
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members.forEach((member) => {
|
||||
member.equipmentMatrix =
|
||||
draft.members[index].equipmentMatrix;
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Apply to all
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
radius="large"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: 12,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" justify="center">
|
||||
{consumables.map((consumable, index) => (
|
||||
<img
|
||||
alt={
|
||||
awaitedConsumableDefinitions[consumable]
|
||||
.name
|
||||
}
|
||||
key={consumable}
|
||||
src={asset(
|
||||
`/icons/consumables/${consumable}.webp`,
|
||||
)}
|
||||
style={{
|
||||
left: '50%',
|
||||
/**
|
||||
* max = 100% - 16px - 4px
|
||||
* min = 4px
|
||||
* diff = max - min
|
||||
* = 100% - 16px - 4px - 4px
|
||||
* = 100% - 24px
|
||||
* coefficient = index / length - 1
|
||||
* position = min + coefficient * diff
|
||||
*/
|
||||
top: `calc(4px + ${
|
||||
consumables.length === 1
|
||||
? 0.5
|
||||
: index / (consumables.length - 1)
|
||||
} * (100% - 24px))`,
|
||||
transform: 'translateX(-50%)',
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{consumables.length === 0 && (
|
||||
<ComponentPlaceholderIcon />
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content>
|
||||
<ConsumablesManager
|
||||
consumables={consumablesList}
|
||||
selected={consumables}
|
||||
onConsumablesChange={(consumables) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].consumables = consumables;
|
||||
});
|
||||
}}
|
||||
disabled={tank.consumables === consumables.length}
|
||||
/>
|
||||
|
||||
<Flex justify="end" mt="4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].consumables = [];
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Flex>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="2" width="100%" align="center">
|
||||
<Text>{Math.round(crewMastery * 100)}%</Text>
|
||||
<Flex flexGrow="1">
|
||||
<Slider
|
||||
defaultValue={[crewMastery]}
|
||||
min={0.5}
|
||||
max={1}
|
||||
step={1 / 100}
|
||||
onValueChange={debounce(([value]: number[]) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index].crewMastery = value;
|
||||
});
|
||||
}, 500)}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
|
||||
<Body stats={stats} />
|
||||
</StickyTableRoot>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { PlusIcon, TrashIcon } from '@radix-ui/react-icons';
|
||||
import { Button, Dialog, Flex, SegmentedControl } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
|
||||
import { tankToCompareMember } from '../../../../core/blitzkit/tankToCompareMember';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
import * as ComparePersistent from '../../../../stores/comparePersistent';
|
||||
import { TankSearch } from '../../tankopedia/components/TankSearch';
|
||||
|
||||
interface ControlsProps {
|
||||
addTankDialogOpen: boolean;
|
||||
onAddTankDialogOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function Controls({
|
||||
addTankDialogOpen,
|
||||
onAddTankDialogOpenChange,
|
||||
}: ControlsProps) {
|
||||
const deltaMode = ComparePersistent.use((state) => state.deltaMode);
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
const mutateComparePersistent = ComparePersistent.useMutation();
|
||||
|
||||
return (
|
||||
<Flex gap="2" wrap="wrap" justify="center">
|
||||
<Dialog.Root
|
||||
open={addTankDialogOpen}
|
||||
onOpenChange={onAddTankDialogOpenChange}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button variant="soft">
|
||||
<PlusIcon /> Add
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Title align="center">Add tanks</Dialog.Title>
|
||||
|
||||
<Flex gap="4" direction="column">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members.push(
|
||||
tankToCompareMember(tank, awaitedProvisionDefinitions),
|
||||
);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
onAddTankDialogOpenChange(false);
|
||||
}}
|
||||
onSelectAll={(tanks) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members.push(
|
||||
...tanks.map((tank) =>
|
||||
tankToCompareMember(tank, awaitedProvisionDefinitions),
|
||||
),
|
||||
);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
onAddTankDialogOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Button
|
||||
variant="soft"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members = [];
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TrashIcon /> Clear
|
||||
</Button>
|
||||
|
||||
<SegmentedControl.Root
|
||||
value={deltaMode}
|
||||
onValueChange={(value) => {
|
||||
mutateComparePersistent((draft) => {
|
||||
draft.deltaMode = value as ComparePersistent.DeltaMode;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SegmentedControl.Item
|
||||
value={'none' satisfies ComparePersistent.DeltaMode}
|
||||
>
|
||||
No deltas
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item
|
||||
value={'percentage' satisfies ComparePersistent.DeltaMode}
|
||||
>
|
||||
Percentage
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item
|
||||
value={'absolute' satisfies ComparePersistent.DeltaMode}
|
||||
>
|
||||
Absolute
|
||||
</SegmentedControl.Item>
|
||||
</SegmentedControl.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import { Callout } from '@radix-ui/themes';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
|
||||
export function DamageWarning() {
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const hasReactive = members.some((member) => member.consumables.includes(33));
|
||||
const haDynamicArmor = members.some((member) =>
|
||||
member.consumables.includes(73),
|
||||
);
|
||||
const hasSpallLiner = members.some((member) =>
|
||||
member.provisions.includes(101),
|
||||
);
|
||||
|
||||
if (!hasReactive && !haDynamicArmor && !hasSpallLiner) return null;
|
||||
|
||||
{
|
||||
<Callout.Root>
|
||||
<Callout.Icon>
|
||||
<InfoCircledIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
Consumables like Reactive Armor and Dynamic Armor System and provisions
|
||||
like Spall Liner may reduce other tanks' damage.
|
||||
</Callout.Text>
|
||||
</Callout.Root>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const insertionMarkers: { element: HTMLDivElement; index: number }[] =
|
||||
[];
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { theme } from '../../../../../stitches.config';
|
||||
import { insertionMarkers } from './constants';
|
||||
|
||||
export function InsertionMarker({ index }: { index: number }) {
|
||||
const marker = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
insertionMarkers.push({
|
||||
element: marker.current!,
|
||||
index,
|
||||
});
|
||||
|
||||
return () => {
|
||||
insertionMarkers.splice(
|
||||
insertionMarkers.findIndex(
|
||||
(insertionMarker) => insertionMarker.element === marker.current!,
|
||||
),
|
||||
1,
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={marker}
|
||||
style={{
|
||||
height: '75%',
|
||||
width: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: theme.colors.textLowContrast_crimson,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Flex, Text } from '@radix-ui/themes';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Vector2 } from 'three';
|
||||
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
|
||||
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
|
||||
import { tankIcon } from '../../../../core/blitzkit/tankIcon';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
import { InsertionMarker } from './IntersectionMarker';
|
||||
import { insertionMarkers } from './IntersectionMarker/constants';
|
||||
import { TankControl } from './TankControl';
|
||||
|
||||
interface TankCardProps {
|
||||
index: number;
|
||||
tank: TankDefinition;
|
||||
}
|
||||
|
||||
export function TankCard({ index, tank }: TankCardProps) {
|
||||
const draggable = useRef<HTMLDivElement>(null);
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
const initial = new Vector2();
|
||||
const current = new Vector2();
|
||||
const delta = new Vector2();
|
||||
const marker = new Vector2();
|
||||
let initialRect = draggable.current!.getBoundingClientRect();
|
||||
let dropIndex = -1;
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
initialRect = draggable.current!.getBoundingClientRect();
|
||||
|
||||
delta.set(0, 0);
|
||||
current.copy(initial.set(event.clientX, event.clientY));
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
current.set(event.clientX, event.clientY);
|
||||
delta.copy(current).sub(initial);
|
||||
insertionMarkers.forEach(({ element }) => {
|
||||
element.style.opacity = '0';
|
||||
});
|
||||
|
||||
const distances = insertionMarkers.map((insertionMarker) => {
|
||||
const markerBounds = insertionMarker.element.getBoundingClientRect();
|
||||
marker.set(markerBounds.left, markerBounds.top);
|
||||
|
||||
return {
|
||||
...insertionMarker,
|
||||
distance: marker.distanceTo(current),
|
||||
};
|
||||
});
|
||||
const closest = distances.reduce((a, b) =>
|
||||
a.distance < b.distance ? a : b,
|
||||
);
|
||||
|
||||
dropIndex = closest.index;
|
||||
closest.element.style.opacity = '1';
|
||||
draggable.current!.style.position = 'fixed';
|
||||
draggable.current!.style.cursor = 'grabbing';
|
||||
draggable.current!.style.zIndex = '1';
|
||||
draggable.current!.style.left = `${initialRect.left + delta.x}px`;
|
||||
draggable.current!.style.top = `${initialRect.top + delta.y}px`;
|
||||
}
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
draggable.current!.style.position = 'static';
|
||||
draggable.current!.style.zIndex = 'unset';
|
||||
draggable.current!.style.cursor = 'grab';
|
||||
|
||||
insertionMarkers.forEach(({ element }) => {
|
||||
element.style.opacity = '0';
|
||||
});
|
||||
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
|
||||
if (dropIndex === -1) return;
|
||||
|
||||
mutateCompareEphemeral((draft) => {
|
||||
const fromIndex = index;
|
||||
const toIndex = dropIndex > index ? dropIndex - 1 : dropIndex;
|
||||
|
||||
const member = draft.members[fromIndex];
|
||||
draft.members.splice(fromIndex, 1);
|
||||
draft.members.splice(toIndex, 0, member);
|
||||
});
|
||||
|
||||
dropIndex = -1;
|
||||
}
|
||||
|
||||
draggable.current?.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
return () => {
|
||||
draggable.current?.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StickyColumnHeaderCell>
|
||||
<Flex direction="column" align="center" justify="between" gap="2">
|
||||
<TankControl index={index} key={tank.id} id={tank.id} />
|
||||
|
||||
<Flex
|
||||
ref={draggable}
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="between"
|
||||
gap="2"
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={tank.name}
|
||||
src={tankIcon(tank.id)}
|
||||
width={64}
|
||||
height={64}
|
||||
draggable={false}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 128,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{tank.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<InsertionMarker index={index + 1} />
|
||||
</StickyColumnHeaderCell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ExternalLinkIcon, LoopIcon, TrashIcon } from '@radix-ui/react-icons';
|
||||
import { Dialog, Flex, IconButton } from '@radix-ui/themes';
|
||||
import Link from 'next/link';
|
||||
import { use, useState } from 'react';
|
||||
import { provisionDefinitions } from '../../../../core/blitzkit/provisionDefinitions';
|
||||
import { tankToCompareMember } from '../../../../core/blitzkit/tankToCompareMember';
|
||||
import * as CompareEphemeral from '../../../../stores/compareEphemeral';
|
||||
import { TankSearch } from '../../tankopedia/components/TankSearch';
|
||||
|
||||
interface TankControlProps {
|
||||
index: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function TankControl({ index, id }: TankControlProps) {
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const [switchTankDialogOpen, setSwitchTankDialogOpen] = useState(false);
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
|
||||
return (
|
||||
<Flex gap="3" justify="center" style={{ width: '100%' }}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members.splice(index, 1);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
|
||||
<Dialog.Root
|
||||
open={switchTankDialogOpen}
|
||||
onOpenChange={setSwitchTankDialogOpen}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<IconButton variant="ghost">
|
||||
<LoopIcon />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
|
||||
<Dialog.Content>
|
||||
<Dialog.Title align="center">Switch tanks</Dialog.Title>
|
||||
|
||||
<Flex gap="4" direction="column">
|
||||
<Flex
|
||||
direction="column"
|
||||
gap="4"
|
||||
style={{ flex: 1 }}
|
||||
justify="center"
|
||||
>
|
||||
<TankSearch
|
||||
compact
|
||||
onSelect={(tank) => {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members[index] = tankToCompareMember(
|
||||
tank,
|
||||
awaitedProvisionDefinitions,
|
||||
);
|
||||
draft.sorting = undefined;
|
||||
});
|
||||
setSwitchTankDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Link href={`/tools/tankopedia/${id}`} target="_blank" prefetch={false}>
|
||||
<IconButton variant="ghost">
|
||||
<ExternalLinkIcon />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ReactNode } from 'react';
|
||||
import * as CompareEphemeral from '../../../stores/compareEphemeral';
|
||||
import * as ComparePersistent from '../../../stores/comparePersistent';
|
||||
import * as TankFilters from '../../../stores/tankFilters';
|
||||
import * as TankopediaSort from '../../../stores/tankopediaSort';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TankopediaSort.Provider>
|
||||
<TankFilters.Provider>
|
||||
<ComparePersistent.Provider>
|
||||
<CompareEphemeral.Provider>{children}</CompareEphemeral.Provider>
|
||||
</ComparePersistent.Provider>
|
||||
</TankFilters.Provider>
|
||||
</TankopediaSort.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import { Box, Flex, Heading, IconButton, Text } from '@radix-ui/themes';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import { createDefaultSkills } from '../../../core/blitzkit/createDefaultSkills';
|
||||
import { equipmentDefinitions } from '../../../core/blitzkit/equipmentDefinitions';
|
||||
import { modelDefinitions } from '../../../core/blitzkit/modelDefinitions';
|
||||
import { provisionDefinitions } from '../../../core/blitzkit/provisionDefinitions';
|
||||
import { skillDefinitions } from '../../../core/blitzkit/skillDefinitions';
|
||||
import { tankCharacteristics } from '../../../core/blitzkit/tankCharacteristics';
|
||||
import { tankDefinitions } from '../../../core/blitzkit/tankDefinitions';
|
||||
import { tankToCompareMember } from '../../../core/blitzkit/tankToCompareMember';
|
||||
import * as CompareEphemeral from '../../../stores/compareEphemeral';
|
||||
import { CompareTable } from './components/CompareTable';
|
||||
import { Controls } from './components/Controls';
|
||||
import { DamageWarning } from './components/DamageWarning';
|
||||
|
||||
export default function Page() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const awaitedSkillDefinitions = use(skillDefinitions);
|
||||
const searchParams = useSearchParams();
|
||||
const members = CompareEphemeral.use((state) => state.members);
|
||||
const [addTankDialogOpen, setAddTankDialogOpen] = useState(false);
|
||||
const awaitedModelDefinitions = use(modelDefinitions);
|
||||
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
|
||||
const awaitedProvisionDefinitions = use(provisionDefinitions);
|
||||
const crewSkills = CompareEphemeral.use((state) => state.crewSkills);
|
||||
const mutateCompareEphemeral = CompareEphemeral.useMutation();
|
||||
const stats = useMemo<ReturnType<typeof tankCharacteristics>[]>(
|
||||
() =>
|
||||
members.map((thisMember) =>
|
||||
tankCharacteristics(
|
||||
{
|
||||
...thisMember,
|
||||
crewSkills,
|
||||
stockEngine: thisMember.tank.engines[0],
|
||||
stockGun: thisMember.tank.turrets[0].guns[0],
|
||||
stockTrack: thisMember.tank.tracks[0],
|
||||
stockTurret: thisMember.tank.turrets[0],
|
||||
applyReactiveArmor: members.some(
|
||||
(member) =>
|
||||
member.key !== thisMember.key &&
|
||||
member.consumables.includes(33),
|
||||
),
|
||||
applyDynamicArmor: members.some(
|
||||
(member) =>
|
||||
member.key !== thisMember.key &&
|
||||
member.consumables.includes(73),
|
||||
),
|
||||
applySpallLiner: members.some(
|
||||
(member) =>
|
||||
member.key !== thisMember.key &&
|
||||
member.provisions.includes(101),
|
||||
),
|
||||
},
|
||||
{
|
||||
equipmentDefinitions: awaitedEquipmentDefinitions,
|
||||
provisionDefinitions: awaitedProvisionDefinitions,
|
||||
tankModelDefinition: awaitedModelDefinitions[thisMember.tank.id],
|
||||
},
|
||||
),
|
||||
),
|
||||
[members, crewSkills],
|
||||
);
|
||||
const createQueryString = useCallback(
|
||||
(name: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(name, value);
|
||||
|
||||
return params.toString();
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
router.push(
|
||||
pathname +
|
||||
'?' +
|
||||
createQueryString(
|
||||
'tanks',
|
||||
members.map(({ tank }) => tank.id).join(','),
|
||||
),
|
||||
);
|
||||
}, [members]);
|
||||
|
||||
useEffect(() => {
|
||||
const tanksParam = searchParams.get('tanks');
|
||||
|
||||
if (tanksParam) {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.members = tanksParam
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.map((id) =>
|
||||
tankToCompareMember(
|
||||
awaitedTankDefinitions[id],
|
||||
awaitedProvisionDefinitions,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(crewSkills).length === 0) {
|
||||
mutateCompareEphemeral((draft) => {
|
||||
draft.crewSkills = createDefaultSkills(awaitedSkillDefinitions);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageWrapper color="crimson" size="100%">
|
||||
<Flex justify="center" gap="4" align="center" direction="column">
|
||||
<DamageWarning />
|
||||
<Controls
|
||||
addTankDialogOpen={addTankDialogOpen}
|
||||
onAddTankDialogOpenChange={setAddTankDialogOpen}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{members.length > 0 && (
|
||||
<Flex justify="center">
|
||||
<Box maxWidth="100%" position="relative">
|
||||
<CompareTable stats={stats} />
|
||||
|
||||
<IconButton
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 73,
|
||||
transform: 'translate(50%, -50%)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
onClick={() => setAddTankDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
direction="column"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Heading color="gray">No tanks selected</Heading>
|
||||
<Text color="gray">
|
||||
Press the <PlusIcon /> Add button to get started
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Switch } from '@radix-ui/themes';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
|
||||
export function Boolean({ configKey }: EmbedPreviewControllerProps) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<Switch
|
||||
variant="soft"
|
||||
checked={EmbedState.use((state) => state[configKey] as boolean)}
|
||||
onCheckedChange={() => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = !draft[configKey];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Slider } from '@radix-ui/themes';
|
||||
|
||||
interface ColorControllerRawProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ColorControllerRaw({
|
||||
value,
|
||||
onValueChange,
|
||||
}: ColorControllerRawProps) {
|
||||
const tintString = value.slice(0, 7);
|
||||
const alphaString = value.slice(7, 9);
|
||||
const alpha = alphaString.length === 0 ? 255 : parseInt(alphaString, 16);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="color"
|
||||
value={value.slice(0, 7)}
|
||||
onChange={(event) => {
|
||||
let alpha = value.slice(7, 9);
|
||||
if (alpha.length === 0) alpha = 'ff';
|
||||
onValueChange(`${event.target.value}${alpha}`);
|
||||
}}
|
||||
/>
|
||||
<Slider
|
||||
variant="soft"
|
||||
min={0}
|
||||
max={255}
|
||||
value={[alpha]}
|
||||
onValueChange={([value]) => {
|
||||
onValueChange(`${tintString}${value.toString(16)}`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as EmbedState from '../../../../../../stores/embedState';
|
||||
import { EmbedPreviewControllerProps } from '../../page';
|
||||
import { ColorControllerRaw } from './components/ColorControllerRaw';
|
||||
|
||||
export function Color({ configKey }: EmbedPreviewControllerProps) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
const color = EmbedState.use((state) => state[configKey] as string);
|
||||
|
||||
return (
|
||||
<ColorControllerRaw
|
||||
value={color}
|
||||
onValueChange={(value) => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = value;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Select } from '@radix-ui/themes';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import {
|
||||
EmbedConfigItemType,
|
||||
EmbedItemType,
|
||||
} from '../../../../../stores/embedState/constants';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
|
||||
export function Enum({
|
||||
configKey,
|
||||
config,
|
||||
}: EmbedPreviewControllerProps & {
|
||||
config: EmbedConfigItemType<EmbedItemType.Enum>;
|
||||
}) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={EmbedState.use((state) => state[configKey] as string)}
|
||||
onValueChange={(value) => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = value;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Select.Trigger />
|
||||
<Select.Content>
|
||||
{config.options.map((option) => (
|
||||
<Select.Item key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from '@radix-ui/themes';
|
||||
import { useRef, useState } from 'react';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
|
||||
export function Import() {
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
const [showInvalidURLPrompt, setShowInvalidURLPrompt] = useState(false);
|
||||
const [showConfirmation, setConfirmation] = useState(false);
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading>Import</Heading>
|
||||
<Text size="2" color="gray" mb="2">
|
||||
Paste your current URL if you have one already to import your settings
|
||||
</Text>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={showInvalidURLPrompt}
|
||||
onOpenChange={setShowInvalidURLPrompt}
|
||||
>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Title>Invalid URL</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Try pasting the URL that BlitzKit previously generated, you silly
|
||||
goose!
|
||||
</AlertDialog.Description>
|
||||
|
||||
<Flex gap="3" mt="4" justify="end">
|
||||
<AlertDialog.Cancel>
|
||||
<Button>Okay, my bad</Button>
|
||||
</AlertDialog.Cancel>
|
||||
</Flex>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<Flex gap="2" mb="6">
|
||||
<TextField.Root
|
||||
ref={importInput}
|
||||
placeholder="Paste URL..."
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Tooltip content="Applied!" open={showConfirmation}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!importInput.current) return;
|
||||
|
||||
try {
|
||||
const url = new URL(importInput.current.value);
|
||||
const state = url.searchParams.get('state');
|
||||
if (state === null) throw undefined;
|
||||
const json = JSON.parse(state);
|
||||
|
||||
mutateEmbedState((draft) => {
|
||||
Object.assign(draft, json);
|
||||
});
|
||||
|
||||
importInput.current.value = '';
|
||||
setConfirmation(true);
|
||||
setTimeout(() => setConfirmation(false), 2000);
|
||||
} catch {
|
||||
setShowInvalidURLPrompt(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const fadeOutBorder = keyframes({
|
||||
from: {
|
||||
outline: '1rem solid #ff0000ff',
|
||||
},
|
||||
to: {
|
||||
outline: '1rem solid #ff000000',
|
||||
},
|
||||
});
|
||||
|
||||
export const wrapper = style({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const animated = style({
|
||||
animation: `${fadeOutBorder} 0.5s linear`,
|
||||
|
||||
':hover': {
|
||||
outline: '1rem solid #ff000080',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Box } from '@radix-ui/themes';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEmbedStateCurry } from '../../../../../../stores/embedState/utilities';
|
||||
import { previews } from '../../../configurations';
|
||||
import * as styles from './index.css';
|
||||
|
||||
interface PreviewWrapperProps {
|
||||
name: keyof typeof previews;
|
||||
}
|
||||
|
||||
export function PreviewWrapper({ name }: PreviewWrapperProps) {
|
||||
const { useState } = useEmbedStateCurry();
|
||||
const width = useState('width');
|
||||
const height = useState('height');
|
||||
const Preview = previews[name];
|
||||
const wrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrapper.current) return;
|
||||
|
||||
wrapper.current.classList.remove(styles.animated);
|
||||
void wrapper.current.offsetWidth;
|
||||
wrapper.current.classList.add(styles.animated);
|
||||
}, [width, height]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={wrapper}
|
||||
className={styles.wrapper}
|
||||
width={`${width}px`}
|
||||
height={`${height}px`}
|
||||
>
|
||||
<Preview />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Slider } from '@radix-ui/themes';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
|
||||
export function Radius({ configKey }: EmbedPreviewControllerProps) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<Slider
|
||||
variant="soft"
|
||||
min={0}
|
||||
max={5}
|
||||
value={[
|
||||
EmbedState.use((state) => {
|
||||
const radius = state[configKey] as EmbedState.RadixRadius;
|
||||
return radius === 'full' ? 5 : parseInt(radius);
|
||||
}),
|
||||
]}
|
||||
onValueChange={([value]) => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = value === 5 ? 'full' : `${value}`;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import {
|
||||
EmbedConfigItemType,
|
||||
EmbedItemType,
|
||||
} from '../../../../../stores/embedState/constants';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
import { ColorControllerRaw } from './Color/components/ColorControllerRaw';
|
||||
|
||||
export function RichText({ configKey }: EmbedPreviewControllerProps) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
const state = EmbedState.use(
|
||||
(state) =>
|
||||
state[
|
||||
configKey
|
||||
] as EmbedConfigItemType<EmbedItemType.RichText>['default'],
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorControllerRaw
|
||||
value={state.color}
|
||||
onValueChange={(value) => {
|
||||
mutateEmbedState((draft) => {
|
||||
(
|
||||
draft[
|
||||
configKey
|
||||
] as EmbedConfigItemType<EmbedItemType.RichText>['default']
|
||||
).color = value;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Slider } from '@radix-ui/themes';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
|
||||
export function Size({ configKey }: EmbedPreviewControllerProps) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<Slider
|
||||
variant="soft"
|
||||
min={0}
|
||||
max={9}
|
||||
value={[
|
||||
EmbedState.use((state) =>
|
||||
parseInt(state[configKey] as EmbedState.RadixSize),
|
||||
),
|
||||
]}
|
||||
onValueChange={([value]) => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = `${value}`;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Slider } from '@radix-ui/themes';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
|
||||
export function SizeWithout0({ configKey }: EmbedPreviewControllerProps) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<Slider
|
||||
variant="soft"
|
||||
min={1}
|
||||
max={9}
|
||||
value={[
|
||||
EmbedState.use((state) =>
|
||||
parseInt(state[configKey] as EmbedState.RadixSizeWithout0),
|
||||
),
|
||||
]}
|
||||
onValueChange={([value]) => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = `${value}`;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Slider as RadixSlider } from '@radix-ui/themes';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import {
|
||||
EmbedConfigItemType,
|
||||
EmbedItemType,
|
||||
} from '../../../../../stores/embedState/constants';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
|
||||
export function Slider({
|
||||
configKey,
|
||||
config,
|
||||
}: EmbedPreviewControllerProps & {
|
||||
config: EmbedConfigItemType<EmbedItemType.Slider>;
|
||||
}) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<RadixSlider
|
||||
variant="soft"
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
value={[
|
||||
EmbedState.use((state) =>
|
||||
parseInt(state[configKey] as EmbedState.RadixSize),
|
||||
),
|
||||
]}
|
||||
onValueChange={([value]) => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = value;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { TextField } from '@radix-ui/themes';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import { EmbedPreviewControllerProps } from '../page';
|
||||
|
||||
export function Text({ configKey }: EmbedPreviewControllerProps) {
|
||||
const mutateEmbedState = EmbedState.useMutation();
|
||||
|
||||
return (
|
||||
<TextField.Root
|
||||
placeholder="Empty"
|
||||
value={EmbedState.use((state) => state[configKey] as string)}
|
||||
onChange={(event) => {
|
||||
mutateEmbedState((draft) => {
|
||||
draft[configKey] = event.target.value;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Code, Heading } from '@radix-ui/themes';
|
||||
import { useMemo } from 'react';
|
||||
import PageWrapper from '../../../../../components/PageWrapper';
|
||||
import * as EmbedState from '../../../../../stores/embedState';
|
||||
import { configurations } from '../../configurations';
|
||||
import { extractEmbedConfigDefaults } from '../../utilities';
|
||||
|
||||
export interface EmbedPreviewControllerProps {
|
||||
configKey: string;
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { embed: keyof typeof configurations };
|
||||
searchParams: { state: string };
|
||||
}) {
|
||||
const config = configurations[params.embed];
|
||||
const initialState = useMemo(
|
||||
() => ({
|
||||
...extractEmbedConfigDefaults(config),
|
||||
...(JSON.parse(searchParams.state) as EmbedState.EmbedState),
|
||||
}),
|
||||
[params.embed],
|
||||
);
|
||||
|
||||
return (
|
||||
<EmbedState.Provider data={initialState}>
|
||||
<PageWrapper>
|
||||
<Heading>
|
||||
<Code>{params.embed}</Code>
|
||||
</Heading>
|
||||
|
||||
<Code color="gray">
|
||||
<pre>{JSON.stringify(initialState, null, 2)}</pre>
|
||||
</Code>
|
||||
</PageWrapper>
|
||||
</EmbedState.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as EmbedState from '../../../../stores/embedState';
|
||||
import { configurations } from '../configurations';
|
||||
import { extractEmbedConfigDefaults } from '../utilities';
|
||||
|
||||
export default function Layout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: { embed: keyof typeof configurations };
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const config = configurations[params.embed];
|
||||
const initialState = useMemo(
|
||||
() => extractEmbedConfigDefaults(config),
|
||||
[params.embed],
|
||||
);
|
||||
|
||||
return (
|
||||
<EmbedState.Provider data={initialState}>{children}</EmbedState.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CodeIcon,
|
||||
HeightIcon,
|
||||
ImageIcon,
|
||||
Link2Icon,
|
||||
ResetIcon,
|
||||
TimerIcon,
|
||||
WidthIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Box, Button, Flex, Heading, ScrollArea, Text } from '@radix-ui/themes';
|
||||
import { capitalize, startCase } from 'lodash';
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { stringify } from 'urlon';
|
||||
import { CopyButton } from '../../../../components/CopyButton';
|
||||
import PageWrapper from '../../../../components/PageWrapper';
|
||||
import { imgur } from '../../../../core/blitzkit/imgur';
|
||||
import * as App from '../../../../stores/app';
|
||||
import * as EmbedState from '../../../../stores/embedState';
|
||||
import { EmbedConfig } from '../../../../stores/embedState';
|
||||
import { EmbedItemType } from '../../../../stores/embedState/constants';
|
||||
import { configurations } from '../configurations';
|
||||
import { Boolean } from './components/Boolean';
|
||||
import { Color } from './components/Color';
|
||||
import { Enum } from './components/Enum';
|
||||
import { Import } from './components/Import';
|
||||
import { PreviewWrapper } from './components/PreviewWrapper';
|
||||
import { Radius } from './components/Radius';
|
||||
import { RichText } from './components/RichText';
|
||||
import { Size } from './components/Size';
|
||||
import { SizeWithout0 } from './components/SizeWithout0';
|
||||
import { Slider } from './components/Slider';
|
||||
import { Text as TextController } from './components/Text';
|
||||
|
||||
export interface EmbedPreviewControllerProps {
|
||||
configKey: string;
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
params,
|
||||
}: {
|
||||
params: { embed: keyof typeof configurations };
|
||||
}) {
|
||||
const embedStateStore = EmbedState.useStore();
|
||||
const appStore = App.useStore();
|
||||
const config = configurations[params.embed] as EmbedConfig;
|
||||
const [backgroundImage, setBackgroundImage] = useState(imgur('SO13zur'));
|
||||
const fileInput = useRef<HTMLInputElement>();
|
||||
|
||||
useEffect(() => {
|
||||
fileInput.current = document.createElement('input');
|
||||
fileInput.current.type = 'file';
|
||||
fileInput.current.style.display = 'none';
|
||||
fileInput.current.accept = 'image/*';
|
||||
fileInput.current.onchange = (event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
setBackgroundImage(URL.createObjectURL(file));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageWrapper padding="0" size={1028 + 640}>
|
||||
<Flex>
|
||||
<ScrollArea
|
||||
scrollbars="vertical"
|
||||
style={{ height: '100%', maxWidth: 320 }}
|
||||
>
|
||||
<Flex direction="column" gap="2" p="4">
|
||||
<Import />
|
||||
|
||||
<Heading>Export</Heading>
|
||||
<Text size="2" color="gray" mb="2">
|
||||
Copy all properties for you streaming software
|
||||
</Text>
|
||||
|
||||
<Flex mb="6" gap="2" wrap="wrap">
|
||||
<CopyButton
|
||||
copy={() => {
|
||||
const { wargaming } = appStore.getState().logins;
|
||||
|
||||
if (!wargaming) return;
|
||||
|
||||
const state = embedStateStore.getState();
|
||||
const initial = embedStateStore.getInitialState();
|
||||
const shallowState: EmbedState.EmbedState = {};
|
||||
|
||||
Object.entries(state).forEach(([key, value]) => {
|
||||
if (value !== initial[key]) {
|
||||
shallowState[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
id: `${wargaming.id}`,
|
||||
state: stringify(shallowState),
|
||||
});
|
||||
|
||||
return `${location.origin}/tools/embed/${params.embed}/host?${searchParams.toString()}`;
|
||||
}}
|
||||
>
|
||||
<Link2Icon />
|
||||
Copy URL
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
copy={() =>
|
||||
'body { margin: 0; background-color: transparent; overflow: hidden; }'
|
||||
}
|
||||
>
|
||||
<CodeIcon />
|
||||
Copy CSS
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
copy={() => `${embedStateStore.getState().width}`}
|
||||
>
|
||||
<WidthIcon />
|
||||
Copy width
|
||||
</CopyButton>
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
copy={() => `${embedStateStore.getState().height}`}
|
||||
>
|
||||
<HeightIcon />
|
||||
Copy height
|
||||
</CopyButton>
|
||||
<CopyButton variant="outline" copy={() => '`'}>
|
||||
<TimerIcon />
|
||||
Copy framerate
|
||||
</CopyButton>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="between" align="center">
|
||||
<Heading>Customize</Heading>
|
||||
<Button
|
||||
color="red"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
embedStateStore.setState(
|
||||
embedStateStore.getInitialState(),
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ResetIcon /> Reset
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text size="2" color="gray" mb="2">
|
||||
Bells and whistles of your embed
|
||||
</Text>
|
||||
|
||||
{Object.keys(config).map((configKey) => {
|
||||
const item = config[configKey];
|
||||
let control: ReactNode;
|
||||
let oneLiner = false;
|
||||
|
||||
switch (item.type) {
|
||||
case EmbedItemType.Boolean: {
|
||||
control = <Boolean configKey={configKey} />;
|
||||
oneLiner = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.Color: {
|
||||
control = <Color configKey={configKey} />;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.Radius: {
|
||||
control = <Radius configKey={configKey} />;
|
||||
oneLiner = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.Size: {
|
||||
control = <Size configKey={configKey} />;
|
||||
oneLiner = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.SizeWithout0: {
|
||||
control = <SizeWithout0 configKey={configKey} />;
|
||||
oneLiner = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.Slider: {
|
||||
control = <Slider configKey={configKey} config={item} />;
|
||||
oneLiner = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.String: {
|
||||
control = <TextController configKey={configKey} />;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.RichText: {
|
||||
control = <RichText configKey={configKey} />;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmbedItemType.Enum: {
|
||||
control = <Enum configKey={configKey} config={item} />;
|
||||
oneLiner = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction={oneLiner ? undefined : 'column'}
|
||||
gap={oneLiner ? '2' : '1'}
|
||||
justify={oneLiner ? 'between' : undefined}
|
||||
mb={oneLiner ? '2' : '4'}
|
||||
pb={item.pad ? '6' : undefined}
|
||||
>
|
||||
<Text>{capitalize(startCase(configKey as string))}</Text>
|
||||
<Flex
|
||||
gap="2"
|
||||
align="center"
|
||||
flexGrow="1"
|
||||
justify={oneLiner ? 'end' : undefined}
|
||||
>
|
||||
{control}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
|
||||
<Box
|
||||
p="4"
|
||||
flexGrow="1"
|
||||
position="sticky"
|
||||
// TODO: re-implement this sticky preview
|
||||
// top={`${NAVBAR_HEIGHT}px`}
|
||||
// height={`calc(100vh - ${NAVBAR_HEIGHT}px)`}
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
height="100%"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-3)',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'var(--shadow-3)',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
justify="between"
|
||||
align="center"
|
||||
p="2"
|
||||
pl="3"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel-solid)',
|
||||
}}
|
||||
>
|
||||
<Text color="gray">Example preview</Text>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<ImageIcon /> Upload test background
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Box
|
||||
flexGrow="1"
|
||||
style={{
|
||||
background: `url(${backgroundImage}) center / cover no-repeat`,
|
||||
}}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<PreviewWrapper name={params.embed} />
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { grayA, grayDark, grayDarkA } from '@radix-ui/colors';
|
||||
import { use } from 'react';
|
||||
import {
|
||||
BreakdownEmbedCard,
|
||||
BreakdownEmbedWrapper,
|
||||
} from '../../../../components/TanksEmbed';
|
||||
import { tankDefinitionsArray } from '../../../../core/blitzkit/tankDefinitions';
|
||||
import {
|
||||
CompositeStatsKey,
|
||||
compositeStatsKeys,
|
||||
} from '../../../../core/statistics/compositeStats/constants';
|
||||
import strings from '../../../../lang/en-US.json';
|
||||
import { EmbedConfig } from '../../../../stores/embedState';
|
||||
import { EmbedItemType } from '../../../../stores/embedState/constants';
|
||||
import { useEmbedStateCurry } from '../../../../stores/embedState/utilities';
|
||||
|
||||
const compositeStatsKeysOptions = compositeStatsKeys.map((value) => ({
|
||||
label: strings.common.composite_stats[value],
|
||||
value,
|
||||
}));
|
||||
|
||||
export const breakdownConfig = {
|
||||
width: { type: EmbedItemType.Slider, default: 320, min: 128, max: 640 },
|
||||
height: { type: EmbedItemType.Slider, default: 492, min: 128, max: 1024 },
|
||||
|
||||
showTotal: { type: EmbedItemType.Boolean, default: true, pad: true },
|
||||
|
||||
listGap: { type: EmbedItemType.Size, default: '2' },
|
||||
listMaxTanks: {
|
||||
type: EmbedItemType.Slider,
|
||||
default: 4,
|
||||
min: 0,
|
||||
max: 8,
|
||||
pad: true,
|
||||
},
|
||||
|
||||
cardRadius: { type: EmbedItemType.Radius, default: '2' },
|
||||
cardHeaderBackgroundColor: {
|
||||
type: EmbedItemType.Color,
|
||||
default: grayDarkA.grayA3,
|
||||
},
|
||||
cardHeaderPadding: { type: EmbedItemType.Size, default: '1' },
|
||||
cardTitle: {
|
||||
type: EmbedItemType.RichText,
|
||||
default: { color: grayDark.gray12, weight: 'bold', size: '3' },
|
||||
},
|
||||
cardTitleTypeColor: { type: EmbedItemType.Boolean, default: true },
|
||||
cardTitleClassIcon: { type: EmbedItemType.Boolean, default: true },
|
||||
cardBodyBackgroundColor: {
|
||||
type: EmbedItemType.Color,
|
||||
default: grayA.grayA11,
|
||||
},
|
||||
cardBodyPadding: {
|
||||
type: EmbedItemType.Size,
|
||||
default: '2',
|
||||
pad: true,
|
||||
},
|
||||
|
||||
columnGap: { type: EmbedItemType.Size, default: '0' },
|
||||
columnValue: {
|
||||
type: EmbedItemType.RichText,
|
||||
default: { color: grayDark.gray12, size: '3', weight: 'regular' },
|
||||
},
|
||||
columnLabel: {
|
||||
type: EmbedItemType.RichText,
|
||||
default: { color: grayDark.gray11, size: '2', weight: 'regular' },
|
||||
pad: true,
|
||||
},
|
||||
|
||||
column1: {
|
||||
type: EmbedItemType.Enum,
|
||||
default: 'cumulative_battles' satisfies CompositeStatsKey,
|
||||
options: compositeStatsKeysOptions,
|
||||
},
|
||||
column2: {
|
||||
type: EmbedItemType.Enum,
|
||||
default: 'normalized_wins' satisfies CompositeStatsKey,
|
||||
options: compositeStatsKeysOptions,
|
||||
},
|
||||
column3: {
|
||||
type: EmbedItemType.Enum,
|
||||
default: 'cumulative_wn8' satisfies CompositeStatsKey,
|
||||
options: compositeStatsKeysOptions,
|
||||
},
|
||||
column4: {
|
||||
type: EmbedItemType.Enum,
|
||||
default: 'normalized_damage_dealt' satisfies CompositeStatsKey,
|
||||
options: compositeStatsKeysOptions,
|
||||
},
|
||||
column1CustomLabel: { type: EmbedItemType.String, default: '' },
|
||||
column2CustomLabel: { type: EmbedItemType.String, default: '' },
|
||||
column3CustomLabel: { type: EmbedItemType.String, default: '' },
|
||||
column4CustomLabel: { type: EmbedItemType.String, default: '' },
|
||||
} satisfies EmbedConfig;
|
||||
|
||||
export function BreakdownPreview() {
|
||||
const tanks = use(tankDefinitionsArray);
|
||||
const { useState } = useEmbedStateCurry<typeof breakdownConfig>();
|
||||
|
||||
return (
|
||||
<BreakdownEmbedWrapper>
|
||||
{useState('showTotal') && <BreakdownEmbedCard tank={null} />}
|
||||
|
||||
{tanks.slice(0, useState('listMaxTanks')).map((tank) => (
|
||||
<BreakdownEmbedCard key={tank.id} tank={tank} />
|
||||
))}
|
||||
</BreakdownEmbedWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { EmbedConfig } from '../../../../stores/embedState';
|
||||
import { BreakdownPreview, breakdownConfig } from './breakdown';
|
||||
|
||||
export const configurations = {
|
||||
breakdown: breakdownConfig,
|
||||
} satisfies Record<string, EmbedConfig>;
|
||||
|
||||
export const previews = {
|
||||
breakdown: BreakdownPreview,
|
||||
} satisfies Record<string, () => ReactNode>;
|
||||
@@ -0,0 +1,5 @@
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
|
||||
export default function Page() {
|
||||
return <PageWrapper></PageWrapper>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
EmbedConfig,
|
||||
ExtractEmbedConfigTypes,
|
||||
RadixColorCompound,
|
||||
RadixRadius,
|
||||
} from '../../../stores/embedState';
|
||||
|
||||
export function extractEmbedConfigDefaults<Config extends EmbedConfig>(
|
||||
config: Config,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).map(([key, value]) => [key, value.default]),
|
||||
) as ExtractEmbedConfigTypes<Config>;
|
||||
}
|
||||
|
||||
export function toWidthVar(state: number) {
|
||||
return `${state}px`;
|
||||
}
|
||||
|
||||
export function toRadiusVar(state: RadixRadius) {
|
||||
return `var(--radius-${state})`;
|
||||
}
|
||||
|
||||
export function toColorVar(state: RadixColorCompound) {
|
||||
return `var(--${state.base}-${state.variant})`;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { imgur, ImgurSize } from '@blitzkit/core';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
import { TOOLS } from '../../constants/tools';
|
||||
|
||||
interface ToolsLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ToolsLayout({ children }: ToolsLayoutProps) {
|
||||
const pathname = usePathname();
|
||||
const pathSplit = pathname.split('/');
|
||||
const toolId = pathSplit.at(2);
|
||||
const isToolRoot = pathname === `/tools/${toolId}`;
|
||||
const tool = TOOLS.find((tool) => tool.id === toolId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tool && (
|
||||
<>
|
||||
{isToolRoot && (
|
||||
<>
|
||||
<title>{tool.title}</title>
|
||||
<meta property="og:image" content={imgur(tool.image)} />
|
||||
|
||||
{tool.pageDescription !== undefined && (
|
||||
<>
|
||||
<meta name="description" content={tool.pageDescription} />
|
||||
<meta
|
||||
property="og:description"
|
||||
content={tool.pageDescription}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pathSplit.length === 3 && (
|
||||
<div
|
||||
style={{
|
||||
height: 128,
|
||||
boxShadow: `inset 0 -192px 128px -128px red`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
backgroundImage: `url(${imgur(tool!.image, {
|
||||
format: 'jpeg',
|
||||
size: ImgurSize.HugeThumbnail,
|
||||
})})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
// filter: 'blur(4px)',
|
||||
transform: 'scale(calc((128 + 8) / 128))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href={`/tools/${toolId}`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
fontWeight: 900,
|
||||
color: 'red',
|
||||
fontSize: 32,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{tool?.title}
|
||||
</Link>
|
||||
<span
|
||||
style={{
|
||||
color: 'red',
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{tool?.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Table } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { RowLoader } from './RowLoader';
|
||||
|
||||
export function BodyLoader() {
|
||||
return (
|
||||
<Table.Body>
|
||||
{times(10, (index) => (
|
||||
<RowLoader key={index} />
|
||||
))}
|
||||
</Table.Body>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FilterControl } from '../../tankopedia/components/FilterControl';
|
||||
import { PlayerCountControl } from './PlayerCountControl';
|
||||
|
||||
export function Controls() {
|
||||
return (
|
||||
<>
|
||||
<FilterControl />
|
||||
<PlayerCountControl />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretSortIcon,
|
||||
CaretUpIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Flex, IconButton, Table } from '@radix-ui/themes';
|
||||
import { StickyColumnHeaderCell } from '../../../../components/StickyColumnHeaderCell';
|
||||
import * as TankPerformanceSort from '../../../../stores/tankPerformanceSort';
|
||||
import {
|
||||
TankPerformanceSortTypeNames,
|
||||
TankPerformanceSortTypeNamesArray,
|
||||
} from '../../../../stores/tankPerformanceSort/constants';
|
||||
|
||||
export function Header() {
|
||||
const sort = TankPerformanceSort.use();
|
||||
const store = TankPerformanceSort.useStore();
|
||||
|
||||
return (
|
||||
<Table.Header>
|
||||
<Table.Row align="center">
|
||||
<StickyColumnHeaderCell>Tank</StickyColumnHeaderCell>
|
||||
{TankPerformanceSortTypeNamesArray.map((type) => {
|
||||
const isSelected = sort.type === type;
|
||||
|
||||
return (
|
||||
<StickyColumnHeaderCell
|
||||
px="2"
|
||||
key={type}
|
||||
width="0"
|
||||
justify="center"
|
||||
>
|
||||
<Flex align="center" gap="1">
|
||||
{TankPerformanceSortTypeNames[type]}
|
||||
|
||||
<IconButton
|
||||
size={isSelected ? '1' : '2'}
|
||||
variant={isSelected ? 'soft' : 'ghost'}
|
||||
color={isSelected ? undefined : 'gray'}
|
||||
highContrast={!isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
store.setState({ direction: -sort.direction as 1 | -1 });
|
||||
} else {
|
||||
store.setState({ type, direction: -1 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isSelected && <CaretSortIcon />}
|
||||
{isSelected && (
|
||||
<>
|
||||
{sort.direction === 1 && <CaretUpIcon />}
|
||||
{sort.direction === -1 && <CaretDownIcon />}
|
||||
</>
|
||||
)}
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</StickyColumnHeaderCell>
|
||||
);
|
||||
})}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { InfoCircledIcon } from '@radix-ui/react-icons';
|
||||
import { Callout, Flex } from '@radix-ui/themes';
|
||||
import { use } from 'react';
|
||||
import { averageDefinitions } from '../../../../core/blitzkit/averageDefinitions';
|
||||
import { discoveredIdsDefinitions } from '../../../../core/blitzkit/discoveredIdDefinitions';
|
||||
import { formatCompact } from '../../../../core/math/formatCompact';
|
||||
|
||||
export function Info() {
|
||||
const awaitedDiscoveredIdsDefinitions = use(discoveredIdsDefinitions);
|
||||
const awaitedAverageDefinitions = use(averageDefinitions);
|
||||
const samples = awaitedDiscoveredIdsDefinitions.count;
|
||||
const minutesAgo = Math.floor(
|
||||
(Date.now() - awaitedAverageDefinitions.time) / (1000 * 60),
|
||||
);
|
||||
const hoursAgo = Math.floor(minutesAgo / 60);
|
||||
|
||||
return (
|
||||
<Flex justify="center">
|
||||
<Callout.Root style={{ width: 'fit-content' }}>
|
||||
<Callout.Icon>
|
||||
<InfoCircledIcon />
|
||||
</Callout.Icon>
|
||||
<Callout.Text>
|
||||
Career stats based on {formatCompact(Math.round(samples))} players.
|
||||
Updated {hoursAgo === 0 ? minutesAgo : hoursAgo}{' '}
|
||||
{hoursAgo === 0 ? 'minutes' : 'hours'} ago.
|
||||
</Callout.Text>
|
||||
</Callout.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Flex, Select, Text } from '@radix-ui/themes';
|
||||
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
|
||||
import { PlayerCountPeriod } from '../../../../stores/tankPerformancePersistent/constants';
|
||||
|
||||
export function PlayerCountControl() {
|
||||
const playerCountPeriod = TankPerformancePersistent.use(
|
||||
(state) => state.playerCountPeriod,
|
||||
);
|
||||
const mutateTankPerformanceSort = TankPerformancePersistent.useMutation();
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" gap="2">
|
||||
<Text>Player count</Text>
|
||||
<Select.Root
|
||||
value={playerCountPeriod}
|
||||
onValueChange={(period) => {
|
||||
mutateTankPerformanceSort((draft) => {
|
||||
draft.playerCountPeriod = period as PlayerCountPeriod;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Select.Trigger />
|
||||
<Select.Content>
|
||||
<Select.Item value={PlayerCountPeriod.Past120Days}>
|
||||
Past 120 days
|
||||
</Select.Item>
|
||||
<Select.Item value={PlayerCountPeriod.Past90Days}>
|
||||
Past 90 days
|
||||
</Select.Item>
|
||||
<Select.Item value={PlayerCountPeriod.Past60Days}>
|
||||
Past 60 days
|
||||
</Select.Item>
|
||||
<Select.Item value={PlayerCountPeriod.ThisMonth}>
|
||||
This month
|
||||
</Select.Item>
|
||||
<Select.Item value={PlayerCountPeriod.ThisWeek}>
|
||||
This week
|
||||
</Select.Item>
|
||||
<Select.Item value={PlayerCountPeriod.Yesterday}>
|
||||
Yesterday
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton, Table } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { memo, useRef } from 'react';
|
||||
import { useIntersection } from '../../../../hooks/useIntersection';
|
||||
import { TankPerformanceSortTypeNamesArray } from '../../../../stores/tankPerformanceSort/constants';
|
||||
|
||||
interface RowLoaderProps {
|
||||
onIntersection?: () => void;
|
||||
}
|
||||
|
||||
export const RowLoader = memo<RowLoaderProps>(({ onIntersection }) => {
|
||||
const row = useRef<HTMLTableRowElement>(null);
|
||||
|
||||
useIntersection(() => onIntersection?.(), row, {
|
||||
disabled: onIntersection === undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<Table.Row ref={row}>
|
||||
<Table.RowHeaderCell align="center">
|
||||
<Skeleton height="100%" width="128px" />
|
||||
</Table.RowHeaderCell>
|
||||
|
||||
{times(TankPerformanceSortTypeNamesArray.length, (index) => (
|
||||
<Table.Cell key={index} align="center">
|
||||
<Skeleton height="1em" width="32px" />
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { Flex } from '@radix-ui/themes';
|
||||
import { Suspense } from 'react';
|
||||
import { StickyTableRoot } from '../../../../components/StickyTableRoot';
|
||||
import { BodyLoader } from './BodyLoader';
|
||||
import { Header } from './Header';
|
||||
import { Tanks } from './Tanks';
|
||||
|
||||
export function TankTable() {
|
||||
return (
|
||||
<Flex justify="center">
|
||||
<StickyTableRoot
|
||||
size={{ initial: '1', sm: '2' }}
|
||||
variant="surface"
|
||||
style={{ maxWidth: '100%', maxHeight: 'calc(100vh - 256px)' }}
|
||||
>
|
||||
<Header />
|
||||
<Suspense fallback={<BodyLoader />}>
|
||||
<Tanks />
|
||||
</Suspense>
|
||||
</StickyTableRoot>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Table } from '@radix-ui/themes';
|
||||
import { memo, use } from 'react';
|
||||
import { TankRowHeaderCell } from '../../../../components/TankRowHeaderCell';
|
||||
import { averageDefinitions } from '../../../../core/blitzkit/averageDefinitions';
|
||||
import { TankDefinition } from '../../../../core/blitzkit/tankDefinitions';
|
||||
import { formatCompact } from '../../../../core/math/formatCompact';
|
||||
import { useAveragesExclusionRatio } from '../../../../hooks/useAveragesExclusionRatio';
|
||||
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
|
||||
|
||||
interface TankRowProps {
|
||||
tank: TankDefinition;
|
||||
}
|
||||
|
||||
export const TankRow = memo<TankRowProps>(
|
||||
({ tank }) => {
|
||||
const awaitedAverageDefinitions = use(averageDefinitions);
|
||||
const averages = awaitedAverageDefinitions.averages[tank.id];
|
||||
const ratio = useAveragesExclusionRatio();
|
||||
const playerCountPeriod = TankPerformancePersistent.use(
|
||||
(state) => state.playerCountPeriod,
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<TankRowHeaderCell tank={tank} />
|
||||
|
||||
<Table.Cell align="center">
|
||||
{((averages.mu.wins / averages.mu.battles) * 100).toFixed(1)}%
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{formatCompact(
|
||||
Math.round(ratio * averages.samples[playerCountPeriod]),
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(
|
||||
averages.mu.damage_dealt / averages.mu.battles,
|
||||
).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(
|
||||
(averages.mu.survived_battles / averages.mu.battles) * 100,
|
||||
)}
|
||||
%
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(averages.mu.xp / averages.mu.battles).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{(averages.mu.frags / averages.mu.battles).toFixed(2)}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{(averages.mu.spotted / averages.mu.battles).toFixed(2)}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round((averages.mu.hits / averages.mu.shots) * 100)}%
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{(averages.mu.shots / averages.mu.battles).toFixed(1)}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{(averages.mu.hits / averages.mu.battles).toFixed(1)}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{(averages.mu.damage_dealt / averages.mu.damage_received).toFixed(2)}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(
|
||||
averages.mu.damage_received / averages.mu.battles,
|
||||
).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{(averages.mu.capture_points / averages.mu.battles).toFixed(2)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
},
|
||||
(prev, next) => prev.tank.id === next.tank.id,
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { Table } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { Suspense, use, useEffect, useMemo, useState } from 'react';
|
||||
import { averageDefinitionsArray } from '../../../../core/blitzkit/averageDefinitions';
|
||||
import { filterTank } from '../../../../core/blitzkit/filterTank';
|
||||
import { tankDefinitions } from '../../../../core/blitzkit/tankDefinitions';
|
||||
import * as TankFilters from '../../../../stores/tankFilters';
|
||||
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
|
||||
import * as TankPerformanceSort from '../../../../stores/tankPerformanceSort';
|
||||
import { RowLoader } from './RowLoader';
|
||||
import { TankRow } from './TankRow';
|
||||
import { Total } from './Total';
|
||||
|
||||
const PREVIEW_COUNT = 10;
|
||||
const DEFAULT_LOADED_ROWS = 25;
|
||||
|
||||
export function Tanks() {
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
const awaitedAverageDefinitionsArray = use(averageDefinitionsArray);
|
||||
const filters = TankFilters.use();
|
||||
const sort = TankPerformanceSort.use();
|
||||
const tankPerformancePersistentStore = TankPerformancePersistent.useStore();
|
||||
const tanksSorted = useMemo(() => {
|
||||
const { playerCountPeriod } = tankPerformancePersistentStore.getState();
|
||||
|
||||
switch (sort.type) {
|
||||
case 'accuracy':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction * (a.mu.hits / a.mu.shots - b.mu.hits / b.mu.shots),
|
||||
);
|
||||
case 'capturePoints':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.capture_points / a.mu.battles -
|
||||
b.mu.capture_points / b.mu.battles),
|
||||
);
|
||||
case 'damage':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.damage_dealt / a.mu.battles -
|
||||
b.mu.damage_dealt / b.mu.battles),
|
||||
);
|
||||
case 'damageRatio':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.damage_dealt / a.mu.damage_received -
|
||||
b.mu.damage_dealt / b.mu.damage_received),
|
||||
);
|
||||
case 'damageTaken':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.damage_received / a.mu.battles -
|
||||
b.mu.damage_received / b.mu.battles),
|
||||
);
|
||||
case 'hits':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.hits / a.mu.battles - b.mu.hits / b.mu.battles),
|
||||
);
|
||||
case 'kills':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.frags / a.mu.battles - b.mu.frags / b.mu.battles),
|
||||
);
|
||||
case 'shots':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.shots / a.mu.battles - b.mu.shots / b.mu.battles),
|
||||
);
|
||||
case 'players':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.samples[playerCountPeriod] - b.samples[playerCountPeriod]),
|
||||
);
|
||||
case 'spots':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.spotted / a.mu.battles - b.mu.spotted / b.mu.battles),
|
||||
);
|
||||
case 'survival':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.survived_battles / a.mu.battles -
|
||||
b.mu.survived_battles / b.mu.battles),
|
||||
);
|
||||
case 'winrate':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction *
|
||||
(a.mu.wins / a.mu.battles - b.mu.wins / b.mu.battles),
|
||||
);
|
||||
case 'xp':
|
||||
return awaitedAverageDefinitionsArray.sort(
|
||||
(a, b) =>
|
||||
sort.direction * (a.mu.xp / a.mu.battles - b.mu.xp / b.mu.battles),
|
||||
);
|
||||
}
|
||||
}, [sort]);
|
||||
const tanks = useMemo(() => {
|
||||
return tanksSorted.filter((tank) =>
|
||||
filterTank(filters, awaitedTankDefinitions[tank.id]),
|
||||
);
|
||||
}, [filters, sort]);
|
||||
const [loadedRows, setLoadedRows] = useState(DEFAULT_LOADED_ROWS);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadedRows(DEFAULT_LOADED_ROWS);
|
||||
}, [filters, sort]);
|
||||
|
||||
return (
|
||||
<Table.Body>
|
||||
<Total tanks={tanks} />
|
||||
|
||||
{tanks.slice(0, loadedRows).map((averages) => {
|
||||
const tank = awaitedTankDefinitions[averages.id];
|
||||
|
||||
if (tank === undefined) return null;
|
||||
|
||||
return (
|
||||
<Suspense key={tank.id} fallback={<RowLoader />}>
|
||||
<TankRow tank={tank} />
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
|
||||
{times(Math.min(PREVIEW_COUNT, tanks.length - loadedRows), (index) => {
|
||||
return (
|
||||
<RowLoader
|
||||
key={index}
|
||||
onIntersection={() => {
|
||||
setLoadedRows((state) => Math.min(state + 2, tanks.length));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { ResponsiveBar } from '@nivo/bar';
|
||||
import { Box, Flex, Heading } from '@radix-ui/themes';
|
||||
import { times } from 'lodash';
|
||||
import { use } from 'react';
|
||||
import { TankClass } from '../../../../components/Tanks';
|
||||
import { TANK_CLASSES } from '../../../../components/Tanks/components/Item/constants';
|
||||
import { averageDefinitionsArray } from '../../../../core/blitzkit/averageDefinitions';
|
||||
import {
|
||||
tankDefinitions,
|
||||
Tier,
|
||||
} from '../../../../core/blitzkit/tankDefinitions';
|
||||
import { TIER_ROMAN_NUMERALS } from '../../../../core/blitzkit/tankDefinitions/constants';
|
||||
import { formatCompact } from '../../../../core/math/formatCompact';
|
||||
import { nivoTheme } from '../../../../core/nivo/theme';
|
||||
import strings from '../../../../lang/en-US.json';
|
||||
|
||||
export function TierBreakdown() {
|
||||
const awaitedAverageDefinitionsArray = use(averageDefinitionsArray);
|
||||
const awaitedTankDefinitions = use(tankDefinitions);
|
||||
|
||||
return (
|
||||
<Flex justify="center" py="6">
|
||||
<Flex direction="column" flexGrow="1" gap="4" maxWidth="640px">
|
||||
<Heading ml="2" size="4">
|
||||
Players (30d) per tier
|
||||
</Heading>
|
||||
|
||||
<Box height="320px" width="100%">
|
||||
<ResponsiveBar
|
||||
{...nivoTheme}
|
||||
data={times(10, (index) => {
|
||||
const tier = (index + 1) as Tier;
|
||||
const tierTanks = awaitedAverageDefinitionsArray.filter(
|
||||
({ id }) => awaitedTankDefinitions[id]?.tier === tier,
|
||||
);
|
||||
const populations: Record<TankClass, number> = {
|
||||
'AT-SPG': 0,
|
||||
heavyTank: 0,
|
||||
lightTank: 0,
|
||||
mediumTank: 0,
|
||||
};
|
||||
|
||||
tierTanks.forEach((stats) => {
|
||||
const tank = awaitedTankDefinitions[stats.id];
|
||||
populations[tank.class] +=
|
||||
stats.mu.battles * stats.samples.d_30;
|
||||
});
|
||||
|
||||
TANK_CLASSES.forEach((tankClass) => {
|
||||
populations[tankClass] = Math.round(populations[tankClass]);
|
||||
});
|
||||
|
||||
return { tier: TIER_ROMAN_NUMERALS[tier], ...populations };
|
||||
})}
|
||||
keys={[...TANK_CLASSES].reverse()}
|
||||
indexBy="tier"
|
||||
colors={{ scheme: 'dark2' }}
|
||||
label={(d) => formatCompact(d.value as number)}
|
||||
margin={{ right: 96, bottom: 20, left: 40 }}
|
||||
legendLabel={(d) =>
|
||||
strings.common.tank_class_short[d.id as TankClass]
|
||||
}
|
||||
axisLeft={{
|
||||
tickSize: 0,
|
||||
format: (value) => formatCompact(value as number),
|
||||
}}
|
||||
axisBottom={{ tickSize: 0 }}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: 'keys',
|
||||
anchor: 'bottom-right',
|
||||
direction: 'column',
|
||||
translateX: 120,
|
||||
itemsSpacing: 4,
|
||||
itemWidth: 100,
|
||||
itemHeight: 20,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Table } from '@radix-ui/themes';
|
||||
import { memo, use, useCallback } from 'react';
|
||||
import { StickyRowHeaderCell } from '../../../../components/StickyRowHeaderCell';
|
||||
import {
|
||||
averageDefinitions,
|
||||
AverageDefinitionsEntryWithId,
|
||||
} from '../../../../core/blitzkit/averageDefinitions';
|
||||
import { formatCompact } from '../../../../core/math/formatCompact';
|
||||
import { BlitzkitStats } from '../../../../core/statistics/compositeStats/constants';
|
||||
import { useAveragesExclusionRatio } from '../../../../hooks/useAveragesExclusionRatio';
|
||||
import * as TankPerformancePersistent from '../../../../stores/tankPerformancePersistent';
|
||||
|
||||
interface TotalProps {
|
||||
tanks: AverageDefinitionsEntryWithId[];
|
||||
}
|
||||
|
||||
export const Total = memo<TotalProps>(
|
||||
({ tanks }) => {
|
||||
const awaitedAverageDefinitions = use(averageDefinitions);
|
||||
const playerCountPeriod = TankPerformancePersistent.use(
|
||||
(state) => state.playerCountPeriod,
|
||||
);
|
||||
const ratio = useAveragesExclusionRatio();
|
||||
const sum = useCallback(
|
||||
(slice: (tank: BlitzkitStats) => number) => {
|
||||
return tanks.reduce(
|
||||
(acc, tank) => acc + tank.samples.total * slice(tank.mu),
|
||||
0,
|
||||
);
|
||||
},
|
||||
[tanks],
|
||||
);
|
||||
|
||||
const battles = sum(({ battles }) => battles);
|
||||
const winrate = sum(({ wins }) => wins) / battles;
|
||||
const damage = sum(({ damage_dealt }) => damage_dealt) / battles;
|
||||
const survival = sum(({ survived_battles }) => survived_battles) / battles;
|
||||
const xp = sum(({ xp }) => xp) / battles;
|
||||
const kills = sum(({ frags }) => frags) / battles;
|
||||
const spots = sum(({ spotted }) => spotted) / battles;
|
||||
const hits = sum(({ hits }) => hits) / battles;
|
||||
const shots = sum(({ shots }) => shots) / battles;
|
||||
const accuracy = hits / shots;
|
||||
const damageReceived =
|
||||
sum(({ damage_received }) => damage_received) / battles;
|
||||
const damageRatio = damage / damageReceived;
|
||||
const capturePoints = sum(({ capture_points }) => capture_points) / battles;
|
||||
|
||||
return (
|
||||
<Table.Row>
|
||||
<StickyRowHeaderCell style={{ overflow: 'hidden', display: 'flex' }}>
|
||||
Total
|
||||
</StickyRowHeaderCell>
|
||||
<Table.Cell align="center">{(winrate * 100).toFixed(1)}%</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{formatCompact(
|
||||
ratio * awaitedAverageDefinitions.samples[playerCountPeriod],
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(damage).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(survival * 100).toFixed(1)}%
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(xp).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">{kills.toFixed(2)}</Table.Cell>
|
||||
<Table.Cell align="center">{spots.toFixed(2)}</Table.Cell>
|
||||
<Table.Cell align="center">{(accuracy * 100).toFixed(0)}%</Table.Cell>
|
||||
<Table.Cell align="center">{shots.toFixed(1)}</Table.Cell>
|
||||
<Table.Cell align="center">{hits.toFixed(1)}</Table.Cell>
|
||||
<Table.Cell align="center">{damageRatio.toFixed(2)}</Table.Cell>
|
||||
<Table.Cell align="center">
|
||||
{Math.round(damageReceived).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell align="center">{capturePoints.toFixed(2)}</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
},
|
||||
(prev, next) => prev.tanks === next.tanks,
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ReactNode } from 'react';
|
||||
import * as TankFilters from '../../../stores/tankFilters';
|
||||
import * as TankPerformancePersistent from '../../../stores/tankPerformancePersistent';
|
||||
import * as TankPerformanceSort from '../../../stores/tankPerformanceSort';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TankPerformancePersistent.Provider>
|
||||
<TankPerformanceSort.Provider>
|
||||
<TankFilters.Provider>{children}</TankFilters.Provider>
|
||||
</TankPerformanceSort.Provider>
|
||||
</TankPerformancePersistent.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import PageWrapper from '../../../components/PageWrapper';
|
||||
import { Controls } from './components/Controls';
|
||||
import { Info } from './components/Info';
|
||||
import { TankTable } from './components/Table';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageWrapper color="jade" noMaxWidth>
|
||||
<Info />
|
||||
{/* <TierBreakdown /> */}
|
||||
<Controls />
|
||||
<TankTable />
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const spinKeyframes = keyframes({
|
||||
from: { transform: 'rotate(0deg)' },
|
||||
to: { transform: 'rotate(360deg)' },
|
||||
});
|
||||
|
||||
export const infiniteSpin = style({
|
||||
animationName: spinKeyframes,
|
||||
animationIterationCount: 'infinite',
|
||||
animationTimingFunction: 'linear',
|
||||
animationDuration: '1s',
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
SymbolIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import { Flex, IconButton, TextField } from '@radix-ui/themes';
|
||||
import { range } from 'lodash';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { infiniteSpin } from './index.css';
|
||||
|
||||
interface PageTurnerProps {
|
||||
leaderboard: Record<number, number>;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
pages: number;
|
||||
onPageChange: (page: number, isButtonClick: boolean) => void;
|
||||
totalPlayers: number;
|
||||
}
|
||||
|
||||
export function PageTurner({
|
||||
page,
|
||||
rowsPerPage,
|
||||
onPageChange,
|
||||
pages,
|
||||
leaderboard,
|
||||
totalPlayers,
|
||||
}: PageTurnerProps) {
|
||||
const pageInput = useRef<HTMLInputElement>(null);
|
||||
const rightLoading = range(
|
||||
page * rowsPerPage,
|
||||
Math.min(totalPlayers - 1, page * rowsPerPage + rowsPerPage + 1),
|
||||
).some((index) => !(index in leaderboard));
|
||||
const leftLoading = range(
|
||||
Math.max(0, page * rowsPerPage - 1),
|
||||
Math.max(0, page * rowsPerPage - rowsPerPage - 1) + 1,
|
||||
).some((index) => !(index in leaderboard));
|
||||
|
||||
useEffect(() => {
|
||||
if (pageInput.current) pageInput.current.value = `${page + 1}`;
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<Flex justify="center" wrap="wrap" gap="2">
|
||||
<Flex gap="2">
|
||||
<IconButton
|
||||
variant="soft"
|
||||
onClick={() => onPageChange(Math.max(page - 1, 0), true)}
|
||||
disabled={page === 0 || leftLoading}
|
||||
>
|
||||
{leftLoading ? (
|
||||
<SymbolIcon className={infiniteSpin} />
|
||||
) : (
|
||||
<CaretLeftIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
<TextField.Root
|
||||
type="number"
|
||||
ref={pageInput}
|
||||
style={{ textAlign: 'center' }}
|
||||
min={1}
|
||||
max={pages}
|
||||
onBlur={(event) => {
|
||||
onPageChange(
|
||||
Math.max(0, Math.min(pages - 1, event.target.valueAsNumber - 1)),
|
||||
false,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TextField.Slot>Page</TextField.Slot>
|
||||
<TextField.Slot>out of {pages}</TextField.Slot>
|
||||
</TextField.Root>
|
||||
<IconButton
|
||||
variant="soft"
|
||||
onClick={() => {
|
||||
onPageChange(Math.min(page + 1, pages - 1), true);
|
||||
}}
|
||||
disabled={page === pages - 1 || rightLoading}
|
||||
>
|
||||
{rightLoading ? (
|
||||
<SymbolIcon className={infiniteSpin} />
|
||||
) : (
|
||||
<CaretRightIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user