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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user