feat(35-02): add image skeleton loading states to all card types

- Add useState(false) loaded state to ItemCard, CandidateCard, GlobalItemCard
- Show bg-gray-100 animate-pulse skeleton overlay while image loads
- Fade in image via transition-opacity duration-200 on onLoad callback
- No-image placeholders (icon on bg-gray-50) unchanged
- Add import { useState } from react to all three files with correct Biome import order
This commit is contained in:
2026-04-19 19:47:11 +02:00
parent 2d2259a0db
commit 88db308a16
3 changed files with 51 additions and 24 deletions

View File

@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas"; import type { CandidateDelta } from "../hooks/useImpactDeltas";
@@ -56,6 +57,7 @@ export function CandidateCard({
rank, rank,
delta, delta,
}: CandidateCardProps) { }: CandidateCardProps) {
const [loaded, setLoaded] = useState(false);
const { t } = useTranslation("threads"); const { t } = useTranslation("threads");
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -169,6 +171,10 @@ export function CandidateCard({
}} }}
> >
{imageUrl ? ( {imageUrl ? (
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage <GearImage
src={imageUrl} src={imageUrl}
alt={name} alt={name}
@@ -176,7 +182,10 @@ export function CandidateCard({
cropZoom={cropZoom} cropZoom={cropZoom}
cropX={cropX} cropX={cropX}
cropY={cropY} cropY={cropY}
onLoad={() => setLoaded(true)}
className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
/> />
</div>
) : ( ) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center"> <div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
<LucideIcon <LucideIcon

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import { GearImage, imageContainerBg } from "./GearImage"; import { GearImage, imageContainerBg } from "./GearImage";
@@ -29,6 +30,7 @@ export function GlobalItemCard({
cropX, cropX,
cropY, cropY,
}: GlobalItemCardProps) { }: GlobalItemCardProps) {
const [loaded, setLoaded] = useState(false);
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
return ( return (
@@ -46,6 +48,10 @@ export function GlobalItemCard({
}} }}
> >
{imageUrl ? ( {imageUrl ? (
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage <GearImage
src={imageUrl} src={imageUrl}
alt={`${brand} ${model}`} alt={`${brand} ${model}`}
@@ -53,7 +59,10 @@ export function GlobalItemCard({
cropZoom={cropZoom} cropZoom={cropZoom}
cropX={cropX} cropX={cropX}
cropY={cropY} cropY={cropY}
onLoad={() => setLoaded(true)}
className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
/> />
</div>
) : ( ) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center"> <div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
<svg <svg

View File

@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import { useDuplicateItem } from "../hooks/useItems"; import { useDuplicateItem } from "../hooks/useItems";
@@ -52,6 +53,7 @@ export function ItemCard({
linkTo, linkTo,
priceCurrency, priceCurrency,
}: ItemCardProps) { }: ItemCardProps) {
const [loaded, setLoaded] = useState(false);
const { t } = useTranslation("collection"); const { t } = useTranslation("collection");
const { weight, price } = useFormatters(); const { weight, price } = useFormatters();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -194,6 +196,10 @@ export function ItemCard({
}} }}
> >
{imageUrl ? ( {imageUrl ? (
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage <GearImage
src={imageUrl} src={imageUrl}
alt={name} alt={name}
@@ -201,7 +207,10 @@ export function ItemCard({
cropZoom={cropZoom} cropZoom={cropZoom}
cropX={cropX} cropX={cropX}
cropY={cropY} cropY={cropY}
onLoad={() => setLoaded(true)}
className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
/> />
</div>
) : ( ) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center"> <div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
<LucideIcon <LucideIcon