From 354aca5057000d67e3551ce167554a742264fd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C3=A8sAbhi?= <43380238+tresabhi@users.noreply.github.com> Date: Mon, 2 Jun 2025 13:21:49 -0500 Subject: [PATCH] feat: skeletons --- .../src/components/Avatars/Colors.ts | 10 + .../src/components/Avatars/Content.tsx | 57 ++++++ .../src/components/Avatars/Group.tsx | 179 ++---------------- .../src/components/Avatars/Popup.tsx | 71 +++++++ .../src/components/Avatars/Stack.tsx | 52 +++++ .../src/components/InlineSkeleton.tsx | 7 + .../website-ue/src/hooks/useIntersection.ts | 29 +++ .../src/pages/[...locale]/avatars/_index.tsx | 30 ++- .../src/types/maybeSkeletonComponentProps.ts | 8 + 9 files changed, 270 insertions(+), 173 deletions(-) create mode 100644 packages/website-ue/src/components/Avatars/Colors.ts create mode 100644 packages/website-ue/src/components/Avatars/Content.tsx create mode 100644 packages/website-ue/src/components/Avatars/Popup.tsx create mode 100644 packages/website-ue/src/components/Avatars/Stack.tsx create mode 100644 packages/website-ue/src/components/InlineSkeleton.tsx create mode 100644 packages/website-ue/src/hooks/useIntersection.ts create mode 100644 packages/website-ue/src/types/maybeSkeletonComponentProps.ts diff --git a/packages/website-ue/src/components/Avatars/Colors.ts b/packages/website-ue/src/components/Avatars/Colors.ts new file mode 100644 index 000000000..fa17f2612 --- /dev/null +++ b/packages/website-ue/src/components/Avatars/Colors.ts @@ -0,0 +1,10 @@ +import { Grade } from '@protos/blitz_static_standard_grades_enum'; +import type { BadgeProps } from '@radix-ui/themes'; + +export const GRADE_COLORS = { + [Grade.GRADE_UNSPECIFIED]: 'gray', + [Grade.GRADE_COMMON]: 'gray', + [Grade.GRADE_RARE]: 'blue', + [Grade.GRADE_EPIC]: 'purple', + [Grade.GRADE_LEGENDARY]: 'amber', +} satisfies Record; diff --git a/packages/website-ue/src/components/Avatars/Content.tsx b/packages/website-ue/src/components/Avatars/Content.tsx new file mode 100644 index 000000000..3f23e1808 --- /dev/null +++ b/packages/website-ue/src/components/Avatars/Content.tsx @@ -0,0 +1,57 @@ +import { type FlexProps, Box, Flex, Skeleton, Text } from '@radix-ui/themes'; +import { useRef } from 'react'; +import { useIntersection } from '../../hooks/useIntersection'; +import { useLocale } from '../../hooks/useLocale'; +import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; +import { InlineSkeleton } from '../InlineSkeleton'; +import type { AvatarGroupProps } from './Group'; +import { Stack } from './Stack'; + +const MAX_AVATARS_PREVIEW = 5; + +export function Content({ + ...props +}: FlexProps & MaybeSkeletonComponentProps) { + const { strings } = useLocale(); + const container = useRef(null); + + useIntersection(() => props.skeleton && props.onIntersection?.(), container, { + disabled: !props.skeleton || props.onIntersection === undefined, + }); + + return ( + + + {props.skeleton && } + + {!props.skeleton && ( + + )} + + + + {props.skeleton ? ( + + ) : ( + (props.name ?? strings.website.tools.avatars.unnamed_avatars) + )} + + + ); +} diff --git a/packages/website-ue/src/components/Avatars/Group.tsx b/packages/website-ue/src/components/Avatars/Group.tsx index a35ffe402..1cd9bf465 100644 --- a/packages/website-ue/src/components/Avatars/Group.tsx +++ b/packages/website-ue/src/components/Avatars/Group.tsx @@ -1,26 +1,12 @@ -import { - ProfileAvatarComponent_Category, - type ProfileAvatarComponent, -} from '@protos/blitz_static_profile_avatar_component'; +import { type ProfileAvatarComponent } from '@protos/blitz_static_profile_avatar_component'; import type { SellableComponent } from '@protos/blitz_static_sellable_component'; -import { Grade } from '@protos/blitz_static_standard_grades_enum'; import type { StuffUIComponent } from '@protos/blitz_static_stuff_ui_component'; -import { DownloadIcon, StarFilledIcon } from '@radix-ui/react-icons'; -import { - Badge, - Box, - Button, - Dialog, - Flex, - Text, - type BadgeProps, - type FlexProps, -} from '@radix-ui/themes'; -import { literals } from 'packages/i18n/src'; -import { Var } from '../../core/radix/var'; -import { useLocale } from '../../hooks/useLocale'; +import { Dialog } from '@radix-ui/themes'; +import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; +import { Content } from './Content'; +import { Popup } from './Popup'; -interface AvatarGroupProps { +export interface AvatarGroupProps { name?: string; avatars: { stuff: StuffUIComponent; @@ -29,7 +15,11 @@ interface AvatarGroupProps { }[]; } -export function AvatarGroup(props: AvatarGroupProps) { +export function AvatarGroup( + props: MaybeSkeletonComponentProps, +) { + if (props.skeleton) return ; + return ( @@ -42,150 +32,3 @@ export function AvatarGroup(props: AvatarGroupProps) { ); } - -const CATEGORY_COLORS: Record< - ProfileAvatarComponent_Category, - BadgeProps['color'] -> = { - [ProfileAvatarComponent_Category.CATEGORY_UNSPECIFIED]: 'gray', - [ProfileAvatarComponent_Category.CATEGORY_PREMUM]: 'gray', - [ProfileAvatarComponent_Category.CATEGORY_SPECIAL]: 'gray', - [ProfileAvatarComponent_Category.CATEGORY_ACCUMULATIVE]: 'gray', - [ProfileAvatarComponent_Category.CATEGORY_OTHER]: 'gray', - [ProfileAvatarComponent_Category.CATEGORY_TEMPORARY]: 'gray', -}; -const GRADE_COLORS = { - [Grade.GRADE_UNSPECIFIED]: 'gray', - [Grade.GRADE_COMMON]: 'gray', - [Grade.GRADE_RARE]: 'blue', - [Grade.GRADE_EPIC]: 'purple', - [Grade.GRADE_LEGENDARY]: 'amber', -} satisfies Record; - -function Popup({ name, avatars }: AvatarGroupProps) { - const { strings, gameStrings } = useLocale(); - - return ( - <> - {name} - - - {avatars.map(({ avatar, stuff }, index) => ( - - - - {strings.common.grades[stuff.grade]} - - - - - - {gameStrings[stuff.description] ?? - (gameStrings[stuff.obtaining_methods] - ? undefined - : strings.website.tools.avatars.no_description)}{' '} - {gameStrings[stuff.obtaining_methods]} - - - - - - - - ))} - - - ); -} - -const dx = '4px'; -const MAX_AVATARS_PREVIEW = 5; - -function Content({ avatars, name, ...props }: FlexProps & AvatarGroupProps) { - const { strings } = useLocale(); - const avatarsReduced = [...avatars].slice(-MAX_AVATARS_PREVIEW); - - return ( - - - {avatarsReduced.map((avatar, index) => ( - - {avatars.length > 1 && ( - - )} - - ))} - - {avatars.length > 1 && ( - - - {literals(strings.common.units.times, [`${avatars.length}`])} - - - )} - - - - {name ?? strings.website.tools.avatars.unnamed_avatars} - - - ); -} diff --git a/packages/website-ue/src/components/Avatars/Popup.tsx b/packages/website-ue/src/components/Avatars/Popup.tsx new file mode 100644 index 000000000..610f6a546 --- /dev/null +++ b/packages/website-ue/src/components/Avatars/Popup.tsx @@ -0,0 +1,71 @@ +import { Grade } from '@protos/blitz_static_standard_grades_enum'; +import { DownloadIcon, StarFilledIcon } from '@radix-ui/react-icons'; +import { Badge, Box, Button, Dialog, Flex, Text } from '@radix-ui/themes'; +import { Var } from '../../core/radix/var'; +import { useLocale } from '../../hooks/useLocale'; +import { GRADE_COLORS } from './Colors'; +import type { AvatarGroupProps } from './Group'; + +export function Popup({ name, avatars }: AvatarGroupProps) { + const { strings, gameStrings } = useLocale(); + + return ( + <> + {name} + + + {avatars.map(({ avatar, stuff }, index) => ( + + + + {strings.common.grades[stuff.grade]} + + + + + + {gameStrings[stuff.description] ?? + (gameStrings[stuff.obtaining_methods] + ? undefined + : strings.website.tools.avatars.no_description)}{' '} + {gameStrings[stuff.obtaining_methods]} + + + + + + + + ))} + + + ); +} diff --git a/packages/website-ue/src/components/Avatars/Stack.tsx b/packages/website-ue/src/components/Avatars/Stack.tsx new file mode 100644 index 000000000..91f71bff2 --- /dev/null +++ b/packages/website-ue/src/components/Avatars/Stack.tsx @@ -0,0 +1,52 @@ +import { Badge, Box } from '@radix-ui/themes'; +import { literals } from 'packages/i18n/src'; +import { Var } from '../../core/radix/var'; +import { useLocale } from '../../hooks/useLocale'; +import type { AvatarGroupProps } from './Group'; + +const dx = '4px'; + +export function Stack({ avatars }: { avatars: AvatarGroupProps['avatars'] }) { + const { strings } = useLocale(); + + return ( + <> + {avatars.map((avatar, index) => ( + + {avatars.length > 1 && ( + + )} + + ))} + + {avatars.length > 1 && ( + + + {literals(strings.common.units.times, [`${avatars.length}`])} + + + )} + + ); +} diff --git a/packages/website-ue/src/components/InlineSkeleton.tsx b/packages/website-ue/src/components/InlineSkeleton.tsx new file mode 100644 index 000000000..4330d5cc6 --- /dev/null +++ b/packages/website-ue/src/components/InlineSkeleton.tsx @@ -0,0 +1,7 @@ +import { Skeleton, type SkeletonProps } from '@radix-ui/themes'; + +export function InlineSkeleton({ className, ...props }: SkeletonProps) { + return ( + + ); +} diff --git a/packages/website-ue/src/hooks/useIntersection.ts b/packages/website-ue/src/hooks/useIntersection.ts new file mode 100644 index 000000000..99d3c01fa --- /dev/null +++ b/packages/website-ue/src/hooks/useIntersection.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, type RefObject } from 'react'; + +interface UseIntersectionOptions extends IntersectionObserverInit { + disabled: boolean; +} + +export function useIntersection( + callback: () => void, + ref: RefObject, + options?: UseIntersectionOptions, +) { + const observer = useRef(null); + + useEffect(() => { + if (options?.disabled || !ref.current) return; + + const rect = ref.current.getBoundingClientRect(); + const visible = !(rect.bottom < 0 || rect.top - window.innerHeight >= 0); + + if (visible) callback(); + + observer.current = new IntersectionObserver(callback, options); + observer.current.observe(ref.current); + + return () => observer.current!.disconnect(); + }, []); + + return observer; +} diff --git a/packages/website-ue/src/pages/[...locale]/avatars/_index.tsx b/packages/website-ue/src/pages/[...locale]/avatars/_index.tsx index 117b1e068..1ee105ce3 100644 --- a/packages/website-ue/src/pages/[...locale]/avatars/_index.tsx +++ b/packages/website-ue/src/pages/[...locale]/avatars/_index.tsx @@ -3,6 +3,7 @@ import { SellableComponent } from '@protos/blitz_static_sellable_component'; import { StuffUIComponent } from '@protos/blitz_static_stuff_ui_component'; import { BlitzkitAllAvatarsComponent } from '@protos/blitzkit_static_all_avatars_component'; import { Grid } from '@radix-ui/themes'; +import { times } from 'lodash-es'; import { CatalogItemAccessor } from 'packages/core/src/blitz/catalogItemAccessor'; import { AvatarGroup } from 'packages/website-ue/src/components/Avatars/Group'; import { PageWrapper } from 'packages/website-ue/src/components/PageWrapper'; @@ -12,7 +13,8 @@ import { useLocale, type LocaleAcceptorProps, } from 'packages/website-ue/src/hooks/useLocale'; -import { useMemo } from 'react'; +import type { MaybeSkeletonComponentProps } from 'packages/website-ue/src/types/maybeSkeletonComponentProps'; +import { useMemo, useState } from 'react'; const allAvatars = await metadata .get('BlitzkitAllAvatarsEntity.blitzkit_all_avatars') @@ -30,7 +32,10 @@ export function Page({ localeData }: LocaleAcceptorProps) { ); } -function Content() { +const DEFAULT_LOADED = 42; +const PREVIEW_COUNT = 28; + +function Content({ skeleton }: MaybeSkeletonComponentProps) { const { gameStrings } = useLocale(); const groups = useMemo(() => { const avatars = allAvatars @@ -67,8 +72,10 @@ function Content() { } } - return [...groups]; + return [...groups].map(([name, avatars]) => ({ name, avatars })); }, []); + const [loadedAvatars, setLoadedAvatars] = useState(DEFAULT_LOADED); + const filtered = useMemo(() => groups, []); return ( @@ -82,9 +89,22 @@ function Content() { gapY="6" flexGrow="1" > - {groups.map(([name, avatars]) => ( - + {filtered.slice(0, loadedAvatars).map((props) => ( + ))} + + {times( + skeleton + ? DEFAULT_LOADED + PREVIEW_COUNT + : Math.min(PREVIEW_COUNT, filtered.length - loadedAvatars), + (index) => ( + setLoadedAvatars((state) => state + 2)} + /> + ), + )} ); diff --git a/packages/website-ue/src/types/maybeSkeletonComponentProps.ts b/packages/website-ue/src/types/maybeSkeletonComponentProps.ts new file mode 100644 index 000000000..381d72dfa --- /dev/null +++ b/packages/website-ue/src/types/maybeSkeletonComponentProps.ts @@ -0,0 +1,8 @@ +export type MaybeSkeletonComponentProps = + | { + skeleton: true; + onIntersection?: () => void; + } + | (T & { + skeleton?: false; + });