feat: antagonist selector filters

This commit is contained in:
TrèsAbhi committed 2024-01-21 11:06:40 -06:00
1 parent efd1a80ee1
commit b44f897528
7 files changed
+408 -421

No files matched your search

@@ -1,74 +0,0 @@
import { Button, Dialog, Flex, Text } from '@radix-ui/themes';
import { produce } from 'immer';
import { Dispatch, SetStateAction, use } from 'react';
import { SmallTankIcon } from '../../../../../../../components/SmallTankIcon';
import {
TIER_ROMAN_NUMERALS,
tankDefinitions,
} from '../../../../../../../core/blitzkrieg/tankDefinitions';
import { Duel } from '../../../page';
interface SearchResultsProps {
ids: number[];
setDuel: Dispatch<SetStateAction<Duel>>;
}
export function SearchResults({ ids, setDuel }: SearchResultsProps) {
const awaitedTankDefinitions = use(tankDefinitions);
return (
<Flex justify="center">
<Flex direction="column" gap="2" align="start">
{ids.map((id) => {
const tank = awaitedTankDefinitions[id];
return (
<Dialog.Close key={id}>
<Button
variant="ghost"
style={{ width: '100%' }}
onClick={() => {
setDuel(
produce<Duel>((draft) => {
draft.antagonist.tank = tank;
draft.antagonist.turret = tank.turrets.at(-1)!;
draft.antagonist.gun =
draft.antagonist.turret.guns.at(-1)!;
draft.antagonist.shell = draft.antagonist.gun.shells[0];
}),
);
}}
>
<Flex gap="2" align="start" style={{ width: '100%' }}>
<Text align="right" color="gray" style={{ width: 24 }}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
<Text
color={tank.tree_type === 'collector' ? 'blue' : 'amber'}
>
<Flex gap="2" align="center">
<SmallTankIcon id={tank.id} size={16} />
<div
style={{
display: 'block',
color:
tank.tree_type === 'researchable'
? 'white'
: undefined,
}}
>
{tank.name}
</div>
</Flex>
</Text>
</Flex>
</Button>
</Dialog.Close>
);
})}
</Flex>
</Flex>
);
}
@@ -1,9 +1,4 @@
import {
CaretRightIcon,
Cross1Icon,
MagnifyingGlassIcon,
ShuffleIcon,
} from '@radix-ui/react-icons';
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
import {
Button,
Card,
@@ -12,23 +7,17 @@ import {
Heading,
Tabs,
Text,
TextField,
Tooltip,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import { produce } from 'immer';
import { debounce } from 'lodash';
import { Dispatch, SetStateAction, use, useRef, useState } from 'react';
import { Dispatch, SetStateAction, useState } from 'react';
import { ModuleButton } from '../../../../../../components/ModuleButton';
import { SmallTankIcon } from '../../../../../../components/SmallTankIcon';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import {
SHELL_NAMES,
tankNamesDiacritics,
} from '../../../../../../core/blitzkrieg/tankDefinitions';
import { SHELL_NAMES } from '../../../../../../core/blitzkrieg/tankDefinitions';
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
import { TankSearch } from '../../../components/TankSearch';
import { Duel } from '../../page';
import { SearchResults } from './components/SearchResults';
interface AntagonistBarProps {
duel: Duel;
@@ -40,13 +29,9 @@ export function AntagonistBar({
setDuel,
}: AntagonistBarProps) {
const mode = useTankopediaTemporary((state) => state.mode);
const awaitedTankNamesDiacritics = use(tankNamesDiacritics);
const [tab, setTab] = useState('search');
const searchInput = useRef<HTMLInputElement>(null);
const [searchResults, setSearchResults] = useState<number[]>([]);
const chunkSize = window.innerWidth > 512 ? 8 : Infinity;
const firstChunk = searchResults.slice(0, chunkSize);
const secondChunk = searchResults.slice(chunkSize);
const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
useState(false);
if (mode !== 'armor') return null;
@@ -79,7 +64,10 @@ export function AntagonistBar({
<Flex align="center" gap="4">
<Text>Versus</Text>
<Dialog.Root>
<Dialog.Root
open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorVisible}
>
<Dialog.Trigger>
<Button variant="ghost">
<Flex gap="2" align="center">
@@ -118,37 +106,22 @@ export function AntagonistBar({
style={{ flex: 1 }}
justify="center"
>
<TextField.Root>
<TextField.Slot>
<MagnifyingGlassIcon />
</TextField.Slot>
<TextField.Input
ref={searchInput}
placeholder="Search tank..."
onChange={debounce(() => {
setSearchResults(
go(
searchInput.current!.value,
awaitedTankNamesDiacritics,
{ key: 'combined', limit: 16 },
).map((item) => item.obj.id),
);
}, 500)}
/>
</TextField.Root>
<div
style={{
display: 'flex',
width: '100%',
justifyContent: 'space-around',
<TankSearch
compact
onSelect={(tank) => {
setDuel(
produce<Duel>((draft) => {
draft.antagonist.tank = tank;
draft.antagonist.turret = tank.turrets.at(-1)!;
draft.antagonist.gun =
draft.antagonist.turret.guns.at(-1)!;
draft.antagonist.shell =
draft.antagonist.gun.shells[0];
}),
);
setAntagonistSelectorVisible(false);
}}
>
<SearchResults ids={firstChunk} setDuel={setDuel} />
{secondChunk.length > 0 && (
<SearchResults ids={secondChunk} setDuel={setDuel} />
)}
</div>
/>
</Flex>
</Tabs.Content>
@@ -0,0 +1,53 @@
import { Button, Flex, Text } from '@radix-ui/themes';
import { SmallTankIcon } from '../../../../../../components/SmallTankIcon';
import {
TIER_ROMAN_NUMERALS,
TankDefinition,
} from '../../../../../../core/blitzkrieg/tankDefinitions';
interface CompactSearchResultRowProps {
tanks: TankDefinition[];
onSelect: (tank: TankDefinition) => void;
}
export function CompactSearchResultRow({
tanks,
onSelect = () => {},
}: CompactSearchResultRowProps) {
return (
<Flex justify="center">
<Flex direction="column" gap="2" align="start">
{tanks.map((tank) => (
<Button
key={tank.id}
variant="ghost"
style={{ width: '100%' }}
onClick={() => onSelect(tank)}
>
<Flex gap="2" align="start" style={{ width: '100%' }}>
<Text align="right" color="gray" style={{ width: 24 }}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
<Text color={tank.tree_type === 'collector' ? 'blue' : 'amber'}>
<Flex gap="2" align="center">
<SmallTankIcon id={tank.id} size={16} />
<div
style={{
display: 'block',
color:
tank.tree_type === 'researchable' ? 'white' : undefined,
}}
>
{tank.name}
</div>
</Flex>
</Text>
</Flex>
</Button>
))}
</Flex>
</Flex>
);
}
@@ -3,23 +3,23 @@
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import { use } from 'react';
import { Flag } from '../../../../../components/Flag';
import { Flag } from '../../../../../../../components/Flag';
import {
TANK_TYPES,
TREE_TYPES,
TREE_TYPE_ICONS,
TREE_TYPE_IMAGES,
TreeTypeEnum,
} from '../../../../../components/Tanks';
} from '../../../../../../../components/Tanks';
import {
NATIONS,
TIERS,
TIER_ROMAN_NUMERALS,
} from '../../../../../core/blitzkrieg/tankDefinitions';
} from '../../../../../../../core/blitzkrieg/tankDefinitions';
import mutateTankopediaPersistent, {
TankopediaTestTankDisplay,
useTankopediaPersistent,
} from '../../../../../stores/tankopedia';
} from '../../../../../../../stores/tankopedia';
import * as styles from './index.css';
export function Options() {
@@ -0,0 +1,316 @@
import { slateDark } from '@radix-ui/colors';
import { CaretDownIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
import {
Button,
Card,
DropdownMenu,
Flex,
Inset,
Text,
TextField,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import { asset } from '../../../../../core/blitzkrieg/asset';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
TankDefinition,
tanksDefinitionsArray,
} from '../../../../../core/blitzkrieg/tankDefinitions';
import { tankIcon } from '../../../../../core/blitzkrieg/tankIcon';
import { theme } from '../../../../../stitches.config';
import mutateTankopediaPersistent, {
TankopediaSortBy,
TankopediaSortDirection,
useTankopediaPersistent,
} from '../../../../../stores/tankopedia';
import * as styles from '../../page.css';
import { PageTurner } from '../PageTurner';
import { CompactSearchResultRow } from './components/CompactSearchResultRow';
import { Options } from './components/Options';
interface TankSearchProps {
compact?: boolean;
onSelect?: (tank: TankDefinition) => void;
}
export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
const tanksPerPage = compact ? 16 : 24;
const filters = useTankopediaPersistent((state) => state.filters);
const sort = useTankopediaPersistent((state) => state.sort);
const awaitedTanks = use(tanksDefinitionsArray);
const input = useRef<HTMLInputElement>(null);
const searchableTanks = useMemo(
() =>
awaitedTanks
.filter(
(tank) =>
(filters.tiers.length === 0
? true
: filters.tiers.includes(tank.tier)) &&
(filters.types.length === 0
? true
: filters.types.includes(tank.type)) &&
(filters.treeTypes.length === 0
? true
: filters.treeTypes.includes(tank.tree_type)) &&
(filters.nations.length === 0
? true
: filters.nations.includes(tank.nation)) &&
(filters.test === 'include'
? true
: filters.test === 'exclude'
? !tank.testing
: tank.testing),
)
.sort((a, b) => {
let diff = 0;
if (sort.by === 'tier') {
diff = a.tier - b.tier;
}
if (sort.by === 'name') {
diff = a.name.localeCompare(b.name);
}
return sort.direction === 'ascending' ? diff : -diff;
}),
[filters, sort],
);
const [searchResults, setSearchedList] = useState(searchableTanks);
const page = useTankopediaPersistent((state) => state.filters.page);
const chunkSize = window.innerWidth > 512 ? tanksPerPage / 2 : Infinity;
const searchResultsPageSlice = searchResults.slice(
page * tanksPerPage,
(page + 1) * tanksPerPage,
);
const firstChunk = searchResultsPageSlice.slice(0, chunkSize);
const secondChunk = searchResultsPageSlice.slice(chunkSize);
useEffect(() => {
setSearchedList(searchableTanks);
if (input.current) input.current.value = '';
}, [searchableTanks]);
useEffect(() => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.min(
Math.max(0, page),
Math.ceil(searchResults.length / tanksPerPage) - 1,
);
});
}, [searchResults]);
return (
<>
<Flex direction="column" gap="4">
<Flex gap="2">
<TextField.Root style={{ flex: 1 }}>
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
if (event.target.value.length === 0) {
setSearchedList(searchableTanks);
} else {
setSearchedList(
go(event.target.value, searchableTanks, {
keys: ['name', 'id'] satisfies (keyof TankDefinition)[],
}).map(({ obj }) => obj),
);
}
}}
/>
</TextField.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.by}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.by = value as TankopediaSortBy;
})
}
>
<DropdownMenu.RadioItem value="tier">
Tier
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="name">
Name
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.direction}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
<Card>
<Suspense fallback={<Text>Loading...</Text>}>
<Options />
</Suspense>
</Card>
</Flex>
{!compact && (
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
)}
<Flex wrap="wrap" gap="3" justify={compact ? 'between' : 'center'}>
{!compact &&
searchResultsPageSlice.map((tank) => (
<Card
key={tank.id}
onClick={() => onSelect(tank)}
className={styles.listing}
style={{
flex: 1,
cursor: 'pointer',
}}
>
<Inset
key={tank.id}
style={{
minWidth: 256,
minHeight: 128,
position: 'relative',
display: 'flex',
}}
>
<img
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
src={
(tank.tree_type === 'collector'
? TANK_ICONS_COLLECTOR
: tank.tree_type === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.type]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.tree_type === 'collector'
? 'blue'
: tank.tree_type === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.tree_type === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
))}
{compact && (
<CompactSearchResultRow tanks={firstChunk} onSelect={onSelect} />
)}
{compact && secondChunk.length > 0 && (
<CompactSearchResultRow tanks={secondChunk} onSelect={onSelect} />
)}
</Flex>
<PageTurner tanksPerPage={tanksPerPage} searchedList={searchResults} />
</>
);
}
+10 -291
View File
@@ -1,303 +1,22 @@
'use client';
import { slateDark } from '@radix-ui/colors';
import { CaretDownIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
import {
Button,
Card,
DropdownMenu,
Flex,
Inset,
Text,
TextField,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import Link from 'next/link';
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import { Text } from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { Suspense } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { asset } from '../../../core/blitzkrieg/asset';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
TankDefinition,
tanksDefinitionsArray,
} from '../../../core/blitzkrieg/tankDefinitions';
import { tankIcon } from '../../../core/blitzkrieg/tankIcon';
import { theme } from '../../../stitches.config';
import mutateTankopediaPersistent, {
TankopediaSortBy,
TankopediaSortDirection,
useTankopediaPersistent,
} from '../../../stores/tankopedia';
import { Options } from './components/Options';
import { PageTurner } from './components/PageTurner';
import * as styles from './page.css';
const TANKS_PER_PAGE = 3 * 2 ** 3;
import { TankSearch } from './components/TankSearch';
export default function Page() {
const filters = useTankopediaPersistent((state) => state.filters);
const sort = useTankopediaPersistent((state) => state.sort);
const awaitedTanks = use(tanksDefinitionsArray);
const input = useRef<HTMLInputElement>(null);
const searchableTanks = useMemo(
() =>
awaitedTanks
.filter(
(tank) =>
(filters.tiers.length === 0
? true
: filters.tiers.includes(tank.tier)) &&
(filters.types.length === 0
? true
: filters.types.includes(tank.type)) &&
(filters.treeTypes.length === 0
? true
: filters.treeTypes.includes(tank.tree_type)) &&
(filters.nations.length === 0
? true
: filters.nations.includes(tank.nation)) &&
(filters.test === 'include'
? true
: filters.test === 'exclude'
? !tank.testing
: tank.testing),
)
.sort((a, b) => {
let diff = 0;
if (sort.by === 'tier') {
diff = a.tier - b.tier;
}
if (sort.by === 'name') {
diff = a.name.localeCompare(b.name);
}
return sort.direction === 'ascending' ? diff : -diff;
}),
[filters, sort],
);
const [searchedList, setSearchedList] = useState(searchableTanks);
const page = useTankopediaPersistent((state) => state.filters.page);
useEffect(() => {
setSearchedList(searchableTanks);
if (input.current) input.current.value = '';
}, [searchableTanks]);
useEffect(() => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.min(
Math.max(0, page),
Math.ceil(searchedList.length / TANKS_PER_PAGE) - 1,
);
});
}, [searchedList]);
const router = useRouter();
return (
<PageWrapper size="wide" color="purple">
<Suspense fallback={<Text>Loading...</Text>}>
<Flex direction="column" gap="4">
<Flex gap="2">
<TextField.Root style={{ flex: 1 }}>
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
if (event.target.value.length === 0) {
setSearchedList(searchableTanks);
} else {
setSearchedList(
go(event.target.value, searchableTanks, {
keys: ['name', 'id'] satisfies (keyof TankDefinition)[],
}).map(({ obj }) => obj),
);
}
}}
/>
</TextField.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Sort
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>By</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.by}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.by = value as TankopediaSortBy;
})
}
>
<DropdownMenu.RadioItem value="tier">
Tier
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="name">
Name
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Label>Direction</DropdownMenu.Label>
<DropdownMenu.RadioGroup
value={sort.direction}
onValueChange={(value) =>
mutateTankopediaPersistent((draft) => {
draft.sort.direction = value as TankopediaSortDirection;
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
<Card>
<Suspense fallback={<Text>Loading...</Text>}>
<Options />
</Suspense>
</Card>
</Flex>
<PageTurner tanksPerPage={TANKS_PER_PAGE} searchedList={searchedList} />
<Flex wrap="wrap" gap="3" justify="center">
{searchedList
.map((tank) => (
<Link
key={tank.id}
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
textDecoration: 'none',
color: 'unset',
}}
>
<Card className={styles.listing}>
<Inset
key={tank.id}
style={{
minWidth: 256,
minHeight: 128,
position: 'relative',
display: 'flex',
}}
>
<img
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
</div>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
src={
(tank.tree_type === 'collector'
? TANK_ICONS_COLLECTOR
: tank.tree_type === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.type]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.tree_type === 'collector'
? 'blue'
: tank.tree_type === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.tree_type === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</Link>
))
.slice(page * TANKS_PER_PAGE, (page + 1) * TANKS_PER_PAGE)}
</Flex>
<PageTurner tanksPerPage={TANKS_PER_PAGE} searchedList={searchedList} />
<TankSearch
onSelect={(tank) => {
router.push(`./tankopedia/${tank.id}`);
}}
/>
</Suspense>
</PageWrapper>
);