386 lines
10 KiB
TypeScript
386 lines
10 KiB
TypeScript
import { Trans, t } from '@lingui/macro';
|
|
import {
|
|
AspectRatio,
|
|
Button,
|
|
Group,
|
|
Image,
|
|
Overlay,
|
|
Paper,
|
|
Text,
|
|
rem,
|
|
useMantineColorScheme
|
|
} from '@mantine/core';
|
|
import { Dropzone, FileWithPath, IMAGE_MIME_TYPE } from '@mantine/dropzone';
|
|
import { useHover } from '@mantine/hooks';
|
|
import { modals } from '@mantine/modals';
|
|
import { useMemo, useState } from 'react';
|
|
|
|
import { api } from '../../App';
|
|
import { UserRoles } from '../../enums/Roles';
|
|
import { cancelEvent } from '../../functions/events';
|
|
import { InvenTreeIcon } from '../../functions/icons';
|
|
import { useUserState } from '../../states/UserState';
|
|
import { PartThumbTable } from '../../tables/part/PartThumbTable';
|
|
import { vars } from '../../theme';
|
|
import { ActionButton } from '../buttons/ActionButton';
|
|
import { ApiImage } from '../images/ApiImage';
|
|
import { StylishText } from '../items/StylishText';
|
|
|
|
/**
|
|
* Props for detail image
|
|
*/
|
|
export type DetailImageProps = {
|
|
appRole: UserRoles;
|
|
src: string;
|
|
apiPath: string;
|
|
refresh?: () => void;
|
|
imageActions?: DetailImageButtonProps;
|
|
pk: string;
|
|
};
|
|
|
|
/**
|
|
* Actions for Detail Images.
|
|
* If true, the button type will be visible
|
|
* @param {boolean} selectExisting - PART ONLY. Allows selecting existing images as part image
|
|
* @param {boolean} uploadFile - Allows uploading a new image
|
|
* @param {boolean} deleteFile - Allows deleting the current image
|
|
*/
|
|
export type DetailImageButtonProps = {
|
|
selectExisting?: boolean;
|
|
uploadFile?: boolean;
|
|
deleteFile?: boolean;
|
|
};
|
|
|
|
// Image is expected to be 1:1 square, so only 1 dimension is needed
|
|
const IMAGE_DIMENSION = 256;
|
|
|
|
// Image to display if instance has no image
|
|
const backup_image = '/static/img/blank_image.png';
|
|
|
|
/**
|
|
* Modal used for removing/deleting the current image relation
|
|
*/
|
|
const removeModal = (apiPath: string, setImage: (image: string) => void) =>
|
|
modals.openConfirmModal({
|
|
title: <StylishText size="xl">{t`Remove Image`}</StylishText>,
|
|
children: (
|
|
<Text>
|
|
<Trans>Remove the associated image from this item?</Trans>
|
|
</Text>
|
|
),
|
|
labels: { confirm: t`Remove`, cancel: t`Cancel` },
|
|
onConfirm: async () => {
|
|
await api.patch(apiPath, { image: null });
|
|
setImage(backup_image);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Modal used for uploading a new image
|
|
*/
|
|
function UploadModal({
|
|
apiPath,
|
|
setImage
|
|
}: {
|
|
apiPath: string;
|
|
setImage: (image: string) => void;
|
|
}) {
|
|
const [currentFile, setCurrentFile] = useState<FileWithPath | null>(null);
|
|
let uploading = false;
|
|
|
|
// Components to show in the Dropzone when no file is selected
|
|
const noFileIdle = (
|
|
<Group>
|
|
<InvenTreeIcon icon="photo" iconProps={{ size: '3.2rem', stroke: 1.5 }} />
|
|
<div>
|
|
<Text size="xl" inline>
|
|
<Trans>Drag and drop to upload</Trans>
|
|
</Text>
|
|
<Text size="sm" color="dimmed" inline mt={7}>
|
|
<Trans>Click to select file(s)</Trans>
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
);
|
|
|
|
/**
|
|
* Generates components to display selected image in Dropzone
|
|
*/
|
|
const fileInfo = (file: FileWithPath) => {
|
|
const imageUrl = URL.createObjectURL(file);
|
|
const size = file.size / 1024 ** 2;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
gap: '15px',
|
|
flexGrow: '1'
|
|
}}
|
|
>
|
|
<Image
|
|
src={imageUrl}
|
|
onLoad={() => URL.revokeObjectURL(imageUrl)}
|
|
radius="sm"
|
|
height={75}
|
|
fit="contain"
|
|
style={{ flexBasis: '40%' }}
|
|
/>
|
|
<div style={{ flexBasis: '60%' }}>
|
|
<Text size="xl" inline style={{ wordBreak: 'break-all' }}>
|
|
{file.name}
|
|
</Text>
|
|
<Text size="sm" color="dimmed" inline mt={7}>
|
|
{size.toFixed(2)} MB
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Create FormData object and upload selected image
|
|
*/
|
|
const uploadImage = async (file: FileWithPath | null) => {
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
uploading = true;
|
|
const formData = new FormData();
|
|
formData.append('image', file, file.name);
|
|
|
|
const response = await api.patch(apiPath, formData);
|
|
|
|
if (response.data.image.includes(file.name)) {
|
|
setImage(response.data.image);
|
|
modals.closeAll();
|
|
}
|
|
};
|
|
|
|
const { colorScheme } = useMantineColorScheme();
|
|
|
|
const primaryColor =
|
|
vars.colors.primaryColors[colorScheme === 'dark' ? 4 : 6];
|
|
const redColor = vars.colors.red[colorScheme === 'dark' ? 4 : 6];
|
|
|
|
return (
|
|
<Paper style={{ height: '220px' }}>
|
|
<Dropzone
|
|
onDrop={(files) => setCurrentFile(files[0])}
|
|
maxFiles={1}
|
|
accept={IMAGE_MIME_TYPE}
|
|
loading={uploading}
|
|
>
|
|
<Group
|
|
justify="center"
|
|
gap="xl"
|
|
style={{ minHeight: rem(140), pointerEvents: 'none' }}
|
|
>
|
|
<Dropzone.Accept>
|
|
<InvenTreeIcon
|
|
icon="upload"
|
|
iconProps={{
|
|
size: '3.2rem',
|
|
stroke: 1.5,
|
|
color: primaryColor
|
|
}}
|
|
/>
|
|
</Dropzone.Accept>
|
|
<Dropzone.Reject>
|
|
<InvenTreeIcon
|
|
icon="reject"
|
|
iconProps={{
|
|
size: '3.2rem',
|
|
stroke: 1.5,
|
|
color: redColor
|
|
}}
|
|
/>
|
|
</Dropzone.Reject>
|
|
<Dropzone.Idle>
|
|
{currentFile ? fileInfo(currentFile) : noFileIdle}
|
|
</Dropzone.Idle>
|
|
</Group>
|
|
</Dropzone>
|
|
<Paper
|
|
style={{
|
|
position: 'sticky',
|
|
bottom: '0',
|
|
left: '0',
|
|
right: '0',
|
|
height: '60px',
|
|
zIndex: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
gap: '10px'
|
|
}}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
disabled={!currentFile}
|
|
onClick={() => setCurrentFile(null)}
|
|
>
|
|
<Trans>Clear</Trans>
|
|
</Button>
|
|
<Button
|
|
disabled={!currentFile}
|
|
onClick={() => uploadImage(currentFile)}
|
|
>
|
|
<Trans>Submit</Trans>
|
|
</Button>
|
|
</Paper>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate components for Action buttons used with the Details Image
|
|
*/
|
|
function ImageActionButtons({
|
|
actions = {},
|
|
visible,
|
|
apiPath,
|
|
hasImage,
|
|
pk,
|
|
setImage
|
|
}: {
|
|
actions?: DetailImageButtonProps;
|
|
visible: boolean;
|
|
apiPath: string;
|
|
hasImage: boolean;
|
|
pk: string;
|
|
setImage: (image: string) => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
{visible && (
|
|
<Group
|
|
gap="xs"
|
|
style={{ zIndex: 2, position: 'absolute', top: '10px', left: '10px' }}
|
|
>
|
|
{actions.selectExisting && (
|
|
<ActionButton
|
|
icon={
|
|
<InvenTreeIcon
|
|
icon="select_image"
|
|
iconProps={{ color: 'white' }}
|
|
/>
|
|
}
|
|
tooltip={t`Select from existing images`}
|
|
variant="outline"
|
|
size="lg"
|
|
tooltipAlignment="top"
|
|
onClick={(event: any) => {
|
|
cancelEvent(event);
|
|
|
|
modals.open({
|
|
title: <StylishText size="xl">{t`Select Image`}</StylishText>,
|
|
size: 'xxl',
|
|
children: <PartThumbTable pk={pk} setImage={setImage} />
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
{actions.uploadFile && (
|
|
<ActionButton
|
|
icon={
|
|
<InvenTreeIcon icon="upload" iconProps={{ color: 'white' }} />
|
|
}
|
|
tooltip={t`Upload new image`}
|
|
variant="outline"
|
|
size="lg"
|
|
tooltipAlignment="top"
|
|
onClick={(event: any) => {
|
|
cancelEvent(event);
|
|
modals.open({
|
|
title: <StylishText size="xl">{t`Upload Image`}</StylishText>,
|
|
children: (
|
|
<UploadModal apiPath={apiPath} setImage={setImage} />
|
|
)
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
{actions.deleteFile && hasImage && (
|
|
<ActionButton
|
|
icon={
|
|
<InvenTreeIcon icon="delete" iconProps={{ color: 'red' }} />
|
|
}
|
|
tooltip={t`Delete image`}
|
|
variant="outline"
|
|
size="lg"
|
|
tooltipAlignment="top"
|
|
onClick={(event: any) => {
|
|
cancelEvent(event);
|
|
removeModal(apiPath, setImage);
|
|
}}
|
|
/>
|
|
)}
|
|
</Group>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders an image with action buttons for display on Details panels
|
|
*/
|
|
export function DetailsImage(props: Readonly<DetailImageProps>) {
|
|
// Displays a group of ActionButtons on hover
|
|
const { hovered, ref } = useHover();
|
|
const [img, setImg] = useState<string>(props.src ?? backup_image);
|
|
|
|
// Sets a new image, and triggers upstream instance refresh
|
|
const setAndRefresh = (image: string) => {
|
|
setImg(image);
|
|
props.refresh && props.refresh();
|
|
};
|
|
|
|
const permissions = useUserState();
|
|
|
|
const hasOverlay: boolean = useMemo(() => {
|
|
return (
|
|
props.imageActions?.selectExisting ||
|
|
props.imageActions?.uploadFile ||
|
|
props.imageActions?.deleteFile ||
|
|
false
|
|
);
|
|
}, [props.imageActions]);
|
|
|
|
const expandImage = (event: any) => {
|
|
cancelEvent(event);
|
|
modals.open({
|
|
children: <ApiImage src={img} />,
|
|
withCloseButton: false
|
|
});
|
|
};
|
|
|
|
return (
|
|
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1} pos="relative">
|
|
<>
|
|
<ApiImage
|
|
src={img}
|
|
mah={IMAGE_DIMENSION}
|
|
maw={IMAGE_DIMENSION}
|
|
onClick={expandImage}
|
|
/>
|
|
{permissions.hasChangeRole(props.appRole) && hasOverlay && hovered && (
|
|
<Overlay color="black" opacity={0.8} onClick={expandImage}>
|
|
<ImageActionButtons
|
|
visible={hovered}
|
|
actions={props.imageActions}
|
|
apiPath={props.apiPath}
|
|
hasImage={props.src ? true : false}
|
|
pk={props.pk}
|
|
setImage={setAndRefresh}
|
|
/>
|
|
</Overlay>
|
|
)}
|
|
</>
|
|
</AspectRatio>
|
|
);
|
|
}
|