mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 20:19:49 +02:00
feat: skeletons
This commit is contained in:
1 parent
c5f156eecc
commit
354aca5057
9 files changed
+270
-173
No files matched your search
@@ -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<Grade, BadgeProps['color']>;
|
||||
@@ -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<AvatarGroupProps>) {
|
||||
const { strings } = useLocale();
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
|
||||
useIntersection(() => props.skeleton && props.onIntersection?.(), container, {
|
||||
disabled: !props.skeleton || props.onIntersection === undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={container}
|
||||
direction="column"
|
||||
gap="3"
|
||||
style={{ cursor: props.skeleton ? undefined : 'pointer' }}
|
||||
{...props}
|
||||
>
|
||||
<Box width="100%" style={{ aspectRatio: '7 / 8' }} position="relative">
|
||||
{props.skeleton && <Skeleton height="100%" />}
|
||||
|
||||
{!props.skeleton && (
|
||||
<Stack avatars={[...props.avatars].slice(-MAX_AVATARS_PREVIEW)} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
size="2"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontStyle:
|
||||
!props.skeleton && props.name === undefined ? 'italic' : undefined,
|
||||
}}
|
||||
>
|
||||
{props.skeleton ? (
|
||||
<InlineSkeleton width="100%" />
|
||||
) : (
|
||||
(props.name ?? strings.website.tools.avatars.unnamed_avatars)
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -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<AvatarGroupProps>,
|
||||
) {
|
||||
if (props.skeleton) return <Content {...props} />;
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
@@ -42,150 +32,3 @@ export function AvatarGroup(props: AvatarGroupProps) {
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
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<Grade, BadgeProps['color']>;
|
||||
|
||||
function Popup({ name, avatars }: AvatarGroupProps) {
|
||||
const { strings, gameStrings } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Title>{name}</Dialog.Title>
|
||||
|
||||
<Flex direction="column" gap="3" mt="5">
|
||||
{avatars.map(({ avatar, stuff }, index) => (
|
||||
<Flex key={index} gap="5">
|
||||
<Box
|
||||
m="1"
|
||||
mb="4"
|
||||
height="10rem"
|
||||
style={{
|
||||
aspectRatio: '7 / 8',
|
||||
borderRadius: Var('radius-4'),
|
||||
outline: `${Var('space-1')} solid ${Var(`${GRADE_COLORS[stuff.grade]}-9`)}`,
|
||||
boxShadow: `0 0 ${
|
||||
1.5 * (stuff.grade / Grade.GRADE_LEGENDARY)
|
||||
}rem ${Var(`${GRADE_COLORS[stuff.grade]}-9`)}`,
|
||||
backgroundImage: `url(${avatar.avatar})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
position="relative"
|
||||
>
|
||||
<Badge
|
||||
mt="1"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
variant="solid"
|
||||
color={GRADE_COLORS[stuff.grade]}
|
||||
>
|
||||
<StarFilledIcon /> {strings.common.grades[stuff.grade]}
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<Flex direction="column" gap="3" justify="center">
|
||||
<Text>
|
||||
{gameStrings[stuff.description] ??
|
||||
(gameStrings[stuff.obtaining_methods]
|
||||
? undefined
|
||||
: strings.website.tools.avatars.no_description)}{' '}
|
||||
{gameStrings[stuff.obtaining_methods]}
|
||||
</Text>
|
||||
|
||||
<Flex>
|
||||
<Button variant="outline">
|
||||
<DownloadIcon /> {strings.website.tools.avatars.download}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Flex direction="column" gap="3" style={{ cursor: 'pointer' }} {...props}>
|
||||
<Box width="100%" style={{ aspectRatio: '7 / 8' }} position="relative">
|
||||
{avatarsReduced.map((avatar, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
position="absolute"
|
||||
top={`calc(${dx} * ${index})`}
|
||||
left={`calc(${dx} * ${index})`}
|
||||
width={`calc(100% - ${dx} * ${avatarsReduced.length - 1})`}
|
||||
height={`calc(100% - ${dx} * ${avatarsReduced.length - 1})`}
|
||||
style={{
|
||||
filter: `brightness(${(index + 1) / avatarsReduced.length})`,
|
||||
borderRadius: Var('radius-4'),
|
||||
boxShadow: Var('shadow-2'),
|
||||
backgroundImage: `url(${avatar.avatar.avatar})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{avatars.length > 1 && (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
background: `linear-gradient(-45deg, ${Var('black-a9')} 25%, ${Var('black-a1')} 50%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{avatars.length > 1 && (
|
||||
<Box position="absolute" bottom="1" right="1">
|
||||
<Badge variant="solid" color="gray" highContrast size="2">
|
||||
{literals(strings.common.units.times, [`${avatars.length}`])}
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
size="2"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontStyle: name === undefined ? 'italic' : undefined,
|
||||
}}
|
||||
>
|
||||
{name ?? strings.website.tools.avatars.unnamed_avatars}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Dialog.Title>{name}</Dialog.Title>
|
||||
|
||||
<Flex direction="column" gap="3" mt="5">
|
||||
{avatars.map(({ avatar, stuff }, index) => (
|
||||
<Flex key={index} gap="5">
|
||||
<Box
|
||||
m="1"
|
||||
mb="4"
|
||||
height="10rem"
|
||||
style={{
|
||||
aspectRatio: '7 / 8',
|
||||
borderRadius: Var('radius-4'),
|
||||
outline: `${Var('space-1')} solid ${Var(`${GRADE_COLORS[stuff.grade]}-9`)}`,
|
||||
boxShadow: `0 0 ${
|
||||
1.5 * (stuff.grade / Grade.GRADE_LEGENDARY)
|
||||
}rem ${Var(`${GRADE_COLORS[stuff.grade]}-9`)}`,
|
||||
backgroundImage: `url(${avatar.avatar})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
position="relative"
|
||||
>
|
||||
<Badge
|
||||
mt="1"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
variant="solid"
|
||||
color={GRADE_COLORS[stuff.grade]}
|
||||
>
|
||||
<StarFilledIcon /> {strings.common.grades[stuff.grade]}
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<Flex direction="column" gap="3" justify="center">
|
||||
<Text>
|
||||
{gameStrings[stuff.description] ??
|
||||
(gameStrings[stuff.obtaining_methods]
|
||||
? undefined
|
||||
: strings.website.tools.avatars.no_description)}{' '}
|
||||
{gameStrings[stuff.obtaining_methods]}
|
||||
</Text>
|
||||
|
||||
<Flex>
|
||||
<Button variant="outline">
|
||||
<DownloadIcon /> {strings.website.tools.avatars.download}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<Box
|
||||
key={index}
|
||||
position="absolute"
|
||||
top={`calc(${dx} * ${index})`}
|
||||
left={`calc(${dx} * ${index})`}
|
||||
width={`calc(100% - ${dx} * ${avatars.length - 1})`}
|
||||
height={`calc(100% - ${dx} * ${avatars.length - 1})`}
|
||||
style={{
|
||||
filter: `brightness(${(index + 1) / avatars.length})`,
|
||||
borderRadius: Var('radius-4'),
|
||||
boxShadow: Var('shadow-2'),
|
||||
backgroundImage: `url(${avatar.avatar.avatar})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{avatars.length > 1 && (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
background: `linear-gradient(-45deg, ${Var('black-a9')} 25%, ${Var('black-a1')} 50%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{avatars.length > 1 && (
|
||||
<Box position="absolute" bottom="1" right="1">
|
||||
<Badge variant="solid" color="gray" highContrast size="2">
|
||||
{literals(strings.common.units.times, [`${avatars.length}`])}
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Skeleton, type SkeletonProps } from '@radix-ui/themes';
|
||||
|
||||
export function InlineSkeleton({ className, ...props }: SkeletonProps) {
|
||||
return (
|
||||
<Skeleton style={{ display: 'inline-block' }} height="1em" {...props} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
|
||||
interface UseIntersectionOptions extends IntersectionObserverInit {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function useIntersection(
|
||||
callback: () => void,
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
options?: UseIntersectionOptions,
|
||||
) {
|
||||
const observer = useRef<IntersectionObserver>(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;
|
||||
}
|
||||
@@ -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 (
|
||||
<PageWrapper color="amber">
|
||||
@@ -82,9 +89,22 @@ function Content() {
|
||||
gapY="6"
|
||||
flexGrow="1"
|
||||
>
|
||||
{groups.map(([name, avatars]) => (
|
||||
<AvatarGroup key={`${name}`} name={name} avatars={avatars} />
|
||||
{filtered.slice(0, loadedAvatars).map((props) => (
|
||||
<AvatarGroup key={`${props.name}`} {...props} />
|
||||
))}
|
||||
|
||||
{times(
|
||||
skeleton
|
||||
? DEFAULT_LOADED + PREVIEW_COUNT
|
||||
: Math.min(PREVIEW_COUNT, filtered.length - loadedAvatars),
|
||||
(index) => (
|
||||
<AvatarGroup
|
||||
key={index}
|
||||
skeleton
|
||||
onIntersection={() => setLoadedAvatars((state) => state + 2)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Grid>
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type MaybeSkeletonComponentProps<T = {}> =
|
||||
| {
|
||||
skeleton: true;
|
||||
onIntersection?: () => void;
|
||||
}
|
||||
| (T & {
|
||||
skeleton?: false;
|
||||
});
|
||||
Reference in new issue
Block a user