some cleanup

This commit is contained in:
tresabhi committed 2026-08-29 13:13:44 -05:00
1 parent 8d7575815b
commit ec161addab
15 files changed
+247 -127

No files matched your search

@@ -5,8 +5,6 @@ import styles from "./index.module.css";
interface Props extends ComponentProps<"div"> {} interface Props extends ComponentProps<"div"> {}
export function Back({ className, onClick, ...props }: Props) { export function Back({ className, onClick, ...props }: Props) {
console.log("init");
return ( return (
<div <div
className={classNames(styles.back, className)} className={classNames(styles.back, className)}
@@ -21,27 +21,19 @@
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
} }
&[data-array="true"] { &[data-list-child="true"] {
&:first-child { &:not(:first-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:last-child {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
}
border-left-width: var(--space-0);
} }
&[data-parent-array="true"] { &:not(:last-child) {
*:first-child > & {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
}
*:last-child > & { border-right-width: var(--space-0);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
} }
} }
@@ -82,6 +74,6 @@
&[data-variant="surface"], &[data-variant="surface"],
&[data-variant="outline"] { &[data-variant="outline"] {
outline: var(--space-1) solid var(--accent-6); border: var(--space-1) solid var(--accent-6);
} }
} }
@@ -8,8 +8,7 @@ export interface ButtonProps extends ComponentProps<"button"> {
radius?: `${1 | 2 | 3}` | "max"; radius?: `${1 | 2 | 3}` | "max";
variant?: "solid" | "surface" | "soft" | "inverted" | "ghost" | "outline"; variant?: "solid" | "surface" | "soft" | "inverted" | "ghost" | "outline";
size?: "regular" | "minor"; size?: "regular" | "minor";
array?: boolean; listChild?: boolean;
parentArray?: boolean;
highContrast?: boolean; // TODO: add high contrast highContrast?: boolean; // TODO: add high contrast
} }
@@ -35,8 +34,7 @@ export function Button({
style, style,
radius = "max", radius = "max",
size = "regular", size = "regular",
array = false, listChild,
parentArray = false,
...props ...props
}: ButtonProps) { }: ButtonProps) {
return ( return (
@@ -47,8 +45,7 @@ export function Button({
data-light-text={variant === "solid"} data-light-text={variant === "solid"}
data-radius={radius} data-radius={radius}
data-size={size} data-size={size}
data-array={array} data-list-child={listChild}
data-parent-array={parentArray}
{...props} {...props}
/> />
); );
@@ -0,0 +1,84 @@
import { times } from "lodash-es";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps";
export interface IncrementalLoaderProps<Props extends { key: string }> {
initial: number;
intermediate: number;
rate?: number;
skeleton?: boolean;
data: Props[];
Component: (
props: MaybeSkeletonComponentProps<Omit<Props, "key">>,
) => ReactNode;
}
export function IncrementalLoader<Props extends { key: string }>({
initial,
rate = 2,
data,
skeleton,
intermediate,
Component,
}: IncrementalLoaderProps<Props>) {
const [count, setCount] = useState(initial);
const sliced = data.slice(0, count);
useEffect(() => setCount(initial), [data, initial]);
return (
<>
{!skeleton &&
sliced.map(({ key, ...props }) => <Component key={key} {...props} />)}
{times(
skeleton
? initial + intermediate
: Math.min(intermediate, data.length - count),
(index) => (
<Item key={index} rate={rate} setCount={setCount}>
<Component skeleton />
</Item>
),
)}
</>
);
}
interface ItemProps {
children: ReactNode;
rate: number;
setCount: (setter: (state: number) => number) => void;
}
function Item({ setCount, rate, children }: ItemProps) {
const wrapper = useRef<HTMLDivElement>(null);
const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => {
function dispose() {
observer.current?.disconnect();
observer.current = null;
}
if (!wrapper.current) return;
if (observer.current === null) {
observer.current = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
setCount((state) => state + rate);
});
observer.current.observe(wrapper.current);
}
return dispose;
}, []);
return (
<div ref={wrapper} className="incremental-loader">
{children}
</div>
);
}
@@ -1,3 +1,4 @@
.inline-skeleton { .inline-skeleton {
display: inline-block; display: inline-block;
height: 1em;
} }
@@ -1,11 +1,11 @@
import { Skeleton, type SkeletonProps } from '@radix-ui/themes'; import { classNames } from "../../ui/classNames";
import './index.css'; import { Skeleton, type SkeletonProps } from "../Skeleton";
import styles from "./index.module.css";
export function InlineSkeleton({ className, ...props }: SkeletonProps) { export function InlineSkeleton({ className, ...props }: SkeletonProps) {
return ( return (
<Skeleton <Skeleton
className={`inline-skeleton ${className}`} className={classNames(styles["inline-skeleton"], className)}
height="1em"
{...props} {...props}
/> />
); );
@@ -4,9 +4,11 @@ import { type ReactNode } from "react";
import { api } from "../../core/blitzkit/api"; import { api } from "../../core/blitzkit/api";
import { useLocale } from "../../hooks/useLocale"; import { useLocale } from "../../hooks/useLocale";
import { TankopediaPersistent } from "../../stores/tankopediaPersistent"; import { TankopediaPersistent } from "../../stores/tankopediaPersistent";
import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
import { classIcons } from "../ClassIcon"; import { classIcons } from "../ClassIcon";
import { Flex } from "../Flex"; import { Flex } from "../Flex";
import { LinkI18nWrapper } from "../LinkI18nWrapper"; import { LinkI18nWrapper } from "../LinkI18nWrapper";
import { TankCardSkeleton } from "../TankCardSkeleton";
import { MAX_RECENTLY_VIEWED } from "../TankSearch/constants"; import { MAX_RECENTLY_VIEWED } from "../TankSearch/constants";
import { Text, type TextProps } from "../Text"; import { Text, type TextProps } from "../Text";
import styles from "./index.module.css"; import styles from "./index.module.css";
@@ -21,38 +23,36 @@ type TankCardProps = {
const tankDefinitions = await api.tankDefinitions(); const tankDefinitions = await api.tankDefinitions();
export const TankCard = ({ export const TankCard = (props: MaybeSkeletonComponentProps<TankCardProps>) => {
tank, if (props.skeleton) {
discriminator, return <TankCardSkeleton />;
onTankSelect: onSelect, }
noLink,
compact,
}: TankCardProps) => {
const { unwrap, locale } = useLocale(); const { unwrap, locale } = useLocale();
const provideLink = !noLink && onSelect === undefined; const provideLink = !props.noLink && props.onTankSelect === undefined;
const name = unwrap(tank.name!); const name = unwrap(props.tank.name!);
const color: TextProps["color"] = const color: TextProps["color"] =
tank.type === TankType.TANK_TYPE_COLLECTOR props.tank.type === TankType.TANK_TYPE_COLLECTOR
? "blue" ? "blue"
: tank.type === TankType.TANK_TYPE_PREMIUM : props.tank.type === TankType.TANK_TYPE_PREMIUM
? "amber" ? "amber"
: "gray"; : "gray";
const lowContrast = tank.type !== TankType.TANK_TYPE_RESEARCHABLE; const lowContrast = props.tank.type !== TankType.TANK_TYPE_RESEARCHABLE;
const Icon = classIcons[tank.class]; const Icon = classIcons[props.tank.class];
const content = ( const content = (
<Flex <Flex
className={styles.wrapper} className={styles.wrapper}
data-compact={compact} data-compact={props.compact}
column column
gap="3" gap="3"
onClick={() => { onClick={() => {
onSelect?.(tank); props.onTankSelect?.(props.tank);
TankopediaPersistent.mutate((draft) => { TankopediaPersistent.mutate((draft) => {
draft.recentlyViewed = uniq([tank.id, ...draft.recentlyViewed]) draft.recentlyViewed = uniq([props.tank.id, ...draft.recentlyViewed])
.filter((id) => id in tankDefinitions.tanks) .filter((id) => id in tankDefinitions.tanks)
.slice(0, MAX_RECENTLY_VIEWED); .slice(0, MAX_RECENTLY_VIEWED);
}); });
@@ -64,13 +64,13 @@ export const TankCard = ({
style={{ style={{
backgroundImage: `url(${alias( backgroundImage: `url(${alias(
"api", "api",
`/flags/scratched/${tank.nation}.webp`, `/flags/scratched/${props.tank.nation}.webp`,
)})`, )})`,
}} }}
> >
<img <img
alt={name} alt={name}
src={alias("api", `/tanks/${tank.id}/icons/big.webp`)} src={alias("api", `/tanks/${props.tank.id}/icons/big.webp`)}
/> />
</Flex> </Flex>
@@ -79,9 +79,9 @@ export const TankCard = ({
<span className={styles.text}>{name}</span> <span className={styles.text}>{name}</span>
</Text> </Text>
{discriminator && ( {props.discriminator && (
<Text lowContrast align="center"> <Text lowContrast align="center">
{discriminator} {props.discriminator}
</Text> </Text>
)} )}
</Flex> </Flex>
@@ -89,7 +89,7 @@ export const TankCard = ({
if (provideLink) { if (provideLink) {
return ( return (
<LinkI18nWrapper locale={locale} href={`/tanks/${tank.slug}`}> <LinkI18nWrapper locale={locale} href={`/tanks/${props.tank.slug}`}>
{content} {content}
</LinkI18nWrapper> </LinkI18nWrapper>
); );
@@ -1,7 +1,43 @@
.icon { .wrapper {
/* height="5rem" */ --image-height: 6rem;
}
.name { &[data-compact="true"] {
/* height="1em" */ --image-height: 4rem;
.image-wrapper {
background-size: auto 75%;
}
}
.image-wrapper {
overflow: hidden;
box-sizing: border-box;
background-size: auto 100%;
background-repeat: no-repeat;
height: var(--image-height);
}
.name {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
.icon {
width: 1em;
height: 1em;
min-width: 1em;
min-height: 1em;
}
.text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
} }
@@ -1,27 +1,17 @@
import { memo, useRef } from "react";
import { useIntersection } from "../../hooks/useIntersection";
import { Flex } from "../Flex"; import { Flex } from "../Flex";
import { InlineSkeleton } from "../InlineSkeleton";
import { Skeleton } from "../Skeleton"; import { Skeleton } from "../Skeleton";
import styles from "./index.module.css"; import styles from "./index.module.css";
interface SkeletonTankCardProps { type TankCardProps = {
onIntersection?: () => void; compact?: boolean;
} };
export const TankCardSkeleton = memo(
({ onIntersection }: SkeletonTankCardProps) => {
const card = useRef<HTMLDivElement>(null!);
useIntersection(() => onIntersection?.(), card, {
disabled: onIntersection === undefined,
});
export const TankCardSkeleton = ({ compact }: TankCardProps) => {
return ( return (
<Flex column gap="2" ref={card}> <Flex className={styles.wrapper} data-compact={compact} column gap="3">
<Skeleton className={styles.icon} /> <Skeleton className={styles["image-wrapper"]} />
<Skeleton className={styles.name} /> <InlineSkeleton className={styles.name} />
</Flex> </Flex>
); );
}, };
() => true,
);
@@ -8,8 +8,8 @@ import {
} from "@blitzkit/core"; } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n/src/literals"; import { literals } from "@blitzkit/i18n/src/literals";
import fuzzysort from "fuzzysort"; import fuzzysort from "fuzzysort";
import { times, uniq } from "lodash-es"; import { uniq } from "lodash-es";
import { memo, useEffect, useMemo, useState } from "react"; import { memo, useMemo } from "react";
import usePromise from "react-promise-suspense"; import usePromise from "react-promise-suspense";
import { awaitableTankNames } from "../../core/awaitables/tankNames"; import { awaitableTankNames } from "../../core/awaitables/tankNames";
import { api } from "../../core/blitzkit/api"; import { api } from "../../core/blitzkit/api";
@@ -25,15 +25,15 @@ import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonCompo
import { Callout } from "../Callout"; import { Callout } from "../Callout";
import { ExperimentIcon } from "../ExperimentIcon"; import { ExperimentIcon } from "../ExperimentIcon";
import { Flex } from "../Flex"; import { Flex } from "../Flex";
import { IncrementalLoader } from "../IncrementalLoader";
import { Link } from "../Link"; import { Link } from "../Link";
import { RecentlyViewedTanks } from "../RecentlyViewedTanks"; import { RecentlyViewedTanks } from "../RecentlyViewedTanks";
import { TankCardSkeleton } from "../TankCardSkeleton"; import { TankCard } from "../TankCard";
import { TankCardWrapper } from "../TankCardWrapper"; import { TankCardWrapper } from "../TankCardWrapper";
import { TankSearchBar } from "../TankSearchBar"; import { TankSearchBar } from "../TankSearchBar";
import { TankSearchFilters } from "../TankSearchFilters"; import { TankSearchFilters } from "../TankSearchFilters";
import { TankSearchNoResults } from "../TankSearchNoResults"; import { TankSearchNoResults } from "../TankSearchNoResults";
import { Text } from "../Text"; import { Text } from "../Text";
import { TankSearchCard } from "./components/Card";
import { MAX_RECENTLY_VIEWED } from "./constants"; import { MAX_RECENTLY_VIEWED } from "./constants";
import styles from "./index.module.css"; import styles from "./index.module.css";
@@ -43,9 +43,6 @@ export type TankSearchProps = MaybeSkeletonComponentProps & {
onSelectAll?: (tanks: TankDefinition[]) => void; onSelectAll?: (tanks: TankDefinition[]) => void;
}; };
const PREVIEW_COUNT = 20;
const DEFAULT_LOADED_CARDS = 64;
const [gameDefinitions, modelDefinitions, tankDefinitions, tankNames] = const [gameDefinitions, modelDefinitions, tankDefinitions, tankNames] =
await Promise.all([ await Promise.all([
api.gameDefinitions(), api.gameDefinitions(),
@@ -420,18 +417,17 @@ export const TankSearch = memo<TankSearchProps>(
} }
}, [tankFilters, tankopediaSort]); }, [tankFilters, tankopediaSort]);
const [loadedCards, setLoadedCards] = useState(DEFAULT_LOADED_CARDS); const data = sorted.map((tank) => ({
const tanks = sorted.slice(0, loadedCards); key: `${tank.id}`,
tank,
useEffect(() => { onTankSelect: onSelect,
setLoadedCards(DEFAULT_LOADED_CARDS); }));
}, [tankFilters, tankopediaSort]);
return ( return (
<Flex className={styles.container} column gap="4"> <Flex className={styles.container} column gap="4">
<TankSearchBar <TankSearchBar
skeleton={skeleton} skeleton={skeleton}
topResult={tanks?.[0]} topResult={sorted[0]}
onSelect={onSelect} onSelect={onSelect}
/> />
@@ -439,7 +435,7 @@ export const TankSearch = memo<TankSearchProps>(
{!skeleton && !compact && <RecentlyViewedTanks />} {!skeleton && !compact && <RecentlyViewedTanks />}
{tanks.length > 0 && ( {sorted.length > 0 && (
<Flex justify="center" className={styles.label}> <Flex justify="center" className={styles.label}>
<div className={styles.count}> <div className={styles.count}>
<Text lowContrast> <Text lowContrast>
@@ -497,38 +493,18 @@ export const TankSearch = memo<TankSearchProps>(
</div> </div>
)} )}
{!skeleton && (
<>
{tanks.length > 0 && (
<TankCardWrapper> <TankCardWrapper>
{tanks.map((tank) => ( <IncrementalLoader
<TankSearchCard skeleton={skeleton}
tank={tank} initial={5 * 5}
key={tank.id} intermediate={5 * 2}
onSelect={onSelect} data={data}
Component={TankCard}
/> />
))}
{times(
Math.min(PREVIEW_COUNT, sorted.length - loadedCards),
(index) => {
return (
<TankCardSkeleton
key={index}
onIntersection={() => {
setLoadedCards((state) =>
Math.min(state + 2, sorted.length),
);
}}
/>
);
},
)}
</TankCardWrapper> </TankCardWrapper>
)}
{tanks.length === 0 && <TankSearchNoResults type="search" />} {!skeleton && sorted.length === 0 && (
</> <TankSearchNoResults type="search" />
)} )}
</Flex> </Flex>
); );
@@ -1,6 +1,11 @@
import type { TankDefinition } from "@blitzkit/core"; import type { TankDefinition } from "@blitzkit/core";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import { type KeyboardEventHandler, useCallback, useRef } from "react"; import {
type KeyboardEventHandler,
useCallback,
useEffect,
useRef,
} from "react";
import { useLocale } from "../../hooks/useLocale"; import { useLocale } from "../../hooks/useLocale";
import { TankFilters } from "../../stores/tankFilters"; import { TankFilters } from "../../stores/tankFilters";
import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps"; import type { MaybeSkeletonComponentProps } from "../../types/maybeSkeletonComponentProps";
@@ -19,8 +24,16 @@ export function TankSearchBar({
onSelect, onSelect,
}: SearchBarProps) { }: SearchBarProps) {
const { strings } = useLocale(); const { strings } = useLocale();
const search = TankFilters.use((state) => state.search); const search = TankFilters.use((state) => state.search);
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
useEffect(() => {
if (search !== null) return;
input.current!.value = "";
}, [search]);
const handleChange = useCallback(() => { const handleChange = useCallback(() => {
if (!input.current) return; if (!input.current) return;
@@ -23,3 +23,7 @@
.overflow-plus { .overflow-plus {
/* ml="1" */ /* ml="1" */
} }
.shell-icon {
display: contents;
}
@@ -613,7 +613,7 @@ function TypeFilter() {
function ShellFilter() { function ShellFilter() {
return ( return (
<Flex style={{}}> <Flex>
<IndividualShellFilter index={0} /> <IndividualShellFilter index={0} />
<IndividualShellFilter index={1} premium /> <IndividualShellFilter index={1} premium />
<IndividualShellFilter index={2} /> <IndividualShellFilter index={2} />
@@ -635,19 +635,14 @@ function IndividualShellFilter({
<DropdownMenu.Root modal={false}> <DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<IconButton <IconButton
listChild
size="minor" size="minor"
variant="surface" variant="surface"
style={{
borderTopLeftRadius: index === 0 ? undefined : 0,
borderBottomLeftRadius: index === 0 ? undefined : 0,
borderTopRightRadius: index === 2 ? undefined : 0,
borderBottomRightRadius: index === 2 ? undefined : 0,
}}
color="gray" color="gray"
highContrast highContrast
> >
{shells[index] === null && ( {shells[index] === null && (
<Text color="gray" style={{ display: "contents" }}> <Text lowContrast className={styles["shell-icon"]}>
<MissingShellIcon width="1em" height="1em" /> <MissingShellIcon width="1em" height="1em" />
</Text> </Text>
)} )}
@@ -2,6 +2,7 @@
import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { ArrowLeftIcon } from "@radix-ui/react-icons";
import type { Tool } from "../constants/tools"; import type { Tool } from "../constants/tools";
import { getStrings } from "../core/i18n/getStrings"; import { getStrings } from "../core/i18n/getStrings";
import { Logo } from "../icons/Logo";
import { Back } from "./Back"; import { Back } from "./Back";
import { Flex } from "./Flex"; import { Flex } from "./Flex";
import { Heading } from "./Heading"; import { Heading } from "./Heading";
@@ -37,6 +38,8 @@ const toolStrings =
backdrop-filter: var(--blur-tint); backdrop-filter: var(--blur-tint);
overflow: hidden;
:root.light & { :root.light & {
backdrop-filter: var(--blur-tint) invert(1) hue-rotate(180deg); backdrop-filter: var(--blur-tint) invert(1) hue-rotate(180deg);
} }
@@ -51,13 +54,23 @@ const toolStrings =
flex: 1; flex: 1;
max-width: var(--page-width); max-width: var(--page-width);
display: flex;
flex-direction: column;
.return { .return {
width: 0.75em; width: 0.75em;
height: 0.75em; height: 0.75em;
} }
.logo {
position: absolute;
pointer-events: none;
right: 0;
top: 50%;
height: 100%;
transform: translateY(-50%) scale(10);
color: var(--black-a4);
}
} }
.pattern { .pattern {
@@ -110,6 +123,8 @@ const toolStrings =
{toolStrings.name} {toolStrings.name}
</Flex> </Flex>
</Heading> </Heading>
<Logo className="logo" />
</div> </div>
</div> </div>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
import type { ComponentProps } from "react";
export function Logo(props: ComponentProps<"svg">) {
return (
<svg
width="128"
height="128"
viewBox="0 0 128 128"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M48.4082 19.8652C56.6277 19.8652 62.3045 21.5665 65.4375 24.9688C67.7005 27.3762 68.8875 30.4035 69 34.0498L52.1045 58.1982L61.252 68.375L30.8174 120.845L32.3818 93.4385H0L12.9863 19.8652H48.4082ZM83.584 58.1318L103.038 35.7969H128L99.2139 65.7285L117.691 109.37H90.4053L81.2812 84.373L78.4297 87.3389L74.5391 109.37H50.0312L52.4111 95.8789L71.4209 68.375L62.2734 58.1982L92.6064 7L83.584 58.1318ZM26.5791 77.875H32.9961C35.3204 77.875 37.1735 77.1677 38.5547 75.7529C39.9358 74.3381 40.8292 72.485 41.2334 70.1943C41.6376 67.9037 41.4012 66.0505 40.5254 64.6357C39.6495 63.1876 38.0496 62.463 35.7256 62.4629H29.3076L26.5791 77.875ZM31.4297 50.4365H37.1904C39.3126 50.4365 40.9635 49.8137 42.1426 48.5674C43.3553 47.2873 44.1641 45.5016 44.5684 43.2109C44.9726 40.954 44.8036 39.2186 44.0625 38.0059C43.3213 36.7598 41.8725 36.1367 39.7168 36.1367H33.957L31.4297 50.4365Z"
fill="currentColor"
/>
</svg>
);
}