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 { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
@@ -56,6 +57,7 @@ export function CandidateCard({
rank,
delta,
}: CandidateCardProps) {
const [loaded, setLoaded] = useState(false);
const { t } = useTranslation("threads");
const { weight, price } = useFormatters();
const navigate = useNavigate();
@@ -169,14 +171,21 @@ export function CandidateCard({
}}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={name}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
/>
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage
src={imageUrl}
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">
<LucideIcon

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import { useFormatters } from "../hooks/useFormatters";
import { GearImage, imageContainerBg } from "./GearImage";
@@ -29,6 +30,7 @@ export function GlobalItemCard({
cropX,
cropY,
}: GlobalItemCardProps) {
const [loaded, setLoaded] = useState(false);
const { weight, price } = useFormatters();
return (
@@ -46,14 +48,21 @@ export function GlobalItemCard({
}}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={`${brand} ${model}`}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
/>
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage
src={imageUrl}
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">
<svg

View File

@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters";
import { useDuplicateItem } from "../hooks/useItems";
@@ -52,6 +53,7 @@ export function ItemCard({
linkTo,
priceCurrency,
}: ItemCardProps) {
const [loaded, setLoaded] = useState(false);
const { t } = useTranslation("collection");
const { weight, price } = useFormatters();
const navigate = useNavigate();
@@ -194,14 +196,21 @@ export function ItemCard({
}}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={name}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
/>
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage
src={imageUrl}
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">
<LucideIcon