feat: skeleton

This commit is contained in:
TrèsAbhi committed 2026-01-16 20:37:11 -06:00
1 parent f7c6fdd861
commit b50c47b744
11 files changed
+88 -28

No files matched your search

@@ -17,6 +17,13 @@
background-size: cover; background-size: cover;
background-position: center; background-position: center;
&.skeleton {
top: 0;
left: 0;
width: 100%;
height: 100%;
}
} }
.count { .count {
@@ -31,5 +38,10 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
width: 100%;
.skeleton {
width: 100%;
}
} }
} }
@@ -1,8 +1,10 @@
import { literals } from "@blitzkit/i18n"; import { literals } from "@blitzkit/i18n";
import { useStrings } from "../../hooks/useStrings"; import { useStrings } from "../../hooks/useStrings";
import type { Avatar } from "../../protos/avatar"; import type { Avatar } from "../../protos/avatar";
import type { SkeletonProps } from "../../types/SkeletonProps"; import type { PropsWithSkeleton } from "../../types/propsWithSkeleton";
import { Badge } from "../Badge"; import { Badge } from "../Badge";
import { InlineSkeleton } from "../InlineSkeleton";
import { Skeleton } from "../Skeleton";
import { Text } from "../Text"; import { Text } from "../Text";
import "./index.css"; import "./index.css";
@@ -14,13 +16,13 @@ interface Props {
const MAX_CARDS = 3; const MAX_CARDS = 3;
const CARD_TRIM = "var(--space-3)"; const CARD_TRIM = "var(--space-3)";
export function AvatarCard(props: SkeletonProps<Props>) { export function AvatarCard(props: PropsWithSkeleton<Props>) {
return ( return (
<div className="avatar-card"> <div className="avatar-card">
{!props.skeleton && <Series avatars={props.avatars} />} <Series skeleton={props.skeleton} {...props} />
<Text align="center" className="avatar-label"> <Text align="center" className="avatar-label">
{props.skeleton ? "LOADING" : props.name} {props.skeleton ? <InlineSkeleton /> : props.name}
</Text> </Text>
</div> </div>
); );
@@ -30,32 +32,35 @@ interface SeriesProps {
avatars: Avatar[]; avatars: Avatar[];
} }
function Series({ avatars }: SeriesProps) { function Series(props: PropsWithSkeleton<SeriesProps>) {
const count = Math.min(avatars.length, MAX_CARDS); const count = Math.min(props.skeleton ? 0 : props.avatars.length, MAX_CARDS);
const strings = useStrings(); const strings = useStrings();
return ( return (
<div className="avatar-series"> <div className="avatar-series">
{avatars.slice(-MAX_CARDS).map((avatar, index) => ( {!props.skeleton &&
<div props.avatars.slice(-MAX_CARDS).map((avatar, index) => (
key={avatar.name} <div
className="avatar-entry" key={avatar.name}
style={{ className="avatar-entry"
filter: `brightness(${(index + 1) / count})`, style={{
top: `calc(${index} * ${CARD_TRIM})`, filter: `brightness(${(index + 1) / count})`,
left: `calc(${index} * ${CARD_TRIM})`, top: `calc(${index} * ${CARD_TRIM})`,
width: `calc(100% - ${CARD_TRIM} * ${count - 1})`, left: `calc(${index} * ${CARD_TRIM})`,
height: `calc(100% - ${CARD_TRIM} * ${count - 1})`, width: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
backgroundImage: `url(/api/avatars/${avatar.name}.webp)`, height: `calc(100% - ${CARD_TRIM} * ${count - 1})`,
}} backgroundImage: `url(/api/avatars/${avatar.name}.webp)`,
/> }}
))} />
))}
{avatars.length > 1 && ( {!props.skeleton && props.avatars.length > 1 && (
<Badge color="gray" highContrast className="count"> <Badge color="gray" highContrast className="count">
{literals(strings.units.x, { value: avatars.length })} {literals(strings.units.x, { value: props.avatars.length })}
</Badge> </Badge>
)} )}
{props.skeleton && <Skeleton className="avatar-entry" />}
</div> </div>
); );
} }
@@ -1,5 +1,5 @@
.avatars-list { .avatars-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--space-7), 1fr)); grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
gap: var(--space-5) var(--space-4); gap: var(--space-5) var(--space-4);
} }
@@ -75,8 +75,8 @@ export function AvatarsList() {
return ( return (
<div className="avatars-list"> <div className="avatars-list">
<IncrementalLoader <IncrementalLoader
initial={40} initial={9 * 5}
intermediate={10} intermediate={9 * 3}
data={filtered} data={filtered}
Component={AvatarCard} Component={AvatarCard}
/> />
@@ -80,5 +80,6 @@ import "@fontsource-variable/roboto-flex";
--blur-tint: blur(8rem); --blur-tint: blur(8rem);
--duration-brief: 200ms; --duration-brief: 200ms;
--duration-subtle: 2s;
} }
</style> </style>
@@ -1,6 +1,6 @@
import { times } from "lodash-es"; import { times } from "lodash-es";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import type { SkeletonProps } from "../../types/SkeletonProps"; import type { PropsWithSkeleton } from "../../types/propsWithSkeleton";
import "./index.css"; import "./index.css";
export interface IncrementalLoaderProps<Props extends { key: string }> { export interface IncrementalLoaderProps<Props extends { key: string }> {
@@ -10,7 +10,7 @@ export interface IncrementalLoaderProps<Props extends { key: string }> {
skeleton?: boolean; skeleton?: boolean;
data: Props[]; data: Props[];
Component: (props: SkeletonProps<Omit<Props, "key">>) => ReactNode; Component: (props: PropsWithSkeleton<Omit<Props, "key">>) => ReactNode;
} }
export function IncrementalLoader<Props extends { key: string }>({ export function IncrementalLoader<Props extends { key: string }>({
@@ -0,0 +1,5 @@
.inline-skeleton {
display: inline-block;
height: 1em;
}
@@ -0,0 +1,11 @@
import { classNames } from "../../core/ui/classNames";
import { Skeleton, type SkeletonProps } from "../Skeleton";
import "./index.css";
export interface InlineSkeletonProps extends SkeletonProps {}
export function InlineSkeleton({ className, ...props }: InlineSkeletonProps) {
return (
<Skeleton className={classNames("inline-skeleton", className)} {...props} />
);
}
@@ -0,0 +1,17 @@
@keyframes skeleton-animation {
0% {
background-color: var(--black-a8);
}
50% {
background-color: var(--black-a5);
}
100% {
background-color: var(--black-a8);
}
}
.skeleton {
animation: skeleton-animation var(--duration-subtle) ease infinite;
border-radius: var(--space-2);
}
@@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
import { classNames } from "../../core/ui/classNames";
import "./index.css";
export interface SkeletonProps extends ComponentProps<"div"> {}
export function Skeleton({ className, ...props }: SkeletonProps) {
return <div className={classNames("skeleton", className)} {...props} />;
}
@@ -1,3 +1,3 @@
export type SkeletonProps<Props> = export type PropsWithSkeleton<Props> =
| { skeleton: true } | { skeleton: true }
| (Props & { skeleton?: false }); | (Props & { skeleton?: false });