diff --git a/.vscode/settings.json b/.vscode/settings.json index 15e2b4208..588494a00 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,6 +28,7 @@ "blitzkit", "blitzrinth", "blitzstars", + "cacheable", "Callouts", "Camo", "CDON", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 44cfb85ee..2d169b953 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,12 +8,29 @@ datasource db { url = env("DATABASE_URL") } -model Users { - blitz Int @id - last_used DateTime? @default(now()) +model User { + blitz_id Int @id @map("blitz") // map: "blitz" was too vague + last_used DateTime? @default(now()) + tank_votes TankVote[] + + @@map("Users") // map: "Users" was mistakenly plural } -model DiscordUsers { - discord BigInt @id - blitz Int +model TankVote { + id Int @id + 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 } diff --git a/src/app/api/tank-voting/[id]/average/route.ts b/src/app/api/tank-voting/[id]/average/route.ts new file mode 100644 index 000000000..bb984e8bf --- /dev/null +++ b/src/app/api/tank-voting/[id]/average/route.ts @@ -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; + 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); + } + + 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); + } + + 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); + } catch (message) { + return NextResponse.json({ + status: 'error', + error: 'DATABASE_ERROR', + message, + } satisfies BlitzkitResponse); + } +} diff --git a/src/app/api/tank-voting/[id]/cast/route.ts b/src/app/api/tank-voting/[id]/cast/route.ts new file mode 100644 index 000000000..fe51e340b --- /dev/null +++ b/src/app/api/tank-voting/[id]/cast/route.ts @@ -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> = {}; + 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); + } catch (message) { + return NextResponse.json({ + status: 'error', + error: 'DATABASE_ERROR', + message, + } satisfies BlitzkitResponse); + } +} diff --git a/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Stars/components/PartialStar.tsx b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Stars/components/PartialStar.tsx new file mode 100644 index 000000000..f47a8cfa5 --- /dev/null +++ b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Stars/components/PartialStar.tsx @@ -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 ( + + + {fill > 0 && ( + + + + )} + + ); +} diff --git a/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Stars/index.tsx b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Stars/index.tsx new file mode 100644 index 000000000..97b79d3b7 --- /dev/null +++ b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Stars/index.tsx @@ -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 ( + + + {stars !== null && + times(Math.floor(stars), (index) => ( + + ))} + {stars !== null && stars % 1 !== 0 && } + {times(5 - Math.ceil(stars ?? 0), (index) => ( + + ))} + + + ); +} diff --git a/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Votes/components/StarRow.tsx b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Votes/components/StarRow.tsx new file mode 100644 index 000000000..199e734b9 --- /dev/null +++ b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Votes/components/StarRow.tsx @@ -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 ( + + + {children} + + + + ); +} diff --git a/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Votes/index.tsx b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Votes/index.tsx new file mode 100644 index 000000000..58c58a4ed --- /dev/null +++ b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/Votes/index.tsx @@ -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 ( + + + + {formatCompact(votes.votes)} {votes.votes === 1 ? 'vote' : 'votes'} + + + {!wargaming && ( + + Login to vote + + )} + {wargaming && } + + + + + + + + + + + + + + {votes.last_updated && ( + + You voted on {new Date(votes.last_updated).toLocaleDateString()} + + )} + + + ); + }, + () => true, +); diff --git a/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/VotesSkeleton.tsx b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/VotesSkeleton.tsx new file mode 100644 index 000000000..739b18e94 --- /dev/null +++ b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/components/VotesSkeleton.tsx @@ -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 ( + + + + + + + {!wargaming && ( + + Login to vote + + )} + {wargaming && } + + + + + + Easiness + + + + Firepower + + + + + + Maneuverability + + + + Survivability + + + + + + ); +} diff --git a/src/app/tools/tankopedia/[id]/components/Model/MetaSection/index.tsx b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/index.tsx index d9f87ee52..5a8718d69 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/MetaSection/index.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/MetaSection/index.tsx @@ -1,11 +1,13 @@ import { Code, Flex } from '@radix-ui/themes'; +import { Suspense } from 'react'; import { classIcons } from '../../../../../../../components/ClassIcon'; import { asset } from '../../../../../../../core/blitzkit/asset'; -import { tankIcon } from '../../../../../../../core/blitzkit/tankIcon'; import strings from '../../../../../../../lang/en-US.json'; import * as App from '../../../../../../../stores/app'; import * as Duel from '../../../../../../../stores/duel'; import { Listing } from './components/Listing'; +import { Votes } from './components/Votes'; +import { VotesSkeleton } from './components/VotesSkeleton'; export function MetaSection() { const developerMode = App.useDeferred(false, (state) => state.developerMode); @@ -13,119 +15,110 @@ export function MetaSection() { const ClassIcon = classIcons[tank.class]; return ( - - {tank.name} + + + + + {tank.nameFull && ( + {tank.nameFull} + )} + + + {tank.nation} + { + strings.common.nations[ + tank.nation as keyof typeof strings.common.nations + ] + } + + + + + + {strings.common.tank_class_short[tank.class]} + + + {tank.tier} + + {strings.common.tree_type[tank.treeType]} + + - - {tank.nameFull && {tank.nameFull}} - - - {tank.nation} - { - strings.common.nations[ - tank.nation as keyof typeof strings.common.nations - ] - } + + {developerMode && ( + + {tank.id} + + )} + {tank.treeType === 'premium' && ( + + + {tank.price.value / 400} + gold + + + )} + + + {tank.price.value.toLocaleString()} + {tank.price.type} + + + + + {(tank.price.value / 2).toLocaleString()} + {tank.price.type} + + + {tank.xp && ( + + + {tank.xp.toLocaleString()} + xp + + + )} - - {tank.tier} - - - - {strings.common.tank_class_short[tank.class]} - - - - {strings.common.tree_type[tank.treeType]} - - {developerMode && ( - - {tank.id} - - )} - {tank.treeType === 'premium' && ( - - - {tank.price.value / 400} - gold - - - )} - - - {tank.price.value.toLocaleString()} - {tank.price.type} - - - - - {(tank.price.value / 2).toLocaleString()} - {tank.price.type} - - - {tank.xp && ( - - - {tank.xp.toLocaleString()} - xp - - - )} + + + }> + + ); diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx index 73cf2280f..583852a1f 100644 --- a/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx +++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankModel.tsx @@ -46,8 +46,6 @@ export const TankModel = memo(() => { if (!isVisible) return null; - console.log(node.name); - function translateTexture(offset: number) { const mesh = node.children[0] as Mesh; const material = mesh?.material as MeshStandardMaterial; diff --git a/src/app/tools/tankopedia/[id]/components/VotingSection/components/Stars.tsx b/src/app/tools/tankopedia/[id]/components/VotingSection/components/Stars.tsx deleted file mode 100644 index c8e26da9e..000000000 --- a/src/app/tools/tankopedia/[id]/components/VotingSection/components/Stars.tsx +++ /dev/null @@ -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 ( - - - {times(stars, () => ( - - ))} - - - {times(5 - stars, () => ( - - ))} - - - ); -} diff --git a/src/app/tools/tankopedia/[id]/components/VotingSection/index.tsx b/src/app/tools/tankopedia/[id]/components/VotingSection/index.tsx deleted file mode 100644 index 8add33623..000000000 --- a/src/app/tools/tankopedia/[id]/components/VotingSection/index.tsx +++ /dev/null @@ -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 ( - - - User poll - - 1.4K votes - - - - - - - Difficulty - - - - Firepower - - - - - - Maneuverability - - - - Survivability - - - - - - {!wargaming && ( - - Sign in to vote - - - )} - - ); -} diff --git a/src/app/tools/tankopedia/[id]/page.tsx b/src/app/tools/tankopedia/[id]/page.tsx index 9b6eb0cd9..05f2c6a0d 100644 --- a/src/app/tools/tankopedia/[id]/page.tsx +++ b/src/app/tools/tankopedia/[id]/page.tsx @@ -15,7 +15,6 @@ import { HeroSection } from './components/Model/HeroSection'; import { MetaSection } from './components/Model/MetaSection'; import { TechTreeSection } from './components/Model/TechTreeSection'; import { VideoSection } from './components/VideoSection'; -import { VotingSection } from './components/VotingSection'; export default function Page({ params }: { params: { id: string } }) { const id = Number(params.id); @@ -83,11 +82,11 @@ export default function Page({ params }: { params: { id: string } }) { - + + {/* */} {/* */} {!exempt && } - {!exempt && } diff --git a/src/commands/link.tsx b/src/commands/link.tsx index 159b9b107..8d8ee8999 100644 --- a/src/commands/link.tsx +++ b/src/commands/link.tsx @@ -3,7 +3,7 @@ import { Glow } from '../components/AllStatsOverview/components/HeroStat/compone import CommandWrapper from '../components/CommandWrapper'; import { getAccountInfo } from '../core/blitz/getAccountInfo'; 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 autocompleteUsername from '../core/discord/autocompleteUsername'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; diff --git a/src/components/Navbar/components/WargamingLoginButton.tsx b/src/components/Navbar/components/WargamingLoginButton.tsx index bb30dbc2b..95848c168 100644 --- a/src/components/Navbar/components/WargamingLoginButton.tsx +++ b/src/components/Navbar/components/WargamingLoginButton.tsx @@ -5,13 +5,19 @@ import { WargamingIcon } from '../../../icons/Wargaming'; import strings from '../../../lang/en-US.json'; import { Link } from '../../Link'; -export function WargamingLoginButton(props: ButtonProps) { +export function WargamingLoginButton({ children, ...props }: ButtonProps) { return ( - +