mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-23 19:49:50 +02:00
feat: voting averages
This commit is contained in:
1 parent
c3c16bf607
commit
2d2d31adf7
24 files changed
+643
-254
No files matched your search
Vendored
+1
@@ -28,6 +28,7 @@
|
|||||||
"blitzkit",
|
"blitzkit",
|
||||||
"blitzrinth",
|
"blitzrinth",
|
||||||
"blitzstars",
|
"blitzstars",
|
||||||
|
"cacheable",
|
||||||
"Callouts",
|
"Callouts",
|
||||||
"Camo",
|
"Camo",
|
||||||
"CDON",
|
"CDON",
|
||||||
|
|||||||
+23
-6
@@ -8,12 +8,29 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Users {
|
model User {
|
||||||
blitz Int @id
|
blitz_id Int @id @map("blitz") // map: "blitz" was too vague
|
||||||
last_used DateTime? @default(now())
|
last_used DateTime? @default(now())
|
||||||
|
tank_votes TankVote[]
|
||||||
|
|
||||||
|
@@map("Users") // map: "Users" was mistakenly plural
|
||||||
}
|
}
|
||||||
|
|
||||||
model DiscordUsers {
|
model TankVote {
|
||||||
discord BigInt @id
|
id Int @id
|
||||||
blitz Int
|
last_updated DateTime @default(now())
|
||||||
|
easiness Int?
|
||||||
|
firepower Int?
|
||||||
|
maneuverability Int?
|
||||||
|
survivability Int?
|
||||||
|
|
||||||
|
user User @relation(fields: [user_blitz_id], references: [blitz_id])
|
||||||
|
user_blitz_id Int
|
||||||
|
}
|
||||||
|
|
||||||
|
model DiscordUser {
|
||||||
|
discord_id BigInt @id @map("discord") // map: "discord" was too vague
|
||||||
|
blitz_id Int @map("blitz") // map: "blitz" was too vague
|
||||||
|
|
||||||
|
@@map("DiscordUsers") // map: "DiscordUsers" was mistakenly plural
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 } 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 BlitzkitResponse<TankVotes>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 BlitzkitResponse<TankVotes>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
blitzIdString !== null &&
|
||||||
|
(!(await isValidBlitzId(blitzId, blitzToken ?? undefined)) ||
|
||||||
|
isNaN(blitzId))
|
||||||
|
) {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'error',
|
||||||
|
error: 'INVALID_TOKEN_OR_PLAYER_ID',
|
||||||
|
} satisfies BlitzkitResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 BlitzkitResponse<TankVotes>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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 } 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 BlitzkitResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blitzId = Number(request.nextUrl.searchParams.get('player'));
|
||||||
|
|
||||||
|
if (isNaN(blitzId) || !(await isValidBlitzId(blitzId))) {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'error',
|
||||||
|
error: 'INVALID_PLAYER_ID',
|
||||||
|
} satisfies BlitzkitResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 BlitzkitResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
votes[category] = vote as StarsInt;
|
||||||
|
recognizedVoteCategories++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recognizedVoteCategories === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'error',
|
||||||
|
error: 'NO_VOTES',
|
||||||
|
} satisfies BlitzkitResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<undefined>);
|
||||||
|
} catch (message) {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'error',
|
||||||
|
error: 'DATABASE_ERROR',
|
||||||
|
message,
|
||||||
|
} satisfies BlitzkitResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
|
||||||
|
import { Box } from '@radix-ui/themes';
|
||||||
|
|
||||||
|
interface PartialStarProps {
|
||||||
|
fill: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PartialStar({ fill }: PartialStarProps) {
|
||||||
|
return (
|
||||||
|
<Box height="1em" width="1em" position="relative">
|
||||||
|
<StarIcon width="1em" height="1em" />
|
||||||
|
{fill > 0 && (
|
||||||
|
<Box
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
width={`${fill * 100}%`}
|
||||||
|
position="absolute"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<StarFilledIcon width="1em" height="1em" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Flex, Text, TextProps } from '@radix-ui/themes';
|
||||||
|
import { times } from 'lodash';
|
||||||
|
import { PartialStar } from './components/PartialStar';
|
||||||
|
|
||||||
|
export type StarsInt = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
interface StarsProps {
|
||||||
|
stars: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stars({ stars }: StarsProps) {
|
||||||
|
let color: TextProps['color'] = 'green';
|
||||||
|
|
||||||
|
if (stars === null) color = 'gray';
|
||||||
|
else if (stars < 1.5) color = 'tomato';
|
||||||
|
else if (stars < 2.5) color = 'orange';
|
||||||
|
else if (stars < 3.5) color = 'amber';
|
||||||
|
else if (stars < 4.5) color = 'blue';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={color}>
|
||||||
|
<Flex>
|
||||||
|
{stars !== null &&
|
||||||
|
times(Math.floor(stars), (index) => (
|
||||||
|
<PartialStar fill={1} key={index} />
|
||||||
|
))}
|
||||||
|
{stars !== null && stars % 1 !== 0 && <PartialStar fill={stars % 1} />}
|
||||||
|
{times(5 - Math.ceil(stars ?? 0), (index) => (
|
||||||
|
<PartialStar fill={0} key={index} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { Flex, Text } from '@radix-ui/themes';
|
||||||
|
import { Stars } from '../../Stars';
|
||||||
|
|
||||||
|
interface StarRowProps {
|
||||||
|
stars: number | null;
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarRow({ stars, children }: StarRowProps) {
|
||||||
|
return (
|
||||||
|
<Text color={stars === null ? 'gray' : undefined}>
|
||||||
|
<Flex align="center" justify="between" width="15rem">
|
||||||
|
{children}
|
||||||
|
<Stars stars={stars} />
|
||||||
|
</Flex>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { WargamingLoginButton } from '../../../../../../../../../components/Navbar/components/WargamingLoginButton';
|
||||||
|
import { formatCompact } from '../../../../../../../../../core/math/formatCompact';
|
||||||
|
import { useTankVotes } from '../../../../../../../../../hooks/useTankVotes';
|
||||||
|
import * as App from '../../../../../../../../../stores/app';
|
||||||
|
import * as Duel from '../../../../../../../../../stores/duel';
|
||||||
|
import { StarRow } from './components/StarRow';
|
||||||
|
|
||||||
|
export const Votes = memo(
|
||||||
|
() => {
|
||||||
|
const id = Duel.use((state) => state.protagonist.tank.id);
|
||||||
|
const votes = useTankVotes(id);
|
||||||
|
const wargaming = App.useDeferred(
|
||||||
|
undefined,
|
||||||
|
(state) => state.logins.wargaming,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="3">
|
||||||
|
<Flex justify="between" align="end">
|
||||||
|
<Heading size="4">
|
||||||
|
{formatCompact(votes.votes)} {votes.votes === 1 ? 'vote' : 'votes'}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{!wargaming && (
|
||||||
|
<WargamingLoginButton variant="outline">
|
||||||
|
Login to vote
|
||||||
|
</WargamingLoginButton>
|
||||||
|
)}
|
||||||
|
{wargaming && <Button>Vote</Button>}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex gap="0" direction="column">
|
||||||
|
<Flex
|
||||||
|
gap={{ initial: '0', sm: '6' }}
|
||||||
|
direction={{ initial: 'column', sm: 'row' }}
|
||||||
|
>
|
||||||
|
<StarRow stars={votes.categories.easiness} children="Easiness" />
|
||||||
|
<StarRow stars={votes.categories.firepower} children="Firepower" />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex
|
||||||
|
gap={{ initial: '0', sm: '6' }}
|
||||||
|
direction={{ initial: 'column', sm: 'row' }}
|
||||||
|
>
|
||||||
|
<StarRow
|
||||||
|
stars={votes.categories.maneuverability}
|
||||||
|
children="Maneuverability"
|
||||||
|
/>
|
||||||
|
<StarRow
|
||||||
|
stars={votes.categories.survivability}
|
||||||
|
children="Survivability"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{votes.last_updated && (
|
||||||
|
<Text align="center" size="2" color="gray" mt="2">
|
||||||
|
You voted on {new Date(votes.last_updated).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button, Flex, Heading, Skeleton, Text } from '@radix-ui/themes';
|
||||||
|
import { WargamingLoginButton } from '../../../../../../../../components/Navbar/components/WargamingLoginButton';
|
||||||
|
import * as App from '../../../../../../../../stores/app';
|
||||||
|
|
||||||
|
export function VotesSkeleton() {
|
||||||
|
const wargaming = App.useDeferred(
|
||||||
|
undefined,
|
||||||
|
(state) => state.logins.wargaming,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="3">
|
||||||
|
<Flex justify="between" align="end">
|
||||||
|
<Heading size="4">
|
||||||
|
<Skeleton height="1em" width="6rem" />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{!wargaming && (
|
||||||
|
<WargamingLoginButton variant="outline">
|
||||||
|
Login to vote
|
||||||
|
</WargamingLoginButton>
|
||||||
|
)}
|
||||||
|
{wargaming && <Button>Vote</Button>}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex gap="0" direction="column">
|
||||||
|
<Flex gap="6">
|
||||||
|
<Flex align="center" justify="between" width="15rem">
|
||||||
|
<Text>Easiness</Text>
|
||||||
|
<Skeleton height="1em" width="5rem" />
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" justify="between" width="15rem">
|
||||||
|
<Text>Firepower</Text>
|
||||||
|
<Skeleton height="1em" width="5rem" />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="6">
|
||||||
|
<Flex align="center" justify="between" width="15rem">
|
||||||
|
<Text>Maneuverability</Text>
|
||||||
|
<Skeleton height="1em" width="5rem" />
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" justify="between" width="15rem">
|
||||||
|
<Text>Survivability</Text>
|
||||||
|
<Skeleton height="1em" width="5rem" />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Code, Flex } from '@radix-ui/themes';
|
import { Code, Flex } from '@radix-ui/themes';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { classIcons } from '../../../../../../../components/ClassIcon';
|
import { classIcons } from '../../../../../../../components/ClassIcon';
|
||||||
import { asset } from '../../../../../../../core/blitzkit/asset';
|
import { asset } from '../../../../../../../core/blitzkit/asset';
|
||||||
import { tankIcon } from '../../../../../../../core/blitzkit/tankIcon';
|
|
||||||
import strings from '../../../../../../../lang/en-US.json';
|
import strings from '../../../../../../../lang/en-US.json';
|
||||||
import * as App from '../../../../../../../stores/app';
|
import * as App from '../../../../../../../stores/app';
|
||||||
import * as Duel from '../../../../../../../stores/duel';
|
import * as Duel from '../../../../../../../stores/duel';
|
||||||
import { Listing } from './components/Listing';
|
import { Listing } from './components/Listing';
|
||||||
|
import { Votes } from './components/Votes';
|
||||||
|
import { VotesSkeleton } from './components/VotesSkeleton';
|
||||||
|
|
||||||
export function MetaSection() {
|
export function MetaSection() {
|
||||||
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
const developerMode = App.useDeferred(false, (state) => state.developerMode);
|
||||||
@@ -13,119 +15,110 @@ export function MetaSection() {
|
|||||||
const ClassIcon = classIcons[tank.class];
|
const ClassIcon = classIcons[tank.class];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex justify="center" align="center">
|
||||||
justify="center"
|
<Flex direction="column" align="center" gap="6">
|
||||||
align="center"
|
<Flex gap="6">
|
||||||
gap="6"
|
<Flex direction="column" width="100%">
|
||||||
direction={{ initial: 'column', sm: 'row' }}
|
{tank.nameFull && (
|
||||||
>
|
<Listing label="Full-name">{tank.nameFull}</Listing>
|
||||||
<img
|
)}
|
||||||
alt={tank.name}
|
<Listing label="Nation">
|
||||||
src={tankIcon(tank.id)}
|
<Flex align="center" gap="1">
|
||||||
style={{
|
<img
|
||||||
objectFit: 'contain',
|
style={{ width: '1em', height: '1em' }}
|
||||||
height: '8rem',
|
alt={tank.nation}
|
||||||
width: '8rem',
|
src={asset(`flags/circle/${tank.nation}.webp`)}
|
||||||
}}
|
/>
|
||||||
/>
|
{
|
||||||
|
strings.common.nations[
|
||||||
|
tank.nation as keyof typeof strings.common.nations
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</Flex>
|
||||||
|
</Listing>
|
||||||
|
<Listing label="Class">
|
||||||
|
<Flex align="center" gap="1">
|
||||||
|
<ClassIcon width="1em" height="1em" />
|
||||||
|
{strings.common.tank_class_short[tank.class]}
|
||||||
|
</Flex>
|
||||||
|
</Listing>
|
||||||
|
<Listing label="Tier">{tank.tier}</Listing>
|
||||||
|
<Listing
|
||||||
|
label="Type"
|
||||||
|
color={
|
||||||
|
tank.treeType === 'collector'
|
||||||
|
? 'blue'
|
||||||
|
: tank.treeType === 'premium'
|
||||||
|
? 'amber'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{strings.common.tree_type[tank.treeType]}
|
||||||
|
</Listing>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
<Flex
|
<Flex direction="column" width="100%">
|
||||||
wrap={{ initial: 'nowrap', sm: 'wrap' }}
|
{developerMode && (
|
||||||
direction={{ initial: 'column', sm: 'row' }}
|
<Listing label="DEV: ID">
|
||||||
align={{ initial: 'center', sm: 'start' }}
|
<Code>{tank.id}</Code>
|
||||||
gapX="6"
|
</Listing>
|
||||||
width="100%"
|
)}
|
||||||
maxWidth="calc(2 * 15rem + var(--space-6))"
|
{tank.treeType === 'premium' && (
|
||||||
>
|
<Listing label="Purchase price">
|
||||||
{tank.nameFull && <Listing label="Full-name">{tank.nameFull}</Listing>}
|
<Flex align="center" gap="1">
|
||||||
<Listing label="Nation">
|
{tank.price.value / 400}
|
||||||
<Flex align="center" gap="1">
|
<img
|
||||||
<img
|
style={{ width: '1em', height: '1em' }}
|
||||||
style={{ width: '1em', height: '1em' }}
|
alt="gold"
|
||||||
alt={tank.nation}
|
src={asset('icons/currencies/gold.webp')}
|
||||||
src={asset(`flags/circle/${tank.nation}.webp`)}
|
/>
|
||||||
/>
|
</Flex>
|
||||||
{
|
</Listing>
|
||||||
strings.common.nations[
|
)}
|
||||||
tank.nation as keyof typeof strings.common.nations
|
<Listing
|
||||||
]
|
label={`${tank.treeType === 'researchable' ? 'Purchase' : 'Restoration'} price`}
|
||||||
}
|
>
|
||||||
|
<Flex align="center" gap="1">
|
||||||
|
{tank.price.value.toLocaleString()}
|
||||||
|
<img
|
||||||
|
style={{ width: '1em', height: '1em' }}
|
||||||
|
alt={tank.price.type}
|
||||||
|
src={asset(
|
||||||
|
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Listing>
|
||||||
|
<Listing label="Sale price">
|
||||||
|
<Flex align="center" gap="1">
|
||||||
|
{(tank.price.value / 2).toLocaleString()}
|
||||||
|
<img
|
||||||
|
style={{ width: '1em', height: '1em' }}
|
||||||
|
alt={tank.price.type}
|
||||||
|
src={asset(
|
||||||
|
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Listing>
|
||||||
|
{tank.xp && (
|
||||||
|
<Listing label="Research XP">
|
||||||
|
<Flex align="center" gap="1">
|
||||||
|
{tank.xp.toLocaleString()}
|
||||||
|
<img
|
||||||
|
style={{ width: '1em', height: '1em' }}
|
||||||
|
alt="xp"
|
||||||
|
src={asset('icons/currencies/xp.webp')}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Listing>
|
||||||
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Listing>
|
</Flex>
|
||||||
<Listing label="Tier">{tank.tier}</Listing>
|
|
||||||
<Listing label="Class">
|
<Suspense fallback={<VotesSkeleton />}>
|
||||||
<Flex align="center" gap="1">
|
<Votes />
|
||||||
<ClassIcon width="1em" height="1em" />
|
</Suspense>
|
||||||
{strings.common.tank_class_short[tank.class]}
|
|
||||||
</Flex>
|
|
||||||
</Listing>
|
|
||||||
<Listing
|
|
||||||
label="Type"
|
|
||||||
color={
|
|
||||||
tank.treeType === 'collector'
|
|
||||||
? 'blue'
|
|
||||||
: tank.treeType === 'premium'
|
|
||||||
? 'amber'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{strings.common.tree_type[tank.treeType]}
|
|
||||||
</Listing>
|
|
||||||
{developerMode && (
|
|
||||||
<Listing label="DEV: ID">
|
|
||||||
<Code>{tank.id}</Code>
|
|
||||||
</Listing>
|
|
||||||
)}
|
|
||||||
{tank.treeType === 'premium' && (
|
|
||||||
<Listing label="Purchase price">
|
|
||||||
<Flex align="center" gap="1">
|
|
||||||
{tank.price.value / 400}
|
|
||||||
<img
|
|
||||||
style={{ width: '1em', height: '1em' }}
|
|
||||||
alt="gold"
|
|
||||||
src={asset('icons/currencies/gold.webp')}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Listing>
|
|
||||||
)}
|
|
||||||
<Listing
|
|
||||||
label={`${tank.treeType === 'researchable' ? 'Purchase' : 'Restoration'} price`}
|
|
||||||
>
|
|
||||||
<Flex align="center" gap="1">
|
|
||||||
{tank.price.value.toLocaleString()}
|
|
||||||
<img
|
|
||||||
style={{ width: '1em', height: '1em' }}
|
|
||||||
alt={tank.price.type}
|
|
||||||
src={asset(
|
|
||||||
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Listing>
|
|
||||||
<Listing label="Sale price">
|
|
||||||
<Flex align="center" gap="1">
|
|
||||||
{(tank.price.value / 2).toLocaleString()}
|
|
||||||
<img
|
|
||||||
style={{ width: '1em', height: '1em' }}
|
|
||||||
alt={tank.price.type}
|
|
||||||
src={asset(
|
|
||||||
`icons/currencies/${tank.price.type === 'gold' ? 'gold' : 'silver'}.webp`,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Listing>
|
|
||||||
{tank.xp && (
|
|
||||||
<Listing label="Research XP">
|
|
||||||
<Flex align="center" gap="1">
|
|
||||||
{tank.xp.toLocaleString()}
|
|
||||||
<img
|
|
||||||
style={{ width: '1em', height: '1em' }}
|
|
||||||
alt="xp"
|
|
||||||
src={asset('icons/currencies/xp.webp')}
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
</Listing>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ export const TankModel = memo(() => {
|
|||||||
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
console.log(node.name);
|
|
||||||
|
|
||||||
function translateTexture(offset: number) {
|
function translateTexture(offset: number) {
|
||||||
const mesh = node.children[0] as Mesh;
|
const mesh = node.children[0] as Mesh;
|
||||||
const material = mesh?.material as MeshStandardMaterial;
|
const material = mesh?.material as MeshStandardMaterial;
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
|
|
||||||
import { Flex, Text, TextProps } from '@radix-ui/themes';
|
|
||||||
import { times } from 'lodash';
|
|
||||||
|
|
||||||
interface StarsProps {
|
|
||||||
stars: 1 | 2 | 3 | 4 | 5;
|
|
||||||
lowerIsBetter?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Stars({ stars, lowerIsBetter = false }: StarsProps) {
|
|
||||||
let color: TextProps['color'];
|
|
||||||
const trueStars = lowerIsBetter ? 6 - stars : stars;
|
|
||||||
|
|
||||||
switch (trueStars) {
|
|
||||||
case 1:
|
|
||||||
color = 'red';
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
color = 'orange';
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
color = 'amber';
|
|
||||||
break;
|
|
||||||
case 4:
|
|
||||||
color = 'blue';
|
|
||||||
break;
|
|
||||||
case 5:
|
|
||||||
color = 'green';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex>
|
|
||||||
<Text color={color}>
|
|
||||||
{times(stars, () => (
|
|
||||||
<StarFilledIcon />
|
|
||||||
))}
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
{times(5 - stars, () => (
|
|
||||||
<StarIcon />
|
|
||||||
))}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Flex, Heading, Text } from '@radix-ui/themes';
|
|
||||||
import { WargamingLoginButton } from '../../../../../../components/Navbar/components/WargamingLoginButton';
|
|
||||||
import * as App from '../../../../../../stores/app';
|
|
||||||
import { Stars } from './components/Stars';
|
|
||||||
|
|
||||||
export function VotingSection() {
|
|
||||||
const wargaming = App.use((state) => state.logins.wargaming);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="column" align="center" gap="4">
|
|
||||||
<Flex direction="column" align="center">
|
|
||||||
<Heading>User poll</Heading>
|
|
||||||
<Flex align="center" gap="2">
|
|
||||||
1.4K votes
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Flex gap="0" direction="column">
|
|
||||||
<Flex gap="6">
|
|
||||||
<Flex align="center" justify="between" width="13rem">
|
|
||||||
<Text>Difficulty</Text>
|
|
||||||
<Stars lowerIsBetter stars={4} />
|
|
||||||
</Flex>
|
|
||||||
<Flex align="center" justify="between" width="13rem">
|
|
||||||
<Text>Firepower</Text>
|
|
||||||
<Stars stars={4} />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
<Flex gap="6">
|
|
||||||
<Flex align="center" justify="between" width="13rem">
|
|
||||||
<Text>Maneuverability</Text>
|
|
||||||
<Stars stars={5} />
|
|
||||||
</Flex>
|
|
||||||
<Flex align="center" justify="between" width="13rem">
|
|
||||||
<Text>Survivability</Text>
|
|
||||||
<Stars stars={1} />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{!wargaming && (
|
|
||||||
<Flex align="center" gap="2">
|
|
||||||
<Text color="gray">Sign in to vote</Text>
|
|
||||||
<WargamingLoginButton />
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import { HeroSection } from './components/Model/HeroSection';
|
|||||||
import { MetaSection } from './components/Model/MetaSection';
|
import { MetaSection } from './components/Model/MetaSection';
|
||||||
import { TechTreeSection } from './components/Model/TechTreeSection';
|
import { TechTreeSection } from './components/Model/TechTreeSection';
|
||||||
import { VideoSection } from './components/VideoSection';
|
import { VideoSection } from './components/VideoSection';
|
||||||
import { VotingSection } from './components/VotingSection';
|
|
||||||
|
|
||||||
export default function Page({ params }: { params: { id: string } }) {
|
export default function Page({ params }: { params: { id: string } }) {
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
@@ -83,11 +82,11 @@ export default function Page({ params }: { params: { id: string } }) {
|
|||||||
<PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
|
<PageWrapper p="0" noMaxWidth color="purple" size={1600} gap="9">
|
||||||
<HeroSection id={id} />
|
<HeroSection id={id} />
|
||||||
<CalloutsSection />
|
<CalloutsSection />
|
||||||
<VotingSection />
|
<MetaSection />
|
||||||
|
{/* <VotingSection /> */}
|
||||||
{/* <ShotDisplaySection /> */}
|
{/* <ShotDisplaySection /> */}
|
||||||
<TechTreeSection />
|
<TechTreeSection />
|
||||||
{!exempt && <AdMidSectionResponsive />}
|
{!exempt && <AdMidSectionResponsive />}
|
||||||
<MetaSection />
|
|
||||||
<CharacteristicsSection />
|
<CharacteristicsSection />
|
||||||
{!exempt && <AdMidSectionResponsive />}
|
{!exempt && <AdMidSectionResponsive />}
|
||||||
<GameModeSection />
|
<GameModeSection />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Glow } from '../components/AllStatsOverview/components/HeroStat/compone
|
|||||||
import CommandWrapper from '../components/CommandWrapper';
|
import CommandWrapper from '../components/CommandWrapper';
|
||||||
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
import { getAccountInfo } from '../core/blitz/getAccountInfo';
|
||||||
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
|
||||||
import { linkBlitzAndDiscord } from '../core/blitzkit/discordBlitzLink';
|
import { linkBlitzAndDiscord } from '../core/blitzkit/linkBlitzAndDiscord';
|
||||||
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
import addUsernameChoices from '../core/discord/addUsernameChoices';
|
||||||
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
import autocompleteUsername from '../core/discord/autocompleteUsername';
|
||||||
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import { WargamingIcon } from '../../../icons/Wargaming';
|
|||||||
import strings from '../../../lang/en-US.json';
|
import strings from '../../../lang/en-US.json';
|
||||||
import { Link } from '../../Link';
|
import { Link } from '../../Link';
|
||||||
|
|
||||||
export function WargamingLoginButton(props: ButtonProps) {
|
export function WargamingLoginButton({ children, ...props }: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog.Root>
|
<Dialog.Root>
|
||||||
<Dialog.Trigger>
|
<Dialog.Trigger>
|
||||||
<Button color="red" {...props}>
|
<Button
|
||||||
<WargamingIcon width={15} height={15} /> Wargaming
|
color="red"
|
||||||
</Button>
|
{...props}
|
||||||
|
children={
|
||||||
|
<>
|
||||||
|
<WargamingIcon width={15} height={15} /> {children ?? 'Wargaming'}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Dialog.Trigger>
|
</Dialog.Trigger>
|
||||||
|
|
||||||
<Dialog.Content width="fit-content">
|
<Dialog.Content width="fit-content">
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Region } from '../../constants/regions';
|
||||||
|
import { WARGAMING_APPLICATION_ID } from '../../constants/wargamingApplicationID';
|
||||||
|
import { idToRegion } from './idToRegion';
|
||||||
|
|
||||||
|
export async function isValidBlitzId(id: number, token?: string) {
|
||||||
|
let region: Region;
|
||||||
|
|
||||||
|
try {
|
||||||
|
region = idToRegion(id);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await fetch(
|
||||||
|
`https://api.wotblitz.com/wotb/account/info/?account_id=${
|
||||||
|
id
|
||||||
|
}&application_id=${WARGAMING_APPLICATION_ID}${
|
||||||
|
token === undefined ? '' : `&access_token=${token}`
|
||||||
|
}&fields=account_id%2C-account_id`,
|
||||||
|
).then(
|
||||||
|
(response) =>
|
||||||
|
response.json() as Promise<
|
||||||
|
| {
|
||||||
|
status: 'ok';
|
||||||
|
data: Record<number, {} | null>;
|
||||||
|
}
|
||||||
|
| { status: 'error' }
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return json.status === 'ok' && json.data[id] !== null;
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { usersDatabase } from '../../databases/users';
|
|
||||||
import { idToRegion } from '../blitz/idToRegion';
|
|
||||||
|
|
||||||
export async function flagUserActivity(blitz: number) {
|
|
||||||
await usersDatabase.users.upsert({
|
|
||||||
where: { blitz },
|
|
||||||
update: {},
|
|
||||||
create: { blitz },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function linkBlitzAndDiscord(discord: bigint, blitz: number) {
|
|
||||||
await usersDatabase.discordUsers.upsert({
|
|
||||||
where: { discord },
|
|
||||||
update: { blitz },
|
|
||||||
create: { discord, blitz },
|
|
||||||
});
|
|
||||||
await flagUserActivity(blitz);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getBlitzFromDiscord(discord: bigint) {
|
|
||||||
const unique = await usersDatabase.discordUsers.findUnique({
|
|
||||||
where: { discord },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (unique === null) return null;
|
|
||||||
|
|
||||||
await flagUserActivity(unique.blitz);
|
|
||||||
return { id: unique.blitz, region: idToRegion(unique.blitz) };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { usersDatabase } from '../../databases/users';
|
||||||
|
|
||||||
|
export async function flagUserActivity(blitzId: number) {
|
||||||
|
await usersDatabase.user.upsert({
|
||||||
|
where: { blitz_id: blitzId },
|
||||||
|
update: {},
|
||||||
|
create: { blitz_id: blitzId },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { usersDatabase } from '../../databases/users';
|
||||||
|
import { idToRegion } from '../blitz/idToRegion';
|
||||||
|
import { flagUserActivity } from './flagUserActivity';
|
||||||
|
|
||||||
|
export async function getBlitzFromDiscord(discordId: bigint) {
|
||||||
|
const unique = await usersDatabase.discordUser.findUnique({
|
||||||
|
where: { discord_id: discordId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unique === null) return null;
|
||||||
|
|
||||||
|
await flagUserActivity(unique.blitz_id);
|
||||||
|
return { id: unique.blitz_id, region: idToRegion(unique.blitz_id) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { usersDatabase } from '../../databases/users';
|
||||||
|
import { flagUserActivity } from './flagUserActivity';
|
||||||
|
|
||||||
|
export async function linkBlitzAndDiscord(discordId: bigint, blitzId: number) {
|
||||||
|
await usersDatabase.discordUser.upsert({
|
||||||
|
where: { discord_id: discordId },
|
||||||
|
update: { blitz_id: blitzId },
|
||||||
|
create: { discord_id: discordId, blitz_id: blitzId },
|
||||||
|
});
|
||||||
|
await flagUserActivity(blitzId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { BlitzkitResponse } from '../../hooks/useTankVotes';
|
||||||
|
|
||||||
|
export function unwrapBlitzkitResponse<Data>(response: BlitzkitResponse<Data>) {
|
||||||
|
if (response.status === 'error') {
|
||||||
|
throw new Error(response.error, { cause: response.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import markdownEscape from 'markdown-escape';
|
|||||||
import { Region } from '../../../constants/regions';
|
import { Region } from '../../../constants/regions';
|
||||||
import { UserError } from '../../../hooks/userError';
|
import { UserError } from '../../../hooks/userError';
|
||||||
import searchPlayersAcrossRegions from '../../blitz/searchPlayersAcrossRegions';
|
import searchPlayersAcrossRegions from '../../blitz/searchPlayersAcrossRegions';
|
||||||
import { getBlitzFromDiscord } from '../../blitzkit/discordBlitzLink';
|
import { getBlitzFromDiscord } from '../../blitzkit/getBlitzFromDiscord';
|
||||||
import { translator } from '../../localization/translator';
|
import { translator } from '../../localization/translator';
|
||||||
import { serverAndIdPattern } from './constants';
|
import { serverAndIdPattern } from './constants';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { use, useMemo } from 'react';
|
||||||
|
import { TankVotes } from '../app/api/tank-voting/[id]/average/route';
|
||||||
|
import { unwrapBlitzkitResponse } from '../core/blitzkit/unwrapBlitzkitResponse';
|
||||||
|
import * as App from '../stores/app';
|
||||||
|
|
||||||
|
export type BlitzkitResponse<Data = undefined> =
|
||||||
|
| {
|
||||||
|
status: 'error';
|
||||||
|
error: string;
|
||||||
|
message?: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'ok';
|
||||||
|
data: Data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache: Record<string, Promise<TankVotes> | TankVotes> = {};
|
||||||
|
|
||||||
|
export function useTankVotes(id: number) {
|
||||||
|
const wargaming = App.use((state) => state.logins.wargaming);
|
||||||
|
const promise = useMemo(
|
||||||
|
() =>
|
||||||
|
new Promise<TankVotes>((resolve) => {
|
||||||
|
if (id in cache) {
|
||||||
|
resolve(cache[id]);
|
||||||
|
} else {
|
||||||
|
const cacheable = fetch(
|
||||||
|
`http://localhost:3000/api/tank-voting/${id}/average${
|
||||||
|
wargaming
|
||||||
|
? `?player=${wargaming.id}&token=${wargaming.token}`
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(response) =>
|
||||||
|
response.json() as Promise<BlitzkitResponse<TankVotes>>,
|
||||||
|
)
|
||||||
|
.then(unwrapBlitzkitResponse);
|
||||||
|
cacheable.then(resolve);
|
||||||
|
|
||||||
|
cache[id] = cacheable;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[id, wargaming],
|
||||||
|
);
|
||||||
|
const votes = use(promise);
|
||||||
|
|
||||||
|
return votes;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user