feat: optimize display effect when image loading fails
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user