mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 12:09:51 +02:00
feat: cast ui
This commit is contained in:
1 parent
2d2d31adf7
commit
c5e2c1fd0f
7 files changed
+253
-37
No files matched your search
@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
|
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
|
||||||
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
|
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
|
||||||
import { usersDatabase } from '../../../../../databases/users';
|
import { usersDatabase } from '../../../../../databases/users';
|
||||||
import { BlitzkitResponse } from '../../../../../hooks/useTankVotes';
|
import {
|
||||||
|
BlitzkitResponse,
|
||||||
|
BlitzkitResponseError,
|
||||||
|
} from '../../../../../hooks/useTankVotes';
|
||||||
import { TankVoteCategory } from '../cast/route';
|
import { TankVoteCategory } from '../cast/route';
|
||||||
|
|
||||||
export interface TankVotes {
|
export interface TankVotes {
|
||||||
@@ -22,7 +25,7 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'INVALID_TANK_ID',
|
error: 'INVALID_TANK_ID',
|
||||||
} satisfies BlitzkitResponse<TankVotes>);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blitzIdString = request.nextUrl.searchParams.get('player');
|
const blitzIdString = request.nextUrl.searchParams.get('player');
|
||||||
@@ -33,7 +36,7 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'UNDEFINED_TOKEN_OR_PLAYER_ID',
|
error: 'UNDEFINED_TOKEN_OR_PLAYER_ID',
|
||||||
} satisfies BlitzkitResponse<TankVotes>);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -44,7 +47,7 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'INVALID_TOKEN_OR_PLAYER_ID',
|
error: 'INVALID_TOKEN_OR_PLAYER_ID',
|
||||||
} satisfies BlitzkitResponse);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -76,6 +79,6 @@ export async function GET(
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'DATABASE_ERROR',
|
error: 'DATABASE_ERROR',
|
||||||
message,
|
message,
|
||||||
} satisfies BlitzkitResponse<TankVotes>);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
|
import { isValidBlitzId } from '../../../../../core/blitz/isValidBlitzId';
|
||||||
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
|
import { tankDefinitions } from '../../../../../core/blitzkit/tankDefinitions';
|
||||||
import { usersDatabase } from '../../../../../databases/users';
|
import { usersDatabase } from '../../../../../databases/users';
|
||||||
import { BlitzkitResponse } from '../../../../../hooks/useTankVotes';
|
import {
|
||||||
|
BlitzkitResponse,
|
||||||
|
BlitzkitResponseError,
|
||||||
|
} from '../../../../../hooks/useTankVotes';
|
||||||
import { StarsInt } from '../../../../tools/tankopedia/[id]/components/Model/MetaSection/components/Stars';
|
import { StarsInt } from '../../../../tools/tankopedia/[id]/components/Model/MetaSection/components/Stars';
|
||||||
|
|
||||||
export const TANK_VOTE_CATEGORIES = [
|
export const TANK_VOTE_CATEGORIES = [
|
||||||
@@ -25,16 +28,29 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'INVALID_TANK_ID',
|
error: 'INVALID_TANK_ID',
|
||||||
} satisfies BlitzkitResponse);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blitzId = Number(request.nextUrl.searchParams.get('player'));
|
const blitzIdString = request.nextUrl.searchParams.get('player');
|
||||||
|
const blitzId = Number(blitzIdString);
|
||||||
|
const blitzToken = request.nextUrl.searchParams.get('token');
|
||||||
|
|
||||||
if (isNaN(blitzId) || !(await isValidBlitzId(blitzId))) {
|
if (blitzIdString === null || blitzToken === null) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'INVALID_PLAYER_ID',
|
error: 'UNDEFINED_TOKEN_OR_PLAYER_ID',
|
||||||
} satisfies BlitzkitResponse);
|
} 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>> = {};
|
const votes: Partial<Record<TankVoteCategory, StarsInt>> = {};
|
||||||
@@ -51,7 +67,7 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'INVALID_VOTE',
|
error: 'INVALID_VOTE',
|
||||||
} satisfies BlitzkitResponse);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
votes[category] = vote as StarsInt;
|
votes[category] = vote as StarsInt;
|
||||||
@@ -62,7 +78,7 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'NO_VOTES',
|
error: 'NO_VOTES',
|
||||||
} satisfies BlitzkitResponse);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,12 +106,12 @@ export async function GET(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
data: undefined,
|
data: undefined,
|
||||||
} satisfies BlitzkitResponse<undefined>);
|
} satisfies BlitzkitResponse);
|
||||||
} catch (message) {
|
} catch (message) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'DATABASE_ERROR',
|
error: 'DATABASE_ERROR',
|
||||||
message,
|
message,
|
||||||
} satisfies BlitzkitResponse);
|
} satisfies BlitzkitResponseError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
|
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
|
||||||
import { Box } from '@radix-ui/themes';
|
import { Box, BoxProps } from '@radix-ui/themes';
|
||||||
|
|
||||||
interface PartialStarProps {
|
type PartialStarProps = BoxProps & {
|
||||||
fill: number;
|
fill: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function PartialStar({ fill }: PartialStarProps) {
|
export function PartialStar({ fill, ...props }: PartialStarProps) {
|
||||||
return (
|
return (
|
||||||
<Box height="1em" width="1em" position="relative">
|
<Box height="1em" width="1em" position="relative" {...props}>
|
||||||
<StarIcon width="1em" height="1em" />
|
<StarIcon width="1em" height="1em" />
|
||||||
{fill > 0 && (
|
{fill > 0 && (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
+18
-7
@@ -4,11 +4,12 @@ import { PartialStar } from './components/PartialStar';
|
|||||||
|
|
||||||
export type StarsInt = 1 | 2 | 3 | 4 | 5;
|
export type StarsInt = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
interface StarsProps {
|
type StarsProps = TextProps & {
|
||||||
stars: number | null;
|
stars: number | null;
|
||||||
}
|
onCast?: (value: StarsInt) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export function Stars({ stars }: StarsProps) {
|
export function Stars({ stars, onCast, ...props }: StarsProps) {
|
||||||
let color: TextProps['color'] = 'green';
|
let color: TextProps['color'] = 'green';
|
||||||
|
|
||||||
if (stars === null) color = 'gray';
|
if (stars === null) color = 'gray';
|
||||||
@@ -18,15 +19,25 @@ export function Stars({ stars }: StarsProps) {
|
|||||||
else if (stars < 4.5) color = 'blue';
|
else if (stars < 4.5) color = 'blue';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text color={color}>
|
<Text color={color} {...props}>
|
||||||
<Flex>
|
<Flex style={{ cursor: onCast ? 'pointer' : 'unset' }}>
|
||||||
{stars !== null &&
|
{stars !== null &&
|
||||||
times(Math.floor(stars), (index) => (
|
times(Math.floor(stars), (index) => (
|
||||||
<PartialStar fill={1} key={index} />
|
<PartialStar
|
||||||
|
fill={1}
|
||||||
|
key={index}
|
||||||
|
onClick={() => onCast?.((index + 1) as StarsInt)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{stars !== null && stars % 1 !== 0 && <PartialStar fill={stars % 1} />}
|
{stars !== null && stars % 1 !== 0 && <PartialStar fill={stars % 1} />}
|
||||||
{times(5 - Math.ceil(stars ?? 0), (index) => (
|
{times(5 - Math.ceil(stars ?? 0), (index) => (
|
||||||
<PartialStar fill={0} key={index} />
|
<PartialStar
|
||||||
|
fill={0}
|
||||||
|
key={index}
|
||||||
|
onClick={() =>
|
||||||
|
onCast?.((Math.floor(stars ?? 0) + index + 1) as StarsInt)
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
import { PaperPlaneIcon } from '@radix-ui/react-icons';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
} from '@radix-ui/themes';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { BlitzkitResponse } from '../../../../../../../../../../hooks/useTankVotes';
|
||||||
|
import * as App from '../../../../../../../../../../stores/app';
|
||||||
|
import * as Duel from '../../../../../../../../../../stores/duel';
|
||||||
|
import { Stars, StarsInt } from '../../Stars';
|
||||||
|
|
||||||
|
export function VoteCaster() {
|
||||||
|
const tank = Duel.use((state) => state.protagonist.tank);
|
||||||
|
const [easiness, setEasiness] = useState<StarsInt | null>(null);
|
||||||
|
const [firepower, setFirepower] = useState<StarsInt | null>(null);
|
||||||
|
const [maneuverability, setManeuverability] = useState<StarsInt | null>(null);
|
||||||
|
const [survivability, setSurvivability] = useState<StarsInt | null>(null);
|
||||||
|
const appStore = App.useStore();
|
||||||
|
// true = vote successful, false = vote failed, null = hide alert
|
||||||
|
const [postVote, setPostVote] = useState<boolean | null>(null);
|
||||||
|
// true = open, false = closed, null = casting
|
||||||
|
const [showDialogue, setShowDialogue] = useState<boolean | null>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog.Root open={showDialogue !== false} onOpenChange={setShowDialogue}>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
<Button variant="surface">
|
||||||
|
<PaperPlaneIcon /> Vote
|
||||||
|
</Button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
|
||||||
|
<Dialog.Content maxWidth="23rem">
|
||||||
|
<Heading>Voting for {tank.name}</Heading>
|
||||||
|
|
||||||
|
<Flex direction="column" mt="4">
|
||||||
|
<Flex align="center" justify="between">
|
||||||
|
<Text>
|
||||||
|
Easiness{' '}
|
||||||
|
<Text color="gray" size="2">
|
||||||
|
{easiness === null ? '(empty)' : `(${easiness})`}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Stars stars={easiness} size="5" onCast={setEasiness} />
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" justify="between">
|
||||||
|
<Text>
|
||||||
|
Firepower{' '}
|
||||||
|
<Text color="gray" size="2">
|
||||||
|
{firepower === null ? '(empty)' : `(${firepower})`}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Stars stars={firepower} size="5" onCast={setFirepower} />
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" justify="between">
|
||||||
|
<Text>
|
||||||
|
Maneuverability{' '}
|
||||||
|
<Text color="gray" size="2">
|
||||||
|
{maneuverability === null
|
||||||
|
? '(empty)'
|
||||||
|
: `(${maneuverability})`}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Stars
|
||||||
|
stars={maneuverability}
|
||||||
|
size="5"
|
||||||
|
onCast={setManeuverability}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Flex align="center" justify="between">
|
||||||
|
<Text>
|
||||||
|
Survivability{' '}
|
||||||
|
<Text color="gray" size="2">
|
||||||
|
{survivability === null ? '(empty)' : `(${survivability})`}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Stars stars={survivability} size="5" onCast={setSurvivability} />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="end" mt="4" gap="2">
|
||||||
|
<Dialog.Close>
|
||||||
|
<Button variant="outline" color="red">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
showDialogue === null ||
|
||||||
|
(easiness === null &&
|
||||||
|
firepower === null &&
|
||||||
|
maneuverability === null &&
|
||||||
|
survivability === null)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const wargaming = appStore.getState().logins.wargaming;
|
||||||
|
|
||||||
|
if (!wargaming) {
|
||||||
|
return setShowDialogue(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowDialogue(null);
|
||||||
|
|
||||||
|
fetch(
|
||||||
|
`/api/tank-voting/${tank.id}/cast?${
|
||||||
|
easiness === null ? '' : `easiness=${easiness}&`
|
||||||
|
}${firepower === null ? '' : `firepower=${firepower}&`}${
|
||||||
|
maneuverability === null
|
||||||
|
? ''
|
||||||
|
: `maneuverability=${maneuverability}&`
|
||||||
|
}${
|
||||||
|
survivability === null
|
||||||
|
? ''
|
||||||
|
: `survivability=${survivability}&`
|
||||||
|
}player=${wargaming.id}&token=${wargaming.token}`,
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
(response) => response.json() as Promise<BlitzkitResponse>,
|
||||||
|
)
|
||||||
|
.then((json) => {
|
||||||
|
setShowDialogue(false);
|
||||||
|
setPostVote(json.status === 'ok');
|
||||||
|
|
||||||
|
if (json.status === 'error') {
|
||||||
|
console.error('vote failed', json);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showDialogue === null ? <Spinner /> : <PaperPlaneIcon />} Vote
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<AlertDialog.Root
|
||||||
|
open={postVote !== null}
|
||||||
|
onOpenChange={() => setPostVote(null)}
|
||||||
|
>
|
||||||
|
{postVote === true && (
|
||||||
|
<AlertDialog.Content maxWidth="20rem">
|
||||||
|
<AlertDialog.Title>Vote cased</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
It may take a few minutes for it to show up.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
|
||||||
|
<Flex justify="end" mt="4">
|
||||||
|
<AlertDialog.Cancel>
|
||||||
|
<Button>Okay</Button>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
</Flex>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{postVote === false && (
|
||||||
|
<AlertDialog.Content maxWidth="20rem">
|
||||||
|
<AlertDialog.Title>Voting failed</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
So sorry about this! If you're a nerd, feel free to check out the
|
||||||
|
console.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
|
||||||
|
<Flex justify="end" mt="4">
|
||||||
|
<AlertDialog.Cancel>
|
||||||
|
<Button>Gotcha</Button>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
</Flex>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
)}
|
||||||
|
</AlertDialog.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-5
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
|
import { Flex, Heading, Text } from '@radix-ui/themes';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { WargamingLoginButton } from '../../../../../../../../../components/Navbar/components/WargamingLoginButton';
|
import { WargamingLoginButton } from '../../../../../../../../../components/Navbar/components/WargamingLoginButton';
|
||||||
import { formatCompact } from '../../../../../../../../../core/math/formatCompact';
|
import { formatCompact } from '../../../../../../../../../core/math/formatCompact';
|
||||||
@@ -6,6 +6,7 @@ import { useTankVotes } from '../../../../../../../../../hooks/useTankVotes';
|
|||||||
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 { StarRow } from './components/StarRow';
|
import { StarRow } from './components/StarRow';
|
||||||
|
import { VoteCaster } from './components/VoteCaster';
|
||||||
|
|
||||||
export const Votes = memo(
|
export const Votes = memo(
|
||||||
() => {
|
() => {
|
||||||
@@ -24,11 +25,11 @@ export const Votes = memo(
|
|||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
{!wargaming && (
|
{!wargaming && (
|
||||||
<WargamingLoginButton variant="outline">
|
<WargamingLoginButton variant="surface">
|
||||||
Login to vote
|
Login to vote
|
||||||
</WargamingLoginButton>
|
</WargamingLoginButton>
|
||||||
)}
|
)}
|
||||||
{wargaming && <Button>Vote</Button>}
|
{wargaming && <VoteCaster />}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Flex gap="0" direction="column">
|
<Flex gap="0" direction="column">
|
||||||
@@ -54,9 +55,13 @@ export const Votes = memo(
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{votes.last_updated && (
|
{wargaming && (
|
||||||
<Text align="center" size="2" color="gray" mt="2">
|
<Text align="center" size="2" color="gray" mt="2">
|
||||||
You voted on {new Date(votes.last_updated).toLocaleDateString()}
|
<>
|
||||||
|
{votes.last_updated
|
||||||
|
? `You voted on ${new Date(votes.last_updated).toLocaleDateString()}`
|
||||||
|
: `You haven't voted yet`}
|
||||||
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { TankVotes } from '../app/api/tank-voting/[id]/average/route';
|
|||||||
import { unwrapBlitzkitResponse } from '../core/blitzkit/unwrapBlitzkitResponse';
|
import { unwrapBlitzkitResponse } from '../core/blitzkit/unwrapBlitzkitResponse';
|
||||||
import * as App from '../stores/app';
|
import * as App from '../stores/app';
|
||||||
|
|
||||||
|
export interface BlitzkitResponseError {
|
||||||
|
status: 'error';
|
||||||
|
error: string;
|
||||||
|
message?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export type BlitzkitResponse<Data = undefined> =
|
export type BlitzkitResponse<Data = undefined> =
|
||||||
| {
|
| BlitzkitResponseError
|
||||||
status: 'error';
|
|
||||||
error: string;
|
|
||||||
message?: unknown;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
status: 'ok';
|
status: 'ok';
|
||||||
data: Data;
|
data: Data;
|
||||||
|
|||||||
Reference in new issue
Block a user