mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-10-24 20:19:49 +02:00
feat: groups
This commit is contained in:
1 parent
a7f93698a6
commit
c0c6a21e43
4 files changed
+85
-36
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;
|
||||||
|
|
||||||
|
.avatar-entry {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
border-radius: var(--space-3);
|
border-radius: var(--space-3);
|
||||||
|
box-shadow: var(--shadow-interactive), var(--shadow-aggressive);
|
||||||
|
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
}
|
||||||
box-shadow: var(--shadow-interactive), var(--shadow-aggressive);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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(
|
||||||
|
skeleton
|
||||||
|
? initial + intermediate
|
||||||
|
: Math.min(intermediate, data.length - count),
|
||||||
|
(index) => (
|
||||||
<Item key={index} rate={rate} setCount={setCount}>
|
<Item key={index} rate={rate} setCount={setCount}>
|
||||||
<Component skeleton />
|
<Component skeleton />
|
||||||
</Item>
|
</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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user