diff --git a/src/components/Dashboard/common/ActivityIndicator.jsx b/src/components/Dashboard/common/ActivityIndicator.jsx index fcd1453..c60ba59 100644 --- a/src/components/Dashboard/common/ActivityIndicator.jsx +++ b/src/components/Dashboard/common/ActivityIndicator.jsx @@ -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 ( - {displayName} - ) - } - - if (profileImageId && blurHashUrl) { - return ( - - ) - } + const file = profileImageId + ? { _id: profileImageId, metaData: blurHash ? { blurHash } : undefined } + : null return ( - - + - - + } + /> ) }) diff --git a/src/components/Dashboard/common/ObjectCard.jsx b/src/components/Dashboard/common/ObjectCard.jsx index ef47e75..35af1ef 100644 --- a/src/components/Dashboard/common/ObjectCard.jsx +++ b/src/components/Dashboard/common/ObjectCard.jsx @@ -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( - + descriptionItems.push({ + key: prop.name, + label: prop.label, + span: 2, + children: ( - - ) + ) + }) } }) @@ -48,50 +59,91 @@ const ObjectCard = ({ actions = renderActions(record) } + const primaryDescriptionItems = thumbnailPresent + ? descriptionItems.slice(0, 2) + : [] + const remainingDescriptionItems = thumbnailPresent + ? descriptionItems.slice(2) + : descriptionItems + return ( - - {visibleColumns?.name == true && ( - - {modelIcon} - p.name === 'name')} - objectData={record} - isEditing={isEditing} - style={{ - fontSize: 20, - fontWeight: '600', - lineHeight: 1.2 + + {thumbnailPresent && ( + + - {visibleColumns?.state == true && ( - p.name === 'state')} - objectData={record} - /> - )} + + {visibleColumns?.name == true && ( + + {modelIcon} + p.name === 'name')} + objectData={record} + isEditing={isEditing} + style={{ + fontSize: 20, + fontWeight: '600', + lineHeight: 1.2, + minWidth: 0 + }} + /> + {visibleColumns?.state == true && ( + p.name === 'state')} + objectData={record} + /> + )} + + )} + {primaryDescriptionItems.length > 0 && ( + + )} + )} - - {descriptionItems} - - {actions && ( - <> - - - {actions} - - - )} + + {remainingDescriptionItems.length > 0 && ( + + )} + {actions && ( + <> + + + {actions} + + + )} + ) diff --git a/src/components/Dashboard/common/ObjectTable.jsx b/src/components/Dashboard/common/ObjectTable.jsx index e931a7c..7798f93 100644 --- a/src/components/Dashboard/common/ObjectTable.jsx +++ b/src/components/Dashboard/common/ObjectTable.jsx @@ -1086,11 +1086,12 @@ const ObjectTable = forwardRef( return (
diff --git a/src/components/Dashboard/common/Thumbnail.jsx b/src/components/Dashboard/common/Thumbnail.jsx new file mode 100644 index 0000000..b2f34b5 --- /dev/null +++ b/src/components/Dashboard/common/Thumbnail.jsx @@ -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 ( + {alt} + ) + } + + if (fileId && blurHashUrl) { + return ( + + ) + } + + return ( + + + {fallback || ( + + )} + + + ) +} + +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)