Compare commits

...

4 Commits

Author SHA1 Message Date
95e02dcd63 Add thumbnail property to User model for image handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced a new 'thumbnail' property in the User model to enable thumbnail support for image rendering.
- This enhancement improves the visual representation of user-related images in the application.
2026-07-31 22:54:41 +01:00
db249d9dc9 Add Part Stock and Product Stock Events sections to Inventory components
- Introduced new 'Part Stock Events' and 'Product Stock Events' sections in PartStockInfo and ProductStockInfo components, respectively.
- Updated state management to include events and modified the UI to display event-related information.
- Added icons and collapsible sections for better organization and user experience.
2026-07-31 22:54:28 +01:00
f85b176709 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.
2026-07-31 22:54:18 +01:00
a86eee5e6f Add padding to descriptions rows in App.css for improved layout
- Introduced padding-bottom to .ant-descriptions-row > td within .ant-descriptions-small to enhance spacing and visual consistency in the descriptions component.
2026-07-31 22:53:37 +01:00
8 changed files with 351 additions and 164 deletions

View File

@ -792,6 +792,10 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
top: 0; top: 0;
} }
.ant-descriptions-small .ant-descriptions-row > td {
padding-bottom: 6px;
}
.minimal-code-block-editor .ant-typography pre { .minimal-code-block-editor .ant-typography pre {
margin: 0; margin: 0;
} }

View File

@ -24,6 +24,7 @@ import DocumentPrintButton from '../../common/DocumentPrintButton.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import HistoryDisplay from '../../common/HistoryDisplay.jsx' import HistoryDisplay from '../../common/HistoryDisplay.jsx'
import PartStockIcon from '../../../Icons/PartStockIcon.jsx'
const log = loglevel.getLogger('PartStockInfo') const log = loglevel.getLogger('PartStockInfo')
log.setLevel(config.logLevel) log.setLevel(config.logLevel)
@ -37,7 +38,7 @@ const PartStockInfo = () => {
'PartStockInfo', 'PartStockInfo',
{ {
info: true, info: true,
stocks: true, events: true,
history: true, history: true,
notes: true, notes: true,
auditLogs: false auditLogs: false
@ -91,6 +92,7 @@ const PartStockInfo = () => {
disabled={objectFormState.loading} disabled={objectFormState.loading}
items={[ items={[
{ key: 'info', label: 'Part Stock Information' }, { key: 'info', label: 'Part Stock Information' },
{ key: 'events', label: 'Part Stock Events' },
{ key: 'history', label: 'Part Stock History' }, { key: 'history', label: 'Part Stock History' },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' } { key: 'auditLogs', label: 'Audit Logs' }
@ -128,7 +130,9 @@ const PartStockInfo = () => {
}} }}
editLoading={objectFormState.editLoading} editLoading={objectFormState.editLoading}
formValid={objectFormState.formValid} formValid={objectFormState.formValid}
disabled={objectFormState.beingEditedByOther || objectFormState.loading} disabled={
objectFormState.beingEditedByOther || objectFormState.loading
}
loading={objectFormState.editLoading} loading={objectFormState.editLoading}
/> />
</Space> </Space>
@ -177,6 +181,24 @@ const PartStockInfo = () => {
</InfoCollapse> </InfoCollapse>
</ActionHandler> </ActionHandler>
<InfoCollapse
title='Part Stock Events'
icon={<PartStockIcon />}
active={collapseState.events}
onToggle={(expanded) => updateCollapseState('events', expanded)}
collapseKey='events'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='stockEvent'
masterFilter={{ 'parent._id': partStockId }}
visibleColumns={{ parent: false }}
/>
)}
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Part Stock History' title='Part Stock History'
icon={<GraphIcon />} icon={<GraphIcon />}

View File

@ -19,6 +19,7 @@ import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import PartStockIcon from '../../../Icons/PartStockIcon.jsx' import PartStockIcon from '../../../Icons/PartStockIcon.jsx'
import ProductStockIcon from '../../../Icons/ProductStockIcon.jsx'
import GraphIcon from '../../../Icons/GraphIcon.jsx' import GraphIcon from '../../../Icons/GraphIcon.jsx'
import ObjectForm from '../../common/ObjectForm.jsx' import ObjectForm from '../../common/ObjectForm.jsx'
import EditButtons from '../../common/EditButtons.jsx' import EditButtons from '../../common/EditButtons.jsx'
@ -48,6 +49,7 @@ const ProductStockInfo = () => {
{ {
info: true, info: true,
partStocks: true, partStocks: true,
events: true,
history: true, history: true,
notes: true, notes: true,
auditLogs: false auditLogs: false
@ -115,6 +117,7 @@ const ProductStockInfo = () => {
items={[ items={[
{ key: 'info', label: 'Product Stock Information' }, { key: 'info', label: 'Product Stock Information' },
{ key: 'partStocks', label: 'Part Stocks' }, { key: 'partStocks', label: 'Part Stocks' },
{ key: 'events', label: 'Product Stock Events' },
{ key: 'history', label: 'Product Stock History' }, { key: 'history', label: 'Product Stock History' },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' } { key: 'auditLogs', label: 'Audit Logs' }
@ -221,6 +224,24 @@ const ProductStockInfo = () => {
</ObjectForm> </ObjectForm>
</ActionHandler> </ActionHandler>
<InfoCollapse
title='Product Stock Events'
icon={<ProductStockIcon />}
active={collapseState.events}
onToggle={(expanded) => updateCollapseState('events', expanded)}
collapseKey='events'
>
{objectFormState.loading ? (
<InfoCollapsePlaceholder />
) : (
<ObjectTable
type='stockEvent'
masterFilter={{ 'parent._id': productStockId }}
visibleColumns={{ parent: false }}
/>
)}
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Product Stock History' title='Product Stock History'
icon={<GraphIcon />} icon={<GraphIcon />}

View File

@ -1,12 +1,11 @@
import { useState, useEffect, useContext, useRef, memo } from 'react' import { useContext, memo } from 'react'
import { Flex, Card, Popover, Divider } from 'antd' import { Flex, Popover, Divider } from 'antd'
import { UserOutlined } from '@ant-design/icons' import { UserOutlined } from '@ant-design/icons'
import { decode } from 'blurhash'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import EditIcon from '../../Icons/EditIcon' import EditIcon from '../../Icons/EditIcon'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import SpotlightTooltip from './SpotlightTooltip' import SpotlightTooltip from './SpotlightTooltip'
import Thumbnail from './Thumbnail'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
@ -34,7 +33,7 @@ const getDisplayName = (user) =>
user?.username || user?.username ||
'User' 'User'
const avatarImageStyle = { const avatarStyle = {
width: 32, width: 32,
height: 32, height: 32,
borderRadius: '12px', borderRadius: '12px',
@ -42,132 +41,30 @@ const avatarImageStyle = {
border: '1px solid rgb(128, 128, 128, 0.4)' border: '1px solid rgb(128, 128, 128, 0.4)'
} }
const avatarCardStyle = { borderRadius: '12px', width: 32, height: 32 }
const UserProfileImage = memo(function UserProfileImage({ const UserProfileImage = memo(function UserProfileImage({
profileImageId, profileImageId,
displayName, displayName,
blurHash blurHash
}) { }) {
const { fetchFileThumbnail } = useContext(ApiServerContext) const file = profileImageId
const fetchFileThumbnailRef = useRef(fetchFileThumbnail) ? { _id: profileImageId, metaData: blurHash ? { blurHash } : undefined }
fetchFileThumbnailRef.current = fetchFileThumbnail : null
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}
/>
)
}
return ( return (
<Card <Thumbnail
style={avatarCardStyle} file={file}
styles={{ body: { padding: 0, height: '100%' } }} size={64}
> alt={displayName}
<Flex justify='center' align='center' style={{ height: '100%' }}> style={avatarStyle}
fallback={
<UserOutlined <UserOutlined
style={{ style={{
fontSize: 14, fontSize: 14,
color: 'var(--color-text-secondary)' color: 'var(--color-text-secondary)'
}} }}
/> />
</Flex> }
</Card> />
) )
}) })

View File

@ -2,6 +2,7 @@ import { Descriptions, Card, Flex, Divider } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { createElement } from 'react' import { createElement } from 'react'
import Thumbnail from './Thumbnail'
const ObjectCard = ({ const ObjectCard = ({
model, model,
@ -16,6 +17,12 @@ const ObjectCard = ({
const descriptionItems = [] const descriptionItems = []
const modelIcon = createElement(model.icon, { style: { fontSize: 24 } }) 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) => { model.columns.forEach((colName) => {
const prop = modelProperties.find((p) => p.name === colName) const prop = modelProperties.find((p) => p.name === colName)
if (prop) { if (prop) {
@ -23,13 +30,17 @@ const ObjectCard = ({
(Object.keys(visibleColumns).length > 0 && (Object.keys(visibleColumns).length > 0 &&
visibleColumns[prop.name] === false) || visibleColumns[prop.name] === false) ||
prop.name == 'name' || prop.name == 'name' ||
(prop.name == 'state' && visibleColumns?.name == true) (prop.name == 'state' && visibleColumns?.name == true) ||
(thumbnailPresent && prop.name === thumbnailProperty.name)
) { ) {
return return
} }
descriptionItems.push( descriptionItems.push({
<Descriptions.Item label={prop.label} key={prop.name} colspan={2}> key: prop.name,
label: prop.label,
span: 2,
children: (
<ObjectProperty <ObjectProperty
{...prop} {...prop}
longId={false} longId={false}
@ -37,8 +48,8 @@ const ObjectCard = ({
isEditing={isEditing} isEditing={isEditing}
name={prop.name} name={prop.name}
/> />
</Descriptions.Item> )
) })
} }
}) })
@ -48,50 +59,91 @@ const ObjectCard = ({
actions = renderActions(record) actions = renderActions(record)
} }
const primaryDescriptionItems = thumbnailPresent
? descriptionItems.slice(0, 2)
: []
const remainingDescriptionItems = thumbnailPresent
? descriptionItems.slice(2)
: descriptionItems
return ( return (
<Card <Card
styles={{ body: { padding: 18 } }} styles={{ body: { padding: 18 } }}
style={{ width: '100%' }} style={{ width: '100%' }}
variant={cardStyle} variant={cardStyle}
> >
<Flex vertical gap={8}> <Flex gap={6} vertical>
{visibleColumns?.name == true && ( {thumbnailPresent && (
<Flex align='center' gap={12}> <Flex gap={14}>
{modelIcon} <Thumbnail
<ObjectProperty file={{
{...model.properties.find((p) => p.name === 'name')} _id: record?.[thumbnailProperty.name]?._id,
objectData={record} type: 'image'
isEditing={isEditing}
style={{
fontSize: 20,
fontWeight: '600',
lineHeight: 1.2
}} }}
style={{
width: 84,
height: 84,
marginBottom: 4
}}
size={128}
/> />
{visibleColumns?.state == true && ( <Flex vertical gap={10} style={{ minWidth: 0 }}>
<ObjectProperty {visibleColumns?.name == true && (
{...model.properties.find((p) => p.name === 'state')} <Flex align='center' gap={12} style={{ minWidth: 0 }}>
objectData={record} {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> </Flex>
)} )}
<Descriptions <Flex vertical gap={8}>
column={1} {remainingDescriptionItems.length > 0 && (
size='small' <Descriptions
style={{ width: '100%', tableLayout: 'fixed' }} column={1}
className='objectTableDescritions' size='small'
> style={{
{descriptionItems} width: '100%',
</Descriptions> tableLayout: 'fixed'
{actions && ( }}
<> className='objectTableDescritions'
<Divider style={{ margin: '2px 0 0 0' }} /> items={remainingDescriptionItems}
<Flex align='flex-end' gap={10}> />
{actions} )}
</Flex> {actions && (
</> <>
)} <Divider style={{ margin: '4px 0 0 0' }} />
<Flex align='flex-end' gap={10}>
{actions}
</Flex>
</>
)}
</Flex>
</Flex> </Flex>
</Card> </Card>
) )

View File

@ -1086,11 +1086,12 @@ const ObjectTable = forwardRef(
return ( return (
<Col <Col
xs={24} xs={24}
sm={12} sm={24}
md={12} md={24}
lg={8} lg={12}
xl={6} xl={12}
xxl={6} xxl={8}
xxxl={6}
key={record._id} key={record._id}
> >
<div style={{ width: '100%', overflow: 'hidden' }}> <div style={{ width: '100%', overflow: 'hidden' }}>

View 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)

View File

@ -116,6 +116,7 @@ export const User = {
fileType: 'image', fileType: 'image',
previewOpen: true, previewOpen: true,
showPreview: false, showPreview: false,
thumbnail: true,
columnWidth: 150, columnWidth: 150,
masterFilter: ['.png', '.jpg', '.jpeg'] masterFilter: ['.png', '.jpg', '.jpeg']
} }