feat: add support for thumbhash

This commit is contained in:
codytseng 2025-12-12 10:23:02 +08:00
parent f6f974adc6
commit 51fc7d4c05
6 changed files with 101 additions and 29 deletions

View file

@ -57,8 +57,8 @@ export default function Collapsible({
>
{children}
{shouldCollapse && !expanded && (
<div className="absolute bottom-0 h-40 w-full bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4">
<div className="bg-background rounded-md">
<div className="absolute bottom-0 h-40 w-full z-10 bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4">
<div className="bg-background rounded-lg">
<Button
className="bg-foreground hover:bg-foreground/80"
onClick={(e) => {

View file

@ -5,9 +5,10 @@ import { TImetaInfo } from '@/types'
import { decode } from 'blurhash'
import { ImageOff } from 'lucide-react'
import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
import { thumbHashToDataURL } from 'thumbhash'
export default function Image({
image: { url, blurHash, pubkey, dim },
image: { url, blurHash, thumbHash, pubkey, dim },
alt,
className = '',
classNames = {},
@ -73,20 +74,39 @@ export default function Image({
return (
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}>
{/* Spacer: transparent image to maintain dimensions when image is loading */}
{isLoading && dim?.width && dim?.height && (
<img
src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
className={cn(
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
className
)}
alt=""
/>
)}
{displaySkeleton && (
<div className="absolute inset-0 z-10">
{blurHash ? (
{thumbHash ? (
<ThumbHashPlaceholder
thumbHash={thumbHash}
className={cn(
'w-full h-full transition-opacity rounded-xl',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
) : blurHash ? (
<BlurHashCanvas
blurHash={blurHash}
className={cn(
'absolute inset-0 transition-opacity rounded-xl',
'w-full h-full transition-opacity rounded-xl',
isLoading ? 'opacity-100' : 'opacity-0'
)}
/>
) : (
<Skeleton
className={cn(
'absolute inset-0 transition-opacity rounded-xl',
'w-full h-full transition-opacity rounded-xl',
isLoading ? 'opacity-100' : 'opacity-0',
classNames.skeleton
)}
@ -104,12 +124,10 @@ export default function Image({
onLoad={handleLoad}
onError={handleError}
className={cn(
'object-cover rounded-xl w-full h-full transition-opacity pointer-events-none',
isLoading ? 'opacity-0' : 'opacity-100',
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
isLoading ? 'opacity-0 absolute inset-0' : '',
className
)}
width={dim?.width}
height={dim?.height}
/>
)}
{hasError &&
@ -178,3 +196,35 @@ function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; classN
/>
)
}
function ThumbHashPlaceholder({
thumbHash,
className = ''
}: {
thumbHash: Uint8Array
className?: string
}) {
const dataUrl = useMemo(() => {
if (!thumbHash) return null
try {
return thumbHashToDataURL(thumbHash)
} catch (error) {
console.warn('failed to decode thumbhash:', error)
return null
}
}, [thumbHash])
if (!dataUrl) return null
return (
<div
className={cn('w-full h-full object-cover rounded-lg', className)}
style={{
backgroundImage: `url(${dataUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(1px)'
}}
/>
)
}