feat: search page

This commit is contained in:
TrèsAbhi committed 2024-09-21 20:26:58 -05:00
1 parent 408b561b75
commit ffa1395b50
70 files changed
+492 -468

No files matched your search

@@ -0,0 +1,14 @@
import { Flex, Text, TextProps } from '@radix-ui/themes';
type ListingProps = TextProps & {
label: string;
};
export function Listing({ label, ...props }: ListingProps) {
return (
<Flex minWidth="15rem" justify="between" gap="4">
<Text color="gray">{label}</Text>
<Text align="right" {...props} />
</Flex>
);
}
@@ -0,0 +1,25 @@
import { StarFilledIcon, StarIcon } from '@radix-ui/react-icons';
import { Box, BoxProps } from '@radix-ui/themes';
type PartialStarProps = BoxProps & {
fill: number;
};
export function PartialStar({ fill, ...props }: PartialStarProps) {
return (
<Box height="1em" width="1em" position="relative" {...props}>
<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,44 @@
import { StarsInt } from '@blitzkit/core';
import { Flex, Text, TextProps } from '@radix-ui/themes';
import { times } from 'lodash-es';
import { PartialStar } from './components/PartialStar';
type StarsProps = TextProps & {
stars: number | null;
onCast?: (value: StarsInt) => void;
};
export function Stars({ stars, onCast, ...props }: 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} {...props}>
<Flex style={{ cursor: onCast ? 'pointer' : 'unset' }}>
{stars !== null &&
times(Math.floor(stars), (index) => (
<PartialStar
fill={1}
key={index}
onClick={() => onCast?.((index + 1) as StarsInt)}
/>
))}
{stars !== null && stars % 1 !== 0 && <PartialStar fill={stars % 1} />}
{times(5 - Math.ceil(stars ?? 0), (index) => (
<PartialStar
fill={0}
key={index}
onClick={() =>
onCast?.((Math.floor(stars ?? 0) + index + 1) as StarsInt)
}
/>
))}
</Flex>
</Text>
);
}
@@ -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,178 @@
import { BlitzkitResponse, StarsInt } from '@blitzkit/core';
import { PaperPlaneIcon } from '@radix-ui/react-icons';
import {
AlertDialog,
Button,
Dialog,
Flex,
Spinner,
Text,
} from '@radix-ui/themes';
import { useState } from 'react';
import * as App from '../../../../../../../../../../stores/app';
import * as Duel from '../../../../../../../../../../stores/duel';
import { Stars } 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">
<Dialog.Title>Voting for {tank.name}</Dialog.Title>
<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 casted</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>
</>
);
}
@@ -0,0 +1,92 @@
import { formatCompact } from '@blitzkit/core';
import { Flex, Heading, Skeleton, Text } from '@radix-ui/themes';
import { memo } from 'react';
import { WargamingLoginButton } from '../../../../../../../../../components/Navbar/components/WargamingLoginButton';
import { useTankVotes } from '../../../../../../../../../hooks/useTankVotes';
import * as App from '../../../../../../../../../stores/app';
import * as Duel from '../../../../../../../../../stores/duel';
import { StarRow } from './components/StarRow';
import { VoteCaster } from './components/VoteCaster';
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="center">
<Heading size="4">
{!votes && <Skeleton height="1em" width="4rem" />}
{votes && formatCompact(votes.votes)}{' '}
{votes && (votes.votes > 1 ? 'votes' : 'vote')}
</Heading>
{!wargaming && (
<WargamingLoginButton variant="surface">
Login to vote
</WargamingLoginButton>
)}
{wargaming && <VoteCaster />}
</Flex>
<Flex gap="0" direction="column">
<Flex
gap={{ initial: '0', sm: '6' }}
direction={{ initial: 'column', sm: 'row' }}
>
<StarRow
stars={votes?.categories.easiness ?? null}
children="Easiness"
/>
<StarRow
stars={votes?.categories.firepower ?? null}
children="Firepower"
/>
</Flex>
<Flex
gap={{ initial: '0', sm: '6' }}
direction={{ initial: 'column', sm: 'row' }}
>
<StarRow
stars={votes?.categories.maneuverability ?? null}
children="Maneuverability"
/>
<StarRow
stars={votes?.categories.survivability ?? null}
children="Survivability"
/>
</Flex>
<Flex justify="center">
<Text size="2" color="gray" mt="2">
<>
{votes ? (
wargaming ? (
votes.last_updated ? (
`You voted on ${new Date(
votes.last_updated,
).toLocaleDateString()}`
) : (
`You haven't voted yet`
)
) : (
'Login to vote'
)
) : (
<Skeleton height="1em" width="5rem" />
)}
</>
</Text>
</Flex>
</Flex>
</Flex>
);
},
() => true,
);
@@ -0,0 +1,216 @@
import {
assertSecret,
asset,
modelDefinitions,
provisionDefinitions,
} from '@blitzkit/core';
import strings from '@blitzkit/core/lang/en-US.json';
import { ChevronLeftIcon, MixIcon, UpdateIcon } from '@radix-ui/react-icons';
import { Button, Code, Dialog, Flex } from '@radix-ui/themes';
import { useState } from 'react';
import { classIcons } from '../../../../../../../components/ClassIcon';
import { Link } from '../../../../../../../components/Link';
import { ScienceIcon } from '../../../../../../../components/ScienceIcon';
import { tankToDuelMember } from '../../../../../../../core/blitzkit/tankToDuelMember';
import * as App from '../../../../../../../stores/app';
import * as Duel from '../../../../../../../stores/duel';
import * as TankopediaEphemeral from '../../../../../../../stores/tankopediaEphemeral';
import { TankSearch } from '../../../../components/TankSearch';
import { Listing } from './components/Listing';
const awaitedProvisionDefinitions = await provisionDefinitions;
const awaitedModelDefinitions = await modelDefinitions;
export function MetaSection() {
const developerMode = App.useDeferred(false, (state) => state.developerMode);
const protagonist = Duel.use((state) => state.protagonist.tank);
const ClassIcon = classIcons[protagonist.class];
const antagonist = Duel.use((state) => state.antagonist.tank);
const compareTanks =
protagonist.id === antagonist.id
? [protagonist.id]
: [protagonist.id, antagonist.id];
const mutateDuel = Duel.useMutation();
const mutateTankopediaEphemeral = TankopediaEphemeral.useMutation();
const [showSwapDialog, setShowSwapDialog] = useState(false);
return (
<Flex justify="center" align="center">
<Flex direction="column" align="center" gap="6">
<Flex gap="2" wrap="wrap" justify="center" align="center">
<Link href="/tools/tankopedia">
<Button variant="outline">
<ChevronLeftIcon />
Back
</Button>
</Link>
<Link href={`/tools/compare?tanks=${compareTanks.join('%2C')}`}>
<Button variant="outline">
<MixIcon />
Compare
</Button>
</Link>
{assertSecret(process.env.PUBLIC_PROMOTE_OPENTEST) === 'true' && (
<Link
href={`https://opentest.blitzkit.app/tools/tankopedia/${protagonist.id}`}
>
<Button variant="outline" color="green">
<ScienceIcon height="1.25em" width="1.25em" />
OpenTest
</Button>
</Link>
)}
<Dialog.Root open={showSwapDialog} onOpenChange={setShowSwapDialog}>
<Dialog.Trigger>
<Button>
<UpdateIcon />
Swap
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Swap tanks</Dialog.Title>
<TankSearch
compact
onSelect={(tank) => {
setShowSwapDialog(false);
mutateTankopediaEphemeral((draft) => {
draft.model = awaitedModelDefinitions[tank.id];
});
mutateDuel((draft) => {
draft.protagonist = tankToDuelMember(
tank,
awaitedProvisionDefinitions,
);
});
window.history.replaceState(
null,
'',
`/tools/tankopedia/${tank.id}`,
);
}}
/>
</Dialog.Content>
</Dialog.Root>
</Flex>
<Flex
gap={{ initial: '0', sm: '6' }}
direction={{ initial: 'column', sm: 'row' }}
>
<Flex direction="column" width="100%">
{protagonist.nameFull && (
<Listing label="Full-name">{protagonist.nameFull}</Listing>
)}
<Listing label="Nation">
<Flex align="center" gap="1">
<img
style={{ width: '1em', height: '1em' }}
alt={protagonist.nation}
src={asset(`flags/circle/${protagonist.nation}.webp`)}
/>
{
strings.common.nations[
protagonist.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[protagonist.class]}
</Flex>
</Listing>
<Listing label="Tier">{protagonist.tier}</Listing>
<Listing
label="Type"
color={
protagonist.treeType === 'collector'
? 'blue'
: protagonist.treeType === 'premium'
? 'amber'
: undefined
}
>
{strings.common.tree_type[protagonist.treeType]}
</Listing>
</Flex>
<Flex direction="column" width="100%">
{developerMode && (
<Listing label="DEV: ID">
<Code>{protagonist.id}</Code>
</Listing>
)}
{protagonist.treeType === 'premium' && (
<Listing label="Purchase price">
<Flex align="center" gap="1">
{protagonist.price.value / 400}
<img
style={{ width: '1em', height: '1em' }}
alt="gold"
src={asset('icons/currencies/gold.webp')}
/>
</Flex>
</Listing>
)}
<Listing
label={`${
protagonist.treeType === 'researchable'
? 'Purchase'
: 'Restoration'
} price`}
>
<Flex align="center" gap="1">
{protagonist.price.value.toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt={protagonist.price.type}
src={asset(
`icons/currencies/${
protagonist.price.type === 'gold' ? 'gold' : 'silver'
}.webp`,
)}
/>
</Flex>
</Listing>
<Listing label="Sale price">
<Flex align="center" gap="1">
{(protagonist.price.value / 2).toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt={protagonist.price.type}
src={asset(
`icons/currencies/${
protagonist.price.type === 'gold' ? 'gold' : 'silver'
}.webp`,
)}
/>
</Flex>
</Listing>
{protagonist.xp && (
<Listing label="Research XP">
<Flex align="center" gap="1">
{protagonist.xp.toLocaleString()}
<img
style={{ width: '1em', height: '1em' }}
alt="xp"
src={asset('icons/currencies/xp.webp')}
/>
</Flex>
</Listing>
)}
</Flex>
</Flex>
{/* <Votes /> */}
</Flex>
</Flex>
);
}