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,14 +171,21 @@ export function CandidateCard({
}} }}
> >
{imageUrl ? ( {imageUrl ? (
<GearImage <div className="relative w-full h-full">
src={imageUrl} {!loaded && (
alt={name} <div className="absolute inset-0 bg-gray-100 animate-pulse" />
dominantColor={dominantColor} )}
cropZoom={cropZoom} <GearImage
cropX={cropX} src={imageUrl}
cropY={cropY} alt={name}
/> dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
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,14 +48,21 @@ export function GlobalItemCard({
}} }}
> >
{imageUrl ? ( {imageUrl ? (
<GearImage <div className="relative w-full h-full">
src={imageUrl} {!loaded && (
alt={`${brand} ${model}`} <div className="absolute inset-0 bg-gray-100 animate-pulse" />
dominantColor={dominantColor} )}
cropZoom={cropZoom} <GearImage
cropX={cropX} src={imageUrl}
cropY={cropY} alt={`${brand} ${model}`}
/> dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
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,14 +196,21 @@ export function ItemCard({
}} }}
> >
{imageUrl ? ( {imageUrl ? (
<GearImage <div className="relative w-full h-full">
src={imageUrl} {!loaded && (
alt={name} <div className="absolute inset-0 bg-gray-100 animate-pulse" />
dominantColor={dominantColor} )}
cropZoom={cropZoom} <GearImage
cropX={cropX} src={imageUrl}
cropY={cropY} alt={name}
/> dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
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