feat: download

This commit is contained in:
TrèsAbhi committed 2025-06-03 14:00:53 -05:00
1 parent 354aca5057
commit 2dc4f12246
17 files changed
+203 -66

No files matched your search

+1
View File
@@ -192,6 +192,7 @@
"TYPELESS", "TYPELESS",
"ubjson", "ubjson",
"Uncompiled", "Uncompiled",
"undiscriminated",
"Unicum", "Unicum",
"UNLOCALIZED", "UNLOCALIZED",
"UNORM", "UNORM",
-1
View File
@@ -41,7 +41,6 @@
"satori": "^0.10.14", "satori": "^0.10.14",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"discord.js": "^14.16.3", "discord.js": "^14.16.3",
"fuzzysort": "^2.0.4",
"bepaint": "^0.1.0-beta.3", "bepaint": "^0.1.0-beta.3",
"markdown-escape": "^2.0.0", "markdown-escape": "^2.0.0",
"@types/markdown-escape": "^1.1.3", "@types/markdown-escape": "^1.1.3",
BIN
View File
Binary file not shown.
@@ -22,6 +22,10 @@ export class CatalogItemAccessor {
} }
} }
undiscriminatedId() {
return this.id.split('.').slice(1).join('.');
}
static fromComponent(id: string, component: Any) { static fromComponent(id: string, component: Any) {
return this.fromComponents(id, [component]); return this.fromComponents(id, [component]);
} }
+24 -6
View File
@@ -1,9 +1,12 @@
import { LocalizationResourcesComponent } from '@protos/blitz_static_localization_resources_component'; import { LocalizationResourcesComponent } from '@protos/blitz_static_localization_resources_component';
import { ProfileAvatarComponent } from '@protos/blitz_static_profile_avatar_component';
import { RemoteStorageComponent } from '@protos/blitz_static_remote_storage_component'; import { RemoteStorageComponent } from '@protos/blitz_static_remote_storage_component';
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 { BlitzkitAllAvatarsComponent } from '@protos/blitzkit_static_all_avatars_component';
import { load } from 'js-yaml'; import { load } from 'js-yaml';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
import { CatalogItemAccessor } from '../catalogItemAccessor'; import { CatalogItemAccessor } from 'packages/core/src/blitz/catalogItemAccessor';
interface LocalizationConfig { interface LocalizationConfig {
namespaces: string[]; namespaces: string[];
@@ -102,11 +105,26 @@ export abstract class MetadataAccessor {
} }
injectBlitzkitProfileAvatars() { injectBlitzkitProfileAvatars() {
const allAvatars: BlitzkitAllAvatarsComponent = { const allAvatars: BlitzkitAllAvatarsComponent = { avatars: [] };
avatars: this.filter((item) =>
item.startsWith('ProfileAvatarEntity.'), for (const item of this.filter((item) =>
).map((avatar) => avatar.pack()), item.startsWith('ProfileAvatarEntity.'),
}; )) {
const stuff = item.get(StuffUIComponent, 'UIComponent');
const avatar = item.get(ProfileAvatarComponent, 'profileAvatarComponent');
const sellable = item.getOptional(SellableComponent, 'sellableComponent');
allAvatars.avatars.push({
id: item.undiscriminatedId(),
name: stuff.display_name,
obtaining: stuff.obtaining_methods,
description: stuff.description,
grade: stuff.grade,
category: avatar.category,
hidden_if_not_obtained: avatar.hidden_if_not_obtained,
sale: sellable?.reward,
});
}
this.add( this.add(
CatalogItemAccessor.fromComponent( CatalogItemAccessor.fromComponent(
@@ -16,7 +16,6 @@ export class SocketMetadataAccessor extends MetadataAccessor {
async get(item: string) { async get(item: string) {
if (item in this.items) return new CatalogItemAccessor(this.items[item]); if (item in this.items) return new CatalogItemAccessor(this.items[item]);
throw new Error(`Item ${item} does not exist in socket metadata`); throw new Error(`Item ${item} does not exist in socket metadata`);
} }
+3 -1
View File
@@ -1270,14 +1270,16 @@
"no_description": "This avatar has no description.", "no_description": "This avatar has no description.",
"download": "Download", "download": "Download",
"enlarge": "Enlarge",
"hidden": "Hidden", "hidden": "Hidden",
"unnamed_avatars": "Unnamed avatars", "unnamed_avatars": "Unnamed avatars",
"search": { "search": {
"hint": "Search avatars...", "hint": "Search avatars...",
"results": "%s1 avatars" "results": "%s1 avatars"
}, },
"categories": { "categories": {
"0": "Unspecified category", "0": "Unspecified",
"1": "Premium", "1": "Premium",
"2": "Special", "2": "Special",
"3": "Accumulative", "3": "Accumulative",
+2
View File
@@ -26,12 +26,14 @@
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/three": "^0.160.0", "@types/three": "^0.160.0",
"astro": "^5.8.0", "astro": "^5.8.0",
"fuzzysort": "^3.1.0",
"immer": "^10.1.1", "immer": "^10.1.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"semver": "^7.7.2", "semver": "^7.7.2",
"sharp": "^0.34.2",
"three": "^0.169.0", "three": "^0.169.0",
"zustand": "^4.5.5" "zustand": "^4.5.5"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,6 +1,4 @@
import { type ProfileAvatarComponent } from '@protos/blitz_static_profile_avatar_component'; import type { BlitzKitAvatar } from '@protos/blitzkit_static_all_avatars_component';
import type { SellableComponent } from '@protos/blitz_static_sellable_component';
import type { StuffUIComponent } from '@protos/blitz_static_stuff_ui_component';
import { Dialog } from '@radix-ui/themes'; import { Dialog } from '@radix-ui/themes';
import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from '../../types/maybeSkeletonComponentProps';
import { Content } from './Content'; import { Content } from './Content';
@@ -8,11 +6,7 @@ import { Popup } from './Popup';
export interface AvatarGroupProps { export interface AvatarGroupProps {
name?: string; name?: string;
avatars: { avatars: { avatar: BlitzKitAvatar }[];
stuff: StuffUIComponent;
avatar: ProfileAvatarComponent;
sellable?: SellableComponent;
}[];
} }
export function AvatarGroup( export function AvatarGroup(
@@ -1,6 +1,12 @@
import { Grade } from '@protos/blitz_static_standard_grades_enum'; import { Grade } from '@protos/blitz_static_standard_grades_enum';
import { DownloadIcon, StarFilledIcon } from '@radix-ui/react-icons'; import {
import { Badge, Box, Button, Dialog, Flex, Text } from '@radix-ui/themes'; CardStackIcon,
DownloadIcon,
ExternalLinkIcon,
EyeNoneIcon,
StarFilledIcon,
} from '@radix-ui/react-icons';
import { Badge, Box, Button, Dialog, Flex, Link, Text } from '@radix-ui/themes';
import { Var } from '../../core/radix/var'; import { Var } from '../../core/radix/var';
import { useLocale } from '../../hooks/useLocale'; import { useLocale } from '../../hooks/useLocale';
import { GRADE_COLORS } from './Colors'; import { GRADE_COLORS } from './Colors';
@@ -13,8 +19,8 @@ export function Popup({ name, avatars }: AvatarGroupProps) {
<> <>
<Dialog.Title>{name}</Dialog.Title> <Dialog.Title>{name}</Dialog.Title>
<Flex direction="column" gap="3" mt="5"> <Flex direction="column" gap="5" mt="5">
{avatars.map(({ avatar, stuff }, index) => ( {[...avatars].reverse().map(({ avatar }, index) => (
<Flex key={index} gap="5"> <Flex key={index} gap="5">
<Box <Box
m="1" m="1"
@@ -23,16 +29,27 @@ export function Popup({ name, avatars }: AvatarGroupProps) {
style={{ style={{
aspectRatio: '7 / 8', aspectRatio: '7 / 8',
borderRadius: Var('radius-4'), borderRadius: Var('radius-4'),
outline: `${Var('space-1')} solid ${Var(`${GRADE_COLORS[stuff.grade]}-9`)}`, outline: `${Var('space-1')} solid ${Var(`${GRADE_COLORS[avatar.grade]}-9`)}`,
boxShadow: `0 0 ${ boxShadow: `0 0 ${
1.5 * (stuff.grade / Grade.GRADE_LEGENDARY) 1.5 * (avatar.grade / Grade.GRADE_LEGENDARY)
}rem ${Var(`${GRADE_COLORS[stuff.grade]}-9`)}`, }rem ${Var(`${GRADE_COLORS[avatar.grade]}-9`)}`,
backgroundImage: `url(${avatar.avatar})`, backgroundImage: 'url(/assets/sad-ghost.png)',
backgroundSize: 'cover', backgroundRepeat: 'no-repeat',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundSize: '3rem',
}} }}
position="relative" position="relative"
> >
<Box
width="100%"
height="100%"
style={{
backgroundImage: `url(/api/avatars/${avatar.id}.webp)`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<Badge <Badge
mt="1" mt="1"
style={{ style={{
@@ -42,25 +59,54 @@ export function Popup({ name, avatars }: AvatarGroupProps) {
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
}} }}
variant="solid" variant="solid"
color={GRADE_COLORS[stuff.grade]} color={GRADE_COLORS[avatar.grade]}
> >
<StarFilledIcon /> {strings.common.grades[stuff.grade]} <StarFilledIcon /> {strings.common.grades[avatar.grade]}
</Badge> </Badge>
</Box> </Box>
<Flex direction="column" gap="3" justify="center"> <Flex direction="column" gap="3" justify="center">
<Flex gap="1">
<Badge color="gray" variant="surface">
<CardStackIcon />
{strings.website.tools.avatars.categories[avatar.category]}
</Badge>
{avatar.hidden_if_not_obtained && (
<Badge color="gray" variant="surface">
<EyeNoneIcon /> {strings.website.tools.avatars.hidden}
</Badge>
)}
{avatar.sale && (
<Badge>
{JSON.stringify(
avatar.sale.reward_list[0].currency_reward?.amount,
)}
</Badge>
)}
</Flex>
<Text> <Text>
{gameStrings[stuff.description] ?? {gameStrings[avatar.description] ??
(gameStrings[stuff.obtaining_methods] (gameStrings[avatar.obtaining]
? undefined ? undefined
: strings.website.tools.avatars.no_description)}{' '} : strings.website.tools.avatars.no_description)}{' '}
{gameStrings[stuff.obtaining_methods]} {gameStrings[avatar.obtaining]}
</Text> </Text>
<Flex> <Flex gap="2">
<Button variant="outline"> <Link href={`/api/avatars/${avatar.id}.webp`} download>
<DownloadIcon /> {strings.website.tools.avatars.download} <Button variant="solid">
</Button> <DownloadIcon /> {strings.website.tools.avatars.download}
</Button>
</Link>
<Link href={`/api/avatars/${avatar.id}.webp`} target="_blank">
<Button variant="outline">
<ExternalLinkIcon /> {strings.website.tools.avatars.enlarge}
</Button>
</Link>
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>
@@ -11,7 +11,7 @@ export function Stack({ avatars }: { avatars: AvatarGroupProps['avatars'] }) {
return ( return (
<> <>
{avatars.map((avatar, index) => ( {avatars.map(({ avatar }, index) => (
<Box <Box
key={index} key={index}
position="absolute" position="absolute"
@@ -23,12 +23,24 @@ export function Stack({ avatars }: { avatars: AvatarGroupProps['avatars'] }) {
filter: `brightness(${(index + 1) / avatars.length})`, filter: `brightness(${(index + 1) / avatars.length})`,
borderRadius: Var('radius-4'), borderRadius: Var('radius-4'),
boxShadow: Var('shadow-2'), boxShadow: Var('shadow-2'),
backgroundImage: `url(${avatar.avatar.avatar})`, overflow: 'hidden',
backgroundSize: 'cover', backgroundImage: 'url(/assets/sad-ghost.png)',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundSize: '3rem',
}} }}
> >
{avatars.length > 1 && ( <Box
width="100%"
height="100%"
style={{
backgroundImage: `url(/api/avatars/${avatar.id}.webp)`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
{avatars.length > 1 && index === avatars.length - 1 && (
<Box <Box
width="100%" width="100%"
height="100%" height="100%"
@@ -1,5 +1,5 @@
import { RemoteMetadataAccessor } from 'packages/core/src'; import { RemoteMetadataAccessor } from 'packages/core/src';
export const remoteMetadata = new RemoteMetadataAccessor( export const remoteMetadata = new RemoteMetadataAccessor(
'/api/metadata/catalog', '/api/internal/catalog',
); );
@@ -1,10 +1,8 @@
import { ProfileAvatarComponent } from '@protos/blitz_static_profile_avatar_component';
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 { BlitzkitAllAvatarsComponent } from '@protos/blitzkit_static_all_avatars_component';
import { Grid } from '@radix-ui/themes'; import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { times } from 'lodash-es'; import { Flex, Grid, Spinner, TextField } from '@radix-ui/themes';
import { CatalogItemAccessor } from 'packages/core/src/blitz/catalogItemAccessor'; import fuzzysort from 'fuzzysort';
import { debounce, times } from 'lodash-es';
import { AvatarGroup } from 'packages/website-ue/src/components/Avatars/Group'; import { AvatarGroup } from 'packages/website-ue/src/components/Avatars/Group';
import { PageWrapper } from 'packages/website-ue/src/components/PageWrapper'; import { PageWrapper } from 'packages/website-ue/src/components/PageWrapper';
import { metadata } from 'packages/website-ue/src/core/blitz/metadata'; import { metadata } from 'packages/website-ue/src/core/blitz/metadata';
@@ -14,7 +12,7 @@ import {
type LocaleAcceptorProps, type LocaleAcceptorProps,
} from 'packages/website-ue/src/hooks/useLocale'; } from 'packages/website-ue/src/hooks/useLocale';
import type { MaybeSkeletonComponentProps } from 'packages/website-ue/src/types/maybeSkeletonComponentProps'; import type { MaybeSkeletonComponentProps } from 'packages/website-ue/src/types/maybeSkeletonComponentProps';
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
const allAvatars = await metadata const allAvatars = await metadata
.get('BlitzkitAllAvatarsEntity.blitzkit_all_avatars') .get('BlitzkitAllAvatarsEntity.blitzkit_all_avatars')
@@ -36,25 +34,17 @@ const DEFAULT_LOADED = 42;
const PREVIEW_COUNT = 28; const PREVIEW_COUNT = 28;
function Content({ skeleton }: MaybeSkeletonComponentProps) { function Content({ skeleton }: MaybeSkeletonComponentProps) {
const { gameStrings } = useLocale(); const { strings, gameStrings } = useLocale();
const input = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const groups = useMemo(() => { const groups = useMemo(() => {
const avatars = allAvatars const avatars = allAvatars
.map((id) => { .map((avatar) => ({
const item = new CatalogItemAccessor(id); avatar,
const stuff = item.get(StuffUIComponent, 'UIComponent'); name: gameStrings[avatar.name] as string | undefined,
const avatar = item.get( }))
ProfileAvatarComponent, .sort((a, b) => a.avatar.grade - b.avatar.grade)
'profileAvatarComponent',
);
const sellable = item.getOptional(
SellableComponent,
'sellableComponent',
);
const name = gameStrings[stuff.display_name] as string | undefined;
return { avatar, name, stuff, sellable };
})
.sort((a, b) => a.stuff.grade - b.stuff.grade)
.sort((a, b) => .sort((a, b) =>
a.name === undefined a.name === undefined
? 1 ? 1
@@ -75,10 +65,50 @@ function Content({ skeleton }: MaybeSkeletonComponentProps) {
return [...groups].map(([name, avatars]) => ({ name, avatars })); return [...groups].map(([name, avatars]) => ({ name, avatars }));
}, []); }, []);
const [loadedAvatars, setLoadedAvatars] = useState(DEFAULT_LOADED); const [loadedAvatars, setLoadedAvatars] = useState(DEFAULT_LOADED);
const filtered = useMemo(() => groups, []); const filtered = useMemo(() => {
setSearching(false);
if (search === null || search.trim().length === 0) return groups;
return fuzzysort
.go(search, groups, { key: 'name' })
.map((result) => result.obj);
}, [search]);
const requestSearch = useCallback(
debounce(() => {
if (!input.current) return;
setSearch(input.current.value);
}, 500),
[],
);
return ( return (
<PageWrapper color="amber"> <PageWrapper color="amber">
<Flex justify="center">
<TextField.Root
ref={input}
my="7"
placeholder={strings.website.tools.avatars.search.hint}
style={{
flex: 1,
maxWidth: '30rem',
}}
onChange={(event) => {
if (event.target.value.trim().length === 0) {
setSearching(false);
return;
}
requestSearch();
setSearching(true);
}}
>
<TextField.Slot>
{searching ? <Spinner /> : <MagnifyingGlassIcon />}
</TextField.Slot>
</TextField.Root>
</Flex>
<Grid <Grid
columns={{ columns={{
initial: 'repeat(auto-fill, minmax(6rem, 1fr))', initial: 'repeat(auto-fill, minmax(6rem, 1fr))',
@@ -0,0 +1,30 @@
import { ProfileAvatarComponent } from '@protos/blitz_static_profile_avatar_component';
import type { APIRoute, GetStaticPaths, GetStaticPathsItem } from 'astro';
import { socketMetadata } from 'packages/website-ue/src/core/blitz/metadata.socket';
export const getStaticPaths: GetStaticPaths = async () => {
const paths: GetStaticPathsItem[] = [];
for (const item in socketMetadata.items) {
if (!item.startsWith('ProfileAvatarEntity.')) continue;
const id = await socketMetadata
.get(item)
.then((item) => item.undiscriminatedId());
paths.push({ params: { id } });
}
return paths;
};
export const GET: APIRoute<{}, { id: string }> = async ({ params }) => {
const item = await socketMetadata.get(`ProfileAvatarEntity.${params.id}`);
const avatar = item.get(ProfileAvatarComponent, 'profileAvatarComponent');
try {
return await fetch(avatar.avatar);
} catch {
return new Response(null, { status: 404 });
}
};