feat: groups

This commit is contained in:
TrèsAbhi committed 2026-01-14 13:58:53 -06:00
1 parent a7f93698a6
commit c0c6a21e43
4 files changed
+91 -42

No files matched your search

@@ -4,15 +4,20 @@
align-items: center; align-items: center;
gap: var(--space-4); gap: var(--space-4);
.avatar-image { .avatar-series {
position: relative;
width: 100%; width: 100%;
aspect-ratio: 7 / 8; aspect-ratio: 7 / 8;
border-radius: var(--space-3);
background-size: cover; .avatar-entry {
background-position: center; position: absolute;
box-shadow: var(--shadow-interactive), var(--shadow-aggressive); border-radius: var(--space-3);
box-shadow: var(--shadow-interactive), var(--shadow-aggressive);
background-size: cover;
background-position: center;
}
} }
.avatar-label { .avatar-label {
@@ -1,27 +1,50 @@
import { useGameStrings } from "../../hooks/useGameStrings";
import type { Avatar } from "../../protos/avatar"; import type { Avatar } from "../../protos/avatar";
import type { SkeletonProps } from "../../types/SkeletonProps"; import type { SkeletonProps } from "../../types/SkeletonProps";
import { Text } from "../Text"; import { Text } from "../Text";
import "./index.css"; import "./index.css";
export function AvatarCard(props: SkeletonProps<Avatar>) { interface Props {
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity"); name: string;
avatars: Avatar[];
}
const MAX_CARDS = 3;
const CARD_TRIM = "var(--space-3)";
export function AvatarCard(props: SkeletonProps<Props>) {
return ( return (
<div className="avatar-card"> <div className="avatar-card">
<div {!props.skeleton && <Series avatars={props.avatars} />}
className="avatar-image"
style={{
backgroundImage: props.skeleton
? undefined
: `url(/api/avatars/${props.name}.webp)`,
}}
/>
<Text align="center" className="avatar-label"> <Text align="center" className="avatar-label">
{props.skeleton {props.skeleton ? "LOADING" : props.name}
? "LOADING"
: profileAvatarEntityStrings[props.stuff_ui!.display_name]}
</Text> </Text>
</div> </div>
); );
} }
interface SeriesProps {
avatars: Avatar[];
}
function Series({ avatars }: SeriesProps) {
const count = Math.min(avatars.length, MAX_CARDS);
return (
<div className="avatar-series">
{avatars.slice(-MAX_CARDS).map((avatar, index) => (
<div
className="avatar-entry"
style={{
filter: `brightness(${(index + 1) / count})`,
top: `calc(${index} * ${CARD_TRIM})`,
left: `calc(${index} * ${CARD_TRIM})`,
width: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
height: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
backgroundImage: `url(/api/avatars/${avatar.name}.webp)`,
}}
/>
))}
</div>
);
}
@@ -5,9 +5,10 @@ import "./index.css";
export interface IncrementalLoaderProps<Props> { export interface IncrementalLoaderProps<Props> {
initial: number; initial: number;
skeleton: number; intermediate: number;
rate?: number; rate?: number;
skeleton?: boolean;
data: Props[]; data: Props[];
Component: (props: SkeletonProps<Props>) => ReactNode; Component: (props: SkeletonProps<Props>) => ReactNode;
} }
@@ -17,6 +18,7 @@ export function IncrementalLoader<Props>({
rate = 2, rate = 2,
data, data,
skeleton, skeleton,
intermediate,
Component, Component,
}: IncrementalLoaderProps<Props>) { }: IncrementalLoaderProps<Props>) {
const [count, setCount] = useState(initial); const [count, setCount] = useState(initial);
@@ -24,15 +26,19 @@ export function IncrementalLoader<Props>({
return ( return (
<> <>
{sliced.map((item, index) => ( {!skeleton &&
<Component key={index} {...item} /> sliced.map((item, index) => <Component key={index} {...item} />)}
))}
{times(Math.min(skeleton, data.length - count), (index) => ( {times(
<Item key={index} rate={rate} setCount={setCount}> skeleton
<Component skeleton /> ? initial + intermediate
</Item> : Math.min(intermediate, data.length - count),
))} (index) => (
<Item key={index} rate={rate} setCount={setCount}>
<Component skeleton />
</Item>
)
)}
</> </>
); );
} }
@@ -5,6 +5,7 @@ import { api } from "../../../core/api/dynamic";
import { useAwait } from "../../../hooks/useAwait"; import { useAwait } from "../../../hooks/useAwait";
import { useGameStrings } from "../../../hooks/useGameStrings"; import { useGameStrings } from "../../../hooks/useGameStrings";
import { LocaleProvider, useLocale } from "../../../hooks/useLocale"; import { LocaleProvider, useLocale } from "../../../hooks/useLocale";
import type { Avatar } from "../../../protos/avatar";
interface PageProps extends ContentProps { interface PageProps extends ContentProps {
locale: string; locale: string;
@@ -26,26 +27,40 @@ function Content({ skeleton }: ContentProps) {
const locale = useLocale(); const locale = useLocale();
const { avatars } = useAwait(() => api.avatars(), "avatars"); const { avatars } = useAwait(() => api.avatars(), "avatars");
const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity"); const profileAvatarEntityStrings = useGameStrings("ProfileAvatarEntity");
const ordered = useMemo( const groups = useMemo(() => {
() => const groups = new Map<string, Avatar[]>();
avatars.sort((a, b) => {
const nameA =
profileAvatarEntityStrings[a.stuff_ui!.display_name] ??
a.stuff_ui!.display_name;
const nameB =
profileAvatarEntityStrings[b.stuff_ui!.display_name] ??
b.stuff_ui!.display_name;
return nameA.localeCompare(nameB, locale); for (const avatar of avatars) {
}), const name =
[] profileAvatarEntityStrings[avatar.stuff_ui!.display_name] ??
); avatar.stuff_ui!.display_name;
if (groups.has(name)) {
groups.get(name)!.push(avatar);
} else {
groups.set(name, [avatar]);
}
}
return groups;
}, []);
const ordered = useMemo(() => {
const array = Array.from(groups.entries());
return array
.sort((a, b) => a[0].localeCompare(b[0], locale))
.map(([name, avatars]) => ({
name,
avatars: avatars.sort((a, b) => {
return a.stuff_ui!.grade - b.stuff_ui!.grade;
}),
}));
}, []);
return ( return (
<div className="avatars"> <div className="avatars">
<IncrementalLoader <IncrementalLoader
initial={40} initial={40}
skeleton={10} intermediate={10}
data={ordered} data={ordered}
Component={AvatarCard} Component={AvatarCard}
/> />