feat: page turner

This commit is contained in:
TrèsAbhi committed 2023-12-03 19:45:43 -06:00
1 parent 579d7570af
commit c5e1a7d6ab
8 files changed
+322 -144

No files matched your search

+7 -1
View File
@@ -41,7 +41,13 @@ export default function RootLayout({ children }: RootLayoutProps) {
: 'url(https://i.imgur.com/PhS06NJ.png)',
}}
>
<Theme appearance="dark" panelBackground="translucent" radius="full">
<Theme
appearance="dark"
panelBackground="translucent"
radius="full"
suppressHydrationWarning
suppressContentEditableWarning
>
{!isEmbed && <Navbar />}
<Flex direction="column">{children}</Flex>
@@ -59,6 +59,8 @@ export function PageTurner({
type="number"
ref={pageInput}
style={{ width: 64, textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(
@@ -1,135 +0,0 @@
'use client';
import { slateDark } from '@radix-ui/colors';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Card, Flex, Inset, Text, TextField } from '@radix-ui/themes';
import { use, useRef } from 'react';
import { Search } from 'react-fuzzysort';
import { tankIcon } from '../../../../../core/blitzkrieg/tankIcon';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
tanks,
} from '../../../../../core/blitzkrieg/tankopedia';
import { theme } from '../../../../../stitches.config';
import * as styles from './index.css';
export function List() {
const awaitedTanks = use(
tanks.then((tanks) => tanks.sort((a, b) => b.tier - a.tier)),
);
const input = useRef<HTMLInputElement>(null);
return (
<>
<TextField.Root>
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input ref={input} placeholder="Search tanks..." />
</TextField.Root>
{awaitedTanks.length > 0 && (
<Flex wrap="wrap" gap="3" justify="center">
<Search
keyOptions={{}}
input={input}
list={awaitedTanks.map((tank, index) => ({
node: (
<a
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
}}
>
<Card className={styles.listing}>
<Inset
key={index}
style={{
minHeight: 128,
minWidth: 256,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
className={styles.listingImage}
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<Flex
align="center"
justify="center"
gap="1"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
left: 12,
}}
>
<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 === 'tech-tree'
? slateDark.slate12
: undefined,
}}
>
{tank.name_short ??
tank.name ??
`Unknown tank ${tank.id}`}
</Text>
</Flex>
</Inset>
</Card>
</a>
),
query: `${
tank.name_short ?? tank.name ?? `Unknown tank ${tank.id}`
} ${tank.tree_type} ${
tank.type === 'AT-SPG' ? 'tank destroyer' : tank.type
} ${tank.id}`,
}))}
/>
</Flex>
)}
</>
);
}
@@ -0,0 +1,60 @@
import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import {
TankopediaSortBy,
TankopediaSortDirection,
useTankopedia,
} from '../../../../stores/tankopedia';
export function Options() {
const tankopediaState = useTankopedia();
return (
<Flex gap="2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Sort by
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
value={tankopediaState.sortBy}
onValueChange={(value) =>
useTankopedia.setState({ sortBy: value as TankopediaSortBy })
}
>
<DropdownMenu.RadioItem value="tier">Tier</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Sort order
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup
value={tankopediaState.sortDirection}
onValueChange={(value) =>
useTankopedia.setState({
sortDirection: value as TankopediaSortDirection,
})
}
>
<DropdownMenu.RadioItem value="ascending">
Ascending
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="descending">
Descending
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
);
}
Whitespace-only changes.
@@ -1,12 +1,12 @@
import { style } from '@vanilla-extract/css';
import { theme } from '../../../../../stitches.config';
import { theme } from '../../../stitches.config';
export const listing = style({});
export const listingShadow = style({
boxShadow: `inset 0 -128px 128px -128px ${theme.colors.appBackground1}`,
[`.${listing}:hover &`]: {
[`.${listing}:hover &, .${listing}:active &`]: {
boxShadow: `inset 0 0 0 -128px ${theme.colors.appBackground1}`,
},
});
@@ -15,7 +15,7 @@ export const listingLabel = style({
transition: `transform ${theme.durations.regular}`,
transform: 'translateY(0)',
[`.${listing}:hover &`]: {
[`.${listing}:hover &, .${listing}:active &`]: {
transform: 'translateY(200%)',
},
});
@@ -24,7 +24,7 @@ export const listingImage = style({
transition: `transform ${theme.durations.regular}`,
transform: 'translateX(25%) translateY(0)',
[`.${listing}:hover &`]: {
[`.${listing}:hover &, .${listing}:active &`]: {
transform: 'translateX(25%) translateY(10%)',
},
});
+222 -4
View File
@@ -1,13 +1,231 @@
import { Text } from '@radix-ui/themes';
import { Suspense } from 'react';
'use client';
import { slateDark } from '@radix-ui/colors';
import {
CaretLeftIcon,
CaretRightIcon,
MagnifyingGlassIcon,
} from '@radix-ui/react-icons';
import { Button, Card, Flex, Inset, Text, TextField } from '@radix-ui/themes';
import { go } from 'fuzzysort';
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react';
import PageWrapper from '../../../components/PageWrapper';
import { List } from './components/List';
import { tankIcon } from '../../../core/blitzkrieg/tankIcon';
import {
BlitzkriegTankopediaEntry,
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
tanks,
} from '../../../core/blitzkrieg/tankopedia';
import { theme } from '../../../stitches.config';
import { useTankopedia } from '../../../stores/tankopedia';
import { Options } from './components/Options';
import * as styles from './page.css';
const TANKS_PER_PAGE = 30;
export default function Page() {
const tankopediaState = useTankopedia();
const awaitedTanks = use(tanks);
const input = useRef<HTMLInputElement>(null);
const sortedTanks = useMemo(
() =>
awaitedTanks.sort((a, b) => {
let diff = 0;
if (tankopediaState.sortBy === 'tier') diff = a.tier - b.tier;
return tankopediaState.sortDirection === 'ascending' ? diff : -diff;
}),
[tankopediaState.sortBy, tankopediaState.sortDirection],
);
const [searchedList, setSearchedList] = useState(sortedTanks);
const [page, setPage] = useState(0);
const pageInput = useRef<HTMLInputElement>(null);
useEffect(() => {
if (pageInput.current) pageInput.current.value = `${page + 1}`;
}, [page]);
return (
<PageWrapper size="wide">
<Suspense fallback={<Text>Loading...</Text>}>
<List />
<Flex direction="column" gap="2">
<TextField.Root>
<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(sortedTanks);
} else {
setSearchedList(
go(event.target.value, sortedTanks, {
keys: [
'name',
'name_short',
'id',
'nation',
'tree_type',
'type',
] satisfies (keyof BlitzkriegTankopediaEntry)[],
}).map(({ obj }) => obj),
);
}
}}
/>
</TextField.Root>
<Options />
<Flex align="center" justify="center" gap="2">
<Button
variant="soft"
disabled={page === 0}
onClick={() => setPage(Math.max(0, page - 1))}
>
<CaretLeftIcon />
</Button>
<TextField.Root>
<TextField.Slot>Page</TextField.Slot>
<TextField.Input
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / TANKS_PER_PAGE) + 1}
style={{ width: 64, textAlign: 'center' }}
onBlur={(event) => {
setPage(
Math.max(
0,
Math.min(
Math.floor(searchedList.length / TANKS_PER_PAGE),
event.target.valueAsNumber - 1,
),
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot>
out of {Math.floor(searchedList.length / TANKS_PER_PAGE) + 1}
</TextField.Slot>
</TextField.Root>
<Button
variant="soft"
disabled={
Math.floor(searchedList.length / TANKS_PER_PAGE) === page
}
onClick={() =>
setPage(
Math.min(
Math.floor(searchedList.length / TANKS_PER_PAGE),
page + 1,
),
)
}
>
<CaretRightIcon />
</Button>
</Flex>
</Flex>
<Flex wrap="wrap" gap="3" justify="center">
{searchedList
.map((tank) => (
<a
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
}}
>
<Card className={styles.listing}>
<Inset
key={tank.id}
style={{
minHeight: 128,
minWidth: 256,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
className={styles.listingImage}
src={tankIcon(tank.id)}
style={{
objectFit: 'contain',
objectPosition: 'left center',
}}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<Flex
align="center"
justify="center"
gap="1"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
left: 12,
}}
>
<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 === 'tech-tree'
? slateDark.slate12
: undefined,
}}
>
{tank.name_short ??
tank.name ??
`Unknown tank ${tank.id}`}
</Text>
</Flex>
</Inset>
</Card>
</a>
))
.slice(page * TANKS_PER_PAGE, (page + 1) * TANKS_PER_PAGE)}
</Flex>
</Suspense>
</PageWrapper>
);
+27
View File
@@ -0,0 +1,27 @@
import { produce } from 'immer';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
export type TankopediaSortBy = 'tier';
export type TankopediaSortDirection = 'ascending' | 'descending';
type Tankopedia = {
sortBy: TankopediaSortBy;
sortDirection: TankopediaSortDirection;
};
export const useTankopedia = create<Tankopedia>()(
devtools(
persist(
(set) => ({
sortBy: 'tier',
sortDirection: 'descending',
}),
{ name: 'tankopedia' },
),
),
);
export default function mutateTankopedia(recipe: (draft: Tankopedia) => void) {
useTankopedia.setState(produce(recipe));
}