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 { cn } from '@/lib/utils'
|
||||||
import { TImageInfo } from '@/types'
|
import { TImageInfo } from '@/types'
|
||||||
import { decode } from 'blurhash'
|
import { decode } from 'blurhash'
|
||||||
|
import { ImageOff } from 'lucide-react'
|
||||||
import { HTMLAttributes, useEffect, useState } from 'react'
|
import { HTMLAttributes, useEffect, useState } from 'react'
|
||||||
|
|
||||||
export default function Image({
|
export default function Image({
|
||||||
image: { url, blurHash },
|
image: { url, blurHash },
|
||||||
alt,
|
alt,
|
||||||
className = '',
|
className = '',
|
||||||
|
classNames = {},
|
||||||
|
hideIfError = false,
|
||||||
...props
|
...props
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
classNames?: {
|
||||||
|
wrapper?: string
|
||||||
|
errorPlaceholder?: string
|
||||||
|
}
|
||||||
image: TImageInfo
|
image: TImageInfo
|
||||||
alt?: string
|
alt?: string
|
||||||
|
hideIfError?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [displayBlurHash, setDisplayBlurHash] = useState(true)
|
const [displayBlurHash, setDisplayBlurHash] = useState(true)
|
||||||
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
|
const [blurDataUrl, setBlurDataUrl] = useState<string | null>(null)
|
||||||
|
const [hasError, setHasError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (blurHash) {
|
if (blurHash) {
|
||||||
@@ -36,14 +45,17 @@ export default function Image({
|
|||||||
}
|
}
|
||||||
}, [blurHash])
|
}, [blurHash])
|
||||||
|
|
||||||
|
if (hideIfError && hasError) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)} {...props}>
|
<div className={cn('relative', classNames.wrapper)} {...props}>
|
||||||
{isLoading && <Skeleton className={cn('absolute inset-0', className)} />}
|
{isLoading && <Skeleton className={cn('absolute inset-0 rounded-lg', className)} />}
|
||||||
|
{!hasError ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
className={cn(
|
className={cn(
|
||||||
'object-cover transition-opacity duration-300',
|
'object-cover transition-opacity duration-300 w-full h-full',
|
||||||
isLoading ? 'opacity-0' : 'opacity-100',
|
isLoading ? 'opacity-0' : 'opacity-100',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -51,8 +63,23 @@ export default function Image({
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setTimeout(() => setDisplayBlurHash(false), 500)
|
setTimeout(() => setDisplayBlurHash(false), 500)
|
||||||
}}
|
}}
|
||||||
|
onError={() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
setHasError(true)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{displayBlurHash && blurDataUrl && (
|
) : (
|
||||||
|
<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
|
<img
|
||||||
src={blurDataUrl}
|
src={blurDataUrl}
|
||||||
className={cn('absolute inset-0 object-cover w-full h-full -z-10', className)}
|
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) => (
|
{images.map((image, index) => (
|
||||||
<CarouselItem key={index} className="xl:basis-2/3 cursor-zoom-in">
|
<CarouselItem key={index} className="xl:basis-2/3 cursor-zoom-in">
|
||||||
<Image
|
<Image
|
||||||
className="xl:rounded-lg"
|
className="xl:rounded-lg max-h-[75vh]"
|
||||||
|
classNames={{
|
||||||
|
errorPlaceholder: 'aspect-square'
|
||||||
|
}}
|
||||||
image={image}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, index)}
|
onClick={(e) => handlePhotoClick(e, index)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ export default function ImageGallery({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg',
|
'rounded-lg',
|
||||||
!disableLightbox ? 'cursor-auto' : '',
|
!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]}
|
image={images[0]}
|
||||||
onClick={(e) => handlePhotoClick(e, 0)}
|
onClick={(e) => handlePhotoClick(e, 0)}
|
||||||
/>
|
/>
|
||||||
@@ -50,7 +53,7 @@ export default function ImageGallery({
|
|||||||
{images.map((image, i) => (
|
{images.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
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}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
@@ -63,7 +66,7 @@ export default function ImageGallery({
|
|||||||
{images.map((image, i) => (
|
{images.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
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}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
@@ -72,11 +75,11 @@ export default function ImageGallery({
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
imageContent = (
|
imageContent = (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2 w-full">
|
||||||
{images.map((image, i) => (
|
{images.map((image, i) => (
|
||||||
<Image
|
<Image
|
||||||
key={i}
|
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}
|
image={image}
|
||||||
onClick={(e) => handlePhotoClick(e, i)}
|
onClick={(e) => handlePhotoClick(e, i)}
|
||||||
/>
|
/>
|
||||||
@@ -86,7 +89,7 @@ export default function ImageGallery({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}
|
{imageContent}
|
||||||
{index >= 0 &&
|
{index >= 0 &&
|
||||||
!disableLightbox &&
|
!disableLightbox &&
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ export default function GroupMetadataCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-start mt-2">
|
<div className="flex gap-2 items-start mt-2">
|
||||||
{metadata.picture && (
|
{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="flex-1 w-0 space-y-1">
|
||||||
<div className="text-xl font-semibold line-clamp-1">{metadata.name}</div>
|
<div className="text-xl font-semibold line-clamp-1">{metadata.name}</div>
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export default function LiveEventCard({
|
|||||||
<Image
|
<Image
|
||||||
image={{ url: metadata.image }}
|
image={{ url: metadata.image }}
|
||||||
className="w-full aspect-video object-cover rounded-lg"
|
className="w-full aspect-video object-cover rounded-lg"
|
||||||
|
hideIfError
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -148,7 +149,7 @@ export default function LiveEventCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{metadata.image && (
|
{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>
|
</div>
|
||||||
{!embedded && <Separator />}
|
{!embedded && <Separator />}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export default function LongFormArticleCard({
|
|||||||
<Image
|
<Image
|
||||||
image={{ url: metadata.image }}
|
image={{ url: metadata.image }}
|
||||||
className="w-full aspect-video object-cover rounded-lg"
|
className="w-full aspect-video object-cover rounded-lg"
|
||||||
|
hideIfError
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -141,7 +142,7 @@ export default function LongFormArticleCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{metadata.image && (
|
{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>
|
</div>
|
||||||
{!embedded && <Separator />}
|
{!embedded && <Separator />}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { useMuteList } from '@/providers/MuteListProvider'
|
|||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import relayInfoService from '@/services/relay-info.service'
|
|
||||||
import storage from '@/services/local-storage.service'
|
import storage from '@/services/local-storage.service'
|
||||||
|
import relayInfoService from '@/services/relay-info.service'
|
||||||
import { TNoteListMode } from '@/types'
|
import { TNoteListMode } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, Filter, kinds } from 'nostr-tools'
|
import { Event, Filter, kinds } from 'nostr-tools'
|
||||||
@@ -326,30 +326,10 @@ function PictureNoteCardMasonry({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LoadingSkeleton({ isPictures }: { isPictures: boolean }) {
|
function LoadingSkeleton({ isPictures }: { isPictures: boolean }) {
|
||||||
const { isLargeScreen } = useScreenSize()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (isPictures) {
|
if (isPictures) {
|
||||||
return (
|
return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
|
||||||
<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 (
|
return (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function PictureNoteCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('cursor-pointer relative', className)} onClick={() => push(toNote(event))}>
|
<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 && (
|
{images.length > 1 && (
|
||||||
<div className="absolute top-2 right-2 bg-background/50 rounded-full p-2">
|
<div className="absolute top-2 right-2 bg-background/50 rounded-full p-2">
|
||||||
<Images size={16} />
|
<Images size={16} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Image from '../Image'
|
import Image from '../Image'
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export default function ProfileBanner({
|
|||||||
<Image
|
<Image
|
||||||
image={{ url: bannerUrl }}
|
image={{ url: bannerUrl }}
|
||||||
alt={`${pubkey} banner`}
|
alt={`${pubkey} banner`}
|
||||||
className={className}
|
className={cn('rounded-lg', className)}
|
||||||
onError={() => setBannerUrl(defaultBanner)}
|
onError={() => setBannerUrl(defaultBanner)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function WebPreview({
|
|||||||
if (isSmallScreen && image) {
|
if (isSmallScreen && image) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border mt-2">
|
<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="bg-muted p-2 w-full rounded-b-lg">
|
||||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||||
<div className="font-semibold line-clamp-1">{title}</div>
|
<div className="font-semibold line-clamp-1">{title}</div>
|
||||||
@@ -50,7 +50,8 @@ export default function WebPreview({
|
|||||||
{image && (
|
{image && (
|
||||||
<Image
|
<Image
|
||||||
image={{ url: 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">
|
<div className="flex-1 w-0 p-2">
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
<ProfileBanner
|
<ProfileBanner
|
||||||
banner={banner}
|
banner={banner}
|
||||||
pubkey={account.pubkey}
|
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">
|
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-lg flex flex-col justify-center items-center">
|
||||||
{uploadingBanner ? (
|
{uploadingBanner ? (
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
|
|||||||
<ProfileBanner
|
<ProfileBanner
|
||||||
banner={banner}
|
banner={banner}
|
||||||
pubkey={pubkey}
|
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">
|
<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" />
|
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||||
|
|||||||
Reference in New Issue
Block a user