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-position: center;
&.skeleton {
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
.count {
@@ -31,5 +38,10 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
.skeleton {
width: 100%;
}
}
}
@@ -1,8 +1,10 @@
import { literals } from "@blitzkit/i18n";
import { useStrings } from "../../hooks/useStrings";
import type { Avatar } from "../../protos/avatar";
import type { SkeletonProps } from "../../types/SkeletonProps";
import type { PropsWithSkeleton } from "../../types/propsWithSkeleton";
import { Badge } from "../Badge";
import { InlineSkeleton } from "../InlineSkeleton";
import { Skeleton } from "../Skeleton";
import { Text } from "../Text";
import "./index.css";
@@ -14,13 +16,13 @@ interface Props {
const MAX_CARDS = 3;
const CARD_TRIM = "var(--space-3)";
export function AvatarCard(props: SkeletonProps<Props>) {
export function AvatarCard(props: PropsWithSkeleton<Props>) {
return (
<div className="avatar-card">
{!props.skeleton && <Series avatars={props.avatars} />}
<Series skeleton={props.skeleton} {...props} />
<Text align="center" className="avatar-label">
{props.skeleton ? "LOADING" : props.name}
{props.skeleton ? <InlineSkeleton /> : props.name}
</Text>
</div>
);
@@ -30,32 +32,35 @@ interface SeriesProps {
avatars: Avatar[];
}
function Series({ avatars }: SeriesProps) {
const count = Math.min(avatars.length, MAX_CARDS);
function Series(props: PropsWithSkeleton<SeriesProps>) {
const count = Math.min(props.skeleton ? 0 : props.avatars.length, MAX_CARDS);
const strings = useStrings();
return (
<div className="avatar-series">
{avatars.slice(-MAX_CARDS).map((avatar, index) => (
<div
key={avatar.name}
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)`,
}}
/>
))}
{!props.skeleton &&
props.avatars.slice(-MAX_CARDS).map((avatar, index) => (
<div
key={avatar.name}
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)`,
}}
/>
))}
{avatars.length > 1 && (
{!props.skeleton && props.avatars.length > 1 && (
<Badge color="gray" highContrast className="count">
{literals(strings.units.x, { value: avatars.length })}
{literals(strings.units.x, { value: props.avatars.length })}
</Badge>
)}
{props.skeleton && <Skeleton className="avatar-entry" />}
</div>
);
}
@@ -1,5 +1,5 @@
.avatars-list {
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);
}
@@ -75,8 +75,8 @@ export function AvatarsList() {
return (
<div className="avatars-list">
<IncrementalLoader
initial={40}
intermediate={10}
initial={9 * 5}
intermediate={9 * 3}
data={filtered}
Component={AvatarCard}
/>
@@ -80,5 +80,6 @@ import "@fontsource-variable/roboto-flex";
--blur-tint: blur(8rem);
--duration-brief: 200ms;
--duration-subtle: 2s;
}
</style>
@@ -1,6 +1,6 @@
import { times } from "lodash-es";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { SkeletonProps } from "../../types/SkeletonProps";
import type { PropsWithSkeleton } from "../../types/propsWithSkeleton";
import "./index.css";
export interface IncrementalLoaderProps<Props extends { key: string }> {
@@ -10,7 +10,7 @@ export interface IncrementalLoaderProps<Props extends { key: string }> {
skeleton?: boolean;
data: Props[];
Component: (props: SkeletonProps<Omit<Props, "key">>) => ReactNode;
Component: (props: PropsWithSkeleton<Omit<Props, "key">>) => ReactNode;
}
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 }
| (Props & { skeleton?: false });