Refactor ActivityIndicator and ObjectCard components to utilize Thumbnail for image rendering
- Removed unnecessary state and effects related to image loading in ActivityIndicator, replacing them with the new Thumbnail component for better performance and simplicity. - Updated ObjectCard to conditionally render a Thumbnail based on the presence of a thumbnail property, enhancing the visual representation of objects. - Introduced a new Thumbnail component to handle image fetching and rendering, including support for blurHash decoding and fallback UI.
This commit is contained in:
parent
a86eee5e6f
commit
f85b176709
@ -1,12 +1,11 @@
|
||||
import { useState, useEffect, useContext, useRef, memo } from 'react'
|
||||
import { Flex, Card, Popover, Divider } from 'antd'
|
||||
import { useContext, memo } from 'react'
|
||||
import { Flex, Popover, Divider } from 'antd'
|
||||
import { UserOutlined } from '@ant-design/icons'
|
||||
import { decode } from 'blurhash'
|
||||
import PropTypes from 'prop-types'
|
||||
import EditIcon from '../../Icons/EditIcon'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
import SpotlightTooltip from './SpotlightTooltip'
|
||||
import Thumbnail from './Thumbnail'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getModelByName } from '../../../database/ObjectModels'
|
||||
|
||||
@ -34,7 +33,7 @@ const getDisplayName = (user) =>
|
||||
user?.username ||
|
||||
'User'
|
||||
|
||||
const avatarImageStyle = {
|
||||
const avatarStyle = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '12px',
|
||||
@ -42,132 +41,30 @@ const avatarImageStyle = {
|
||||
border: '1px solid rgb(128, 128, 128, 0.4)'
|
||||
}
|
||||
|
||||
const avatarCardStyle = { borderRadius: '12px', width: 32, height: 32 }
|
||||
|
||||
const UserProfileImage = memo(function UserProfileImage({
|
||||
profileImageId,
|
||||
displayName,
|
||||
blurHash
|
||||
}) {
|
||||
const { fetchFileThumbnail } = useContext(ApiServerContext)
|
||||
const fetchFileThumbnailRef = useRef(fetchFileThumbnail)
|
||||
fetchFileThumbnailRef.current = fetchFileThumbnail
|
||||
|
||||
const [profileImageUrl, setProfileImageUrl] = useState(null)
|
||||
const [blurHashUrl, setBlurHashUrl] = useState(null)
|
||||
const profileImageUrlRef = useRef(null)
|
||||
const loadedImageIdRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!blurHash) {
|
||||
setBlurHashUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const pixels = decode(blurHash, 32, 32)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 32
|
||||
canvas.height = 32
|
||||
const ctx = canvas.getContext('2d')
|
||||
const imageData = ctx.createImageData(32, 32)
|
||||
imageData.data.set(pixels)
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
setBlurHashUrl(canvas.toDataURL())
|
||||
} catch {
|
||||
setBlurHashUrl(null)
|
||||
}
|
||||
}, [blurHash])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileImageId) {
|
||||
if (profileImageUrlRef.current) {
|
||||
URL.revokeObjectURL(profileImageUrlRef.current)
|
||||
profileImageUrlRef.current = null
|
||||
}
|
||||
loadedImageIdRef.current = null
|
||||
setProfileImageUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
loadedImageIdRef.current === profileImageId &&
|
||||
profileImageUrlRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const file = { _id: profileImageId }
|
||||
|
||||
const loadProfileImage = async () => {
|
||||
try {
|
||||
const fileURL = await fetchFileThumbnailRef.current(file, 64)
|
||||
if (!cancelled) {
|
||||
if (profileImageUrlRef.current) {
|
||||
URL.revokeObjectURL(profileImageUrlRef.current)
|
||||
}
|
||||
profileImageUrlRef.current = fileURL || null
|
||||
loadedImageIdRef.current = profileImageId
|
||||
setProfileImageUrl(fileURL || null)
|
||||
} else if (fileURL) {
|
||||
URL.revokeObjectURL(fileURL)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setProfileImageUrl(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadProfileImage()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [profileImageId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (profileImageUrlRef.current) {
|
||||
URL.revokeObjectURL(profileImageUrlRef.current)
|
||||
profileImageUrlRef.current = null
|
||||
}
|
||||
loadedImageIdRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (profileImageUrl) {
|
||||
return (
|
||||
<img src={profileImageUrl} alt={displayName} style={avatarImageStyle} />
|
||||
)
|
||||
}
|
||||
|
||||
if (profileImageId && blurHashUrl) {
|
||||
return (
|
||||
<img
|
||||
src={blurHashUrl}
|
||||
alt=''
|
||||
aria-hidden
|
||||
style={avatarImageStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const file = profileImageId
|
||||
? { _id: profileImageId, metaData: blurHash ? { blurHash } : undefined }
|
||||
: null
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={avatarCardStyle}
|
||||
styles={{ body: { padding: 0, height: '100%' } }}
|
||||
>
|
||||
<Flex justify='center' align='center' style={{ height: '100%' }}>
|
||||
<Thumbnail
|
||||
file={file}
|
||||
size={64}
|
||||
alt={displayName}
|
||||
style={avatarStyle}
|
||||
fallback={
|
||||
<UserOutlined
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { Descriptions, Card, Flex, Divider } from 'antd'
|
||||
import PropTypes from 'prop-types'
|
||||
import ObjectProperty from './ObjectProperty'
|
||||
import { createElement } from 'react'
|
||||
import Thumbnail from './Thumbnail'
|
||||
|
||||
const ObjectCard = ({
|
||||
model,
|
||||
@ -16,6 +17,12 @@ const ObjectCard = ({
|
||||
const descriptionItems = []
|
||||
const modelIcon = createElement(model.icon, { style: { fontSize: 24 } })
|
||||
|
||||
const thumbnailProperty =
|
||||
model.properties.find((p) => p.thumbnail === true) || null
|
||||
|
||||
const thumbnailPresent =
|
||||
thumbnailProperty !== null && record?.[thumbnailProperty.name]?._id
|
||||
|
||||
model.columns.forEach((colName) => {
|
||||
const prop = modelProperties.find((p) => p.name === colName)
|
||||
if (prop) {
|
||||
@ -23,13 +30,17 @@ const ObjectCard = ({
|
||||
(Object.keys(visibleColumns).length > 0 &&
|
||||
visibleColumns[prop.name] === false) ||
|
||||
prop.name == 'name' ||
|
||||
(prop.name == 'state' && visibleColumns?.name == true)
|
||||
(prop.name == 'state' && visibleColumns?.name == true) ||
|
||||
(thumbnailPresent && prop.name === thumbnailProperty.name)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
descriptionItems.push(
|
||||
<Descriptions.Item label={prop.label} key={prop.name} colspan={2}>
|
||||
descriptionItems.push({
|
||||
key: prop.name,
|
||||
label: prop.label,
|
||||
span: 2,
|
||||
children: (
|
||||
<ObjectProperty
|
||||
{...prop}
|
||||
longId={false}
|
||||
@ -37,8 +48,8 @@ const ObjectCard = ({
|
||||
isEditing={isEditing}
|
||||
name={prop.name}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@ -48,50 +59,91 @@ const ObjectCard = ({
|
||||
actions = renderActions(record)
|
||||
}
|
||||
|
||||
const primaryDescriptionItems = thumbnailPresent
|
||||
? descriptionItems.slice(0, 2)
|
||||
: []
|
||||
const remainingDescriptionItems = thumbnailPresent
|
||||
? descriptionItems.slice(2)
|
||||
: descriptionItems
|
||||
|
||||
return (
|
||||
<Card
|
||||
styles={{ body: { padding: 18 } }}
|
||||
style={{ width: '100%' }}
|
||||
variant={cardStyle}
|
||||
>
|
||||
<Flex vertical gap={8}>
|
||||
{visibleColumns?.name == true && (
|
||||
<Flex align='center' gap={12}>
|
||||
{modelIcon}
|
||||
<ObjectProperty
|
||||
{...model.properties.find((p) => p.name === 'name')}
|
||||
objectData={record}
|
||||
isEditing={isEditing}
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
lineHeight: 1.2
|
||||
<Flex gap={6} vertical>
|
||||
{thumbnailPresent && (
|
||||
<Flex gap={14}>
|
||||
<Thumbnail
|
||||
file={{
|
||||
_id: record?.[thumbnailProperty.name]?._id,
|
||||
type: 'image'
|
||||
}}
|
||||
style={{
|
||||
width: 84,
|
||||
height: 84,
|
||||
marginBottom: 4
|
||||
}}
|
||||
size={128}
|
||||
/>
|
||||
{visibleColumns?.state == true && (
|
||||
<ObjectProperty
|
||||
{...model.properties.find((p) => p.name === 'state')}
|
||||
objectData={record}
|
||||
/>
|
||||
)}
|
||||
<Flex vertical gap={10} style={{ minWidth: 0 }}>
|
||||
{visibleColumns?.name == true && (
|
||||
<Flex align='center' gap={12} style={{ minWidth: 0 }}>
|
||||
{modelIcon}
|
||||
<ObjectProperty
|
||||
{...model.properties.find((p) => p.name === 'name')}
|
||||
objectData={record}
|
||||
isEditing={isEditing}
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
lineHeight: 1.2,
|
||||
minWidth: 0
|
||||
}}
|
||||
/>
|
||||
{visibleColumns?.state == true && (
|
||||
<ObjectProperty
|
||||
{...model.properties.find((p) => p.name === 'state')}
|
||||
objectData={record}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{primaryDescriptionItems.length > 0 && (
|
||||
<Descriptions
|
||||
column={1}
|
||||
size='small'
|
||||
style={{ width: '100%', tableLayout: 'fixed' }}
|
||||
className='objectTableDescritions'
|
||||
items={primaryDescriptionItems}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
<Descriptions
|
||||
column={1}
|
||||
size='small'
|
||||
style={{ width: '100%', tableLayout: 'fixed' }}
|
||||
className='objectTableDescritions'
|
||||
>
|
||||
{descriptionItems}
|
||||
</Descriptions>
|
||||
{actions && (
|
||||
<>
|
||||
<Divider style={{ margin: '2px 0 0 0' }} />
|
||||
<Flex align='flex-end' gap={10}>
|
||||
{actions}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<Flex vertical gap={8}>
|
||||
{remainingDescriptionItems.length > 0 && (
|
||||
<Descriptions
|
||||
column={1}
|
||||
size='small'
|
||||
style={{
|
||||
width: '100%',
|
||||
tableLayout: 'fixed'
|
||||
}}
|
||||
className='objectTableDescritions'
|
||||
items={remainingDescriptionItems}
|
||||
/>
|
||||
)}
|
||||
{actions && (
|
||||
<>
|
||||
<Divider style={{ margin: '4px 0 0 0' }} />
|
||||
<Flex align='flex-end' gap={10}>
|
||||
{actions}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@ -1086,11 +1086,12 @@ const ObjectTable = forwardRef(
|
||||
return (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={12}
|
||||
lg={8}
|
||||
xl={6}
|
||||
xxl={6}
|
||||
sm={24}
|
||||
md={24}
|
||||
lg={12}
|
||||
xl={12}
|
||||
xxl={8}
|
||||
xxxl={6}
|
||||
key={record._id}
|
||||
>
|
||||
<div style={{ width: '100%', overflow: 'hidden' }}>
|
||||
|
||||
189
src/components/Dashboard/common/Thumbnail.jsx
Normal file
189
src/components/Dashboard/common/Thumbnail.jsx
Normal file
@ -0,0 +1,189 @@
|
||||
import { useState, useEffect, useContext, useRef, memo } from 'react'
|
||||
import { Flex, Card } from 'antd'
|
||||
import { FileOutlined } from '@ant-design/icons'
|
||||
import { decode } from 'blurhash'
|
||||
import PropTypes from 'prop-types'
|
||||
import { ApiServerContext } from '../context/ApiServerContext'
|
||||
|
||||
const getFileId = (file) =>
|
||||
file?._id || (typeof file === 'string' ? file : null)
|
||||
|
||||
const getBlurHash = (file) => file?.metaData?.blurHash || null
|
||||
|
||||
const Thumbnail = function Thumbnail({
|
||||
file,
|
||||
size = 64,
|
||||
alt = '',
|
||||
fallback = null,
|
||||
style = {},
|
||||
className
|
||||
}) {
|
||||
const { fetchFileThumbnail } = useContext(ApiServerContext)
|
||||
const fetchFileThumbnailRef = useRef(fetchFileThumbnail)
|
||||
fetchFileThumbnailRef.current = fetchFileThumbnail
|
||||
|
||||
const fileId = getFileId(file)
|
||||
const blurHash = getBlurHash(file)
|
||||
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState(null)
|
||||
const [blurHashUrl, setBlurHashUrl] = useState(null)
|
||||
const thumbnailUrlRef = useRef(null)
|
||||
const loadedKeyRef = useRef(null)
|
||||
|
||||
const imageStyle = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '5px',
|
||||
objectFit: 'cover',
|
||||
...style
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
borderRadius: '5px',
|
||||
width: size,
|
||||
height: size,
|
||||
border: 'none',
|
||||
...style
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!blurHash) {
|
||||
setBlurHashUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const pixels = decode(blurHash, 32, 32)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 32
|
||||
canvas.height = 32
|
||||
const ctx = canvas.getContext('2d')
|
||||
const imageData = ctx.createImageData(32, 32)
|
||||
imageData.data.set(pixels)
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
setBlurHashUrl(canvas.toDataURL())
|
||||
} catch {
|
||||
setBlurHashUrl(null)
|
||||
}
|
||||
}, [blurHash])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileId) {
|
||||
if (thumbnailUrlRef.current) {
|
||||
URL.revokeObjectURL(thumbnailUrlRef.current)
|
||||
thumbnailUrlRef.current = null
|
||||
}
|
||||
loadedKeyRef.current = null
|
||||
setThumbnailUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
const loadKey = `${fileId}-${size}`
|
||||
if (loadedKeyRef.current === loadKey && thumbnailUrlRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const fileObject =
|
||||
typeof file === 'object' && file !== null ? file : { _id: fileId }
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
try {
|
||||
const fileURL = await fetchFileThumbnailRef.current(fileObject, size)
|
||||
if (!cancelled) {
|
||||
if (thumbnailUrlRef.current) {
|
||||
URL.revokeObjectURL(thumbnailUrlRef.current)
|
||||
}
|
||||
thumbnailUrlRef.current = fileURL || null
|
||||
loadedKeyRef.current = loadKey
|
||||
setThumbnailUrl(fileURL || null)
|
||||
} else if (fileURL) {
|
||||
URL.revokeObjectURL(fileURL)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setThumbnailUrl(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadThumbnail()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [file, fileId, size])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (thumbnailUrlRef.current) {
|
||||
URL.revokeObjectURL(thumbnailUrlRef.current)
|
||||
thumbnailUrlRef.current = null
|
||||
}
|
||||
loadedKeyRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (thumbnailUrl) {
|
||||
return (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={imageStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (fileId && blurHashUrl) {
|
||||
return (
|
||||
<img
|
||||
src={blurHashUrl}
|
||||
alt=''
|
||||
aria-hidden
|
||||
className={className}
|
||||
style={imageStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={className}
|
||||
style={cardStyle}
|
||||
styles={{ body: { padding: 0, height: '100%' } }}
|
||||
>
|
||||
<Flex justify='center' align='center' style={{ height: '100%' }}>
|
||||
{fallback || (
|
||||
<FileOutlined
|
||||
style={{
|
||||
fontSize: Math.round(size * 0.22),
|
||||
color: 'var(--color-text-secondary)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Thumbnail.propTypes = {
|
||||
file: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
||||
size: PropTypes.oneOf([64, 128, 256]),
|
||||
alt: PropTypes.string,
|
||||
fallback: PropTypes.node,
|
||||
style: PropTypes.object,
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
const areEqual = (prevProps, nextProps) => {
|
||||
return (
|
||||
getFileId(prevProps.file) === getFileId(nextProps.file) &&
|
||||
getBlurHash(prevProps.file) === getBlurHash(nextProps.file) &&
|
||||
prevProps.size === nextProps.size &&
|
||||
prevProps.alt === nextProps.alt &&
|
||||
JSON.stringify(prevProps.style) === JSON.stringify(nextProps.style)
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Thumbnail, areEqual)
|
||||
Loading…
x
Reference in New Issue
Block a user