feat: optimize display effect when image loading fails

This commit is contained in:
codytseng
2025-02-14 12:17:01 +08:00
parent c4b9b397a6
commit 41d46b1a13
12 changed files with 76 additions and 55 deletions

View File

@@ -2,20 +2,29 @@ import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import { TImageInfo } from '@/types'
import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react'
import { HTMLAttributes, useEffect, useState } from 'react'
export default function Image({
image: { url, blurHash },
alt,
className = '',
classNames = {},
hideIfError = false,
...props
}: HTMLAttributes<HTMLDivElement> & {
classNames?: {
wrapper?: string
errorPlaceholder?: string
}
image: TImageInfo
alt?: string
hideIfError?: boolean
}) {
const [isLoading, setIsLoading] = useState(true)
const [displayBlurHash, setDisplayBlurHash] = useState(true)
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
const [hasError, setHasError] = useState(false)
useEffect(() => {
if (blurHash) {
@@ -36,23 +45,41 @@ export default function Image({
}
}, [blurHash])
if (hideIfError && hasError) return null
return (
<div className={cn('relative', className)} {...props}>
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />}
<img
src={url}
alt={alt}
className={cn(
'object-cover transition-opacity duration-300',
isLoading ? 'opacity-0' : 'opacity-100',
className
)}
onLoad={() => {
setIsLoading(false)
setTimeout(() => setDisplayBlurHash(false), 500)
}}
/>
{displayBlurHash && blurDataUrl && (
<div className={cn('relative', classNames.wrapper)} {...props}>
{isLoading && <Skeleton className={cn('absolute inset-0 rounded-lg', className)} />}
{!hasError ? (
<img
src={url}
alt={alt}
className={cn(
'object-cover transition-opacity duration-300 w-full h-full',
isLoading ? 'opacity-0' : 'opacity-100',
className
)}
onLoad={() => {
setIsLoading(false)
setTimeout(() => setDisplayBlurHash(false), 500)
}}
onError={() => {
setIsLoading(false)
setHasError(true)
}}
/>
) : (
<div
className={cn(
'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
className,
classNames.errorPlaceholder
)}
>
<ImageOff />
</div>
)}
{displayBlurHash && blurDataUrl && !hasError && (
<img
src={blurDataUrl}
className={cn('absolute inset-0 object-cover w-full h-full -z-10', className)}

View File

@@ -48,7 +48,10 @@ export function ImageCarousel({
{images.map((image, index) => (
<CarouselItem key={index} className="xl:basis-2/3 cursor-zoom-in">
<Image
className="xl:rounded-lg"
className="xl:rounded-lg max-h-[75vh]"
classNames={{
errorPlaceholder: 'aspect-square'
}}
image={image}
onClick={(e) => handlePhotoClick(e, index)}
/>

View File

@@ -38,8 +38,11 @@ export default function ImageGallery({
className={cn(
'rounded-lg',
!disableLightbox ? 'cursor-auto' : '',
size === 'small' ? 'h-[15vh]' : 'h-[30vh]'
size === 'small' ? 'max-h-[15vh]' : 'max-h-[30vh]'
)}
classNames={{
errorPlaceholder: cn('aspect-square', size === 'small' ? 'h-[15vh]' : 'h-[30vh]')
}}
image={images[0]}
onClick={(e) => handlePhotoClick(e, 0)}
/>
@@ -50,7 +53,7 @@ export default function ImageGallery({
{images.map((image, i) => (
<Image
key={i}
className={cn('rounded-lg aspect-square w-full', !disableLightbox ? 'cursor-auto' : '')}
className={cn('aspect-square w-full rounded-lg', !disableLightbox ? 'cursor-auto' : '')}
image={image}
onClick={(e) => handlePhotoClick(e, i)}
/>
@@ -63,7 +66,7 @@ export default function ImageGallery({
{images.map((image, i) => (
<Image
key={i}
className={cn('rounded-lg aspect-square w-full', !disableLightbox ? 'cursor-auto' : '')}
className={cn('aspect-square w-full rounded-lg', !disableLightbox ? 'cursor-auto' : '')}
image={image}
onClick={(e) => handlePhotoClick(e, i)}
/>
@@ -72,11 +75,11 @@ export default function ImageGallery({
)
} else {
imageContent = (
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-3 gap-2 w-full">
{images.map((image, i) => (
<Image
key={i}
className={cn('rounded-lg aspect-square w-full', !disableLightbox ? 'cursor-auto' : '')}
className={cn('aspect-square w-full rounded-lg', !disableLightbox ? 'cursor-auto' : '')}
image={image}
onClick={(e) => handlePhotoClick(e, i)}
/>
@@ -86,7 +89,7 @@ export default function ImageGallery({
}
return (
<div className={cn('relative w-fit max-w-full', className)}>
<div className={cn('relative', images.length === 1 ? 'w-fit max-w-full' : 'w-full', className)}>
{imageContent}
{index >= 0 &&
!disableLightbox &&

View File

@@ -93,7 +93,11 @@ export default function GroupMetadataCard({
</div>
<div className="flex gap-2 items-start mt-2">
{metadata.picture && (
<Image image={{ url: metadata.picture }} className="h-32 aspect-square rounded-lg" />
<Image
image={{ url: metadata.picture }}
className="h-32 aspect-square rounded-lg"
hideIfError
/>
)}
<div className="flex-1 w-0 space-y-1">
<div className="text-xl font-semibold line-clamp-1">{metadata.name}</div>

View File

@@ -120,6 +120,7 @@ export default function LiveEventCard({
<Image
image={{ url: metadata.image }}
className="w-full aspect-video object-cover rounded-lg"
hideIfError
/>
)}
<div className="space-y-1">
@@ -148,7 +149,7 @@ export default function LiveEventCard({
</div>
</div>
{metadata.image && (
<Image image={{ url: metadata.image }} className="h-36 max-w-44 rounded-lg" />
<Image image={{ url: metadata.image }} className="h-36 max-w-44 rounded-lg" hideIfError />
)}
</div>
{!embedded && <Separator />}

View File

@@ -113,6 +113,7 @@ export default function LongFormArticleCard({
<Image
image={{ url: metadata.image }}
className="w-full aspect-video object-cover rounded-lg"
hideIfError
/>
)}
<div className="space-y-1">
@@ -141,7 +142,7 @@ export default function LongFormArticleCard({
</div>
</div>
{metadata.image && (
<Image image={{ url: metadata.image }} className="h-36 max-w-48 rounded-lg" />
<Image image={{ url: metadata.image }} className="rounded-lg h-36 max-w-48" hideIfError />
)}
</div>
{!embedded && <Separator />}

View File

@@ -9,8 +9,8 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.service'
import storage from '@/services/local-storage.service'
import relayInfoService from '@/services/relay-info.service'
import { TNoteListMode } from '@/types'
import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools'
@@ -326,30 +326,10 @@ function PictureNoteCardMasonry({
}
function LoadingSkeleton({ isPictures }: { isPictures: boolean }) {
const { isLargeScreen } = useScreenSize()
const { t } = useTranslation()
if (isPictures) {
return (
<div
className={cn(
'px-2 sm:px-4 grid',
isLargeScreen ? 'grid-cols-3 gap-4' : 'grid-cols-2 gap-2'
)}
>
{[...Array(isLargeScreen ? 3 : 2)].map((_, i) => (
<div key={i}>
<Skeleton className="rounded-lg w-full aspect-[6/8]" />
<div className="p-2">
<Skeleton className="w-32 h-5" />
<div className="flex items-center gap-2 mt-2">
<Skeleton className="w-5 h-5 rounded-full" />
<Skeleton className="w-16 h-3" />
</div>
</div>
</div>
))}
</div>
)
return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
}
return (

View File

@@ -38,7 +38,7 @@ export default function PictureNoteCard({
return (
<div className={cn('cursor-pointer relative', className)} onClick={() => push(toNote(event))}>
<Image className="rounded-lg w-full aspect-[6/8]" image={images[0]} />
<Image className="w-full aspect-[6/8] rounded-lg" image={images[0]} />
{images.length > 1 && (
<div className="absolute top-2 right-2 bg-background/50 rounded-full p-2">
<Images size={16} />

View File

@@ -1,4 +1,5 @@
import { generateImageByPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
import Image from '../Image'
@@ -26,7 +27,7 @@ export default function ProfileBanner({
<Image
image={{ url: bannerUrl }}
alt={`${pubkey} banner`}
className={className}
className={cn('rounded-lg', className)}
onError={() => setBannerUrl(defaultBanner)}
/>
)

View File

@@ -30,7 +30,7 @@ export default function WebPreview({
if (isSmallScreen && image) {
return (
<div className="rounded-lg border mt-2">
<Image image={{ url: image }} className="rounded-t-lg w-full h-44" />
<Image image={{ url: image }} className="w-full h-44 rounded-t-lg" hideIfError />
<div className="bg-muted p-2 w-full rounded-b-lg">
<div className="text-xs text-muted-foreground">{hostname}</div>
<div className="font-semibold line-clamp-1">{title}</div>
@@ -50,7 +50,8 @@ export default function WebPreview({
{image && (
<Image
image={{ url: image }}
className={`rounded-l-lg ${size === 'normal' ? 'h-44' : 'h-24'}`}
className={`rounded-lg ${size === 'normal' ? 'h-44' : 'h-24'}`}
hideIfError
/>
)}
<div className="flex-1 w-0 p-2">

View File

@@ -109,7 +109,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<ProfileBanner
banner={banner}
pubkey={account.pubkey}
className="w-full aspect-video object-cover rounded-lg"
className="w-full aspect-video object-cover"
/>
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-lg flex flex-col justify-center items-center">
{uploadingBanner ? (

View File

@@ -82,7 +82,7 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
<ProfileBanner
banner={banner}
pubkey={pubkey}
className="w-full aspect-video object-cover rounded-lg"
className="w-full aspect-video object-cover"
/>
<Avatar className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" />