438
common/resources/client/ui/images/image-selector.tsx
Executable file
438
common/resources/client/ui/images/image-selector.tsx
Executable file
@@ -0,0 +1,438 @@
|
||||
import React, {
|
||||
cloneElement,
|
||||
ComponentPropsWithRef,
|
||||
Fragment,
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useId,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {Button} from '../buttons/button';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {useActiveUpload} from '../../uploads/uploader/use-active-upload';
|
||||
import {UploadInputType} from '../../uploads/types/upload-input-config';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {ProgressBar} from '../progress/progress-bar';
|
||||
import {Disk} from '../../uploads/types/backend-metadata';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {Field} from '@common/ui/forms/input-field/field';
|
||||
import {
|
||||
getInputFieldClassNames,
|
||||
InputFieldStyle,
|
||||
} from '@common/ui/forms/input-field/get-input-field-class-names';
|
||||
import {FileEntry} from '@common/uploads/file-entry';
|
||||
import {useAutoFocus} from '@common/ui/focus/use-auto-focus';
|
||||
import {UploadStrategyConfig} from '@common/uploads/uploader/strategy/upload-strategy';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {AddAPhotoIcon} from '@common/icons/material/AddAPhoto';
|
||||
import {AvatarPlaceholderIcon} from '@common/auth/ui/account-settings/avatar/avatar-placeholder-icon';
|
||||
import {ButtonBaseProps} from '@common/ui/buttons/button-base';
|
||||
|
||||
const TwoMB = 2 * 1024 * 1024;
|
||||
|
||||
interface ImageSelectorProps {
|
||||
className?: string;
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
invalid?: boolean;
|
||||
errorMessage?: ReactNode;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value?: string;
|
||||
onChange?: (newValue: string) => void;
|
||||
defaultValue?: string;
|
||||
diskPrefix: string;
|
||||
showRemoveButton?: boolean;
|
||||
showEditButtonOnHover?: boolean;
|
||||
autoFocus?: boolean;
|
||||
variant?: 'input' | 'square' | 'avatar';
|
||||
placeholderIcon?: ReactElement<SvgIconProps>;
|
||||
previewSize?: string;
|
||||
previewRadius?: string;
|
||||
stretchPreview?: boolean;
|
||||
}
|
||||
export function ImageSelector({
|
||||
className,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
defaultValue,
|
||||
diskPrefix,
|
||||
showRemoveButton,
|
||||
showEditButtonOnHover = false,
|
||||
invalid,
|
||||
errorMessage,
|
||||
required,
|
||||
autoFocus,
|
||||
variant = 'input',
|
||||
previewSize = 'h-80',
|
||||
placeholderIcon,
|
||||
stretchPreview = false,
|
||||
previewRadius,
|
||||
disabled,
|
||||
}: ImageSelectorProps) {
|
||||
const {
|
||||
uploadFile,
|
||||
entry,
|
||||
uploadStatus,
|
||||
deleteEntry,
|
||||
isDeletingEntry,
|
||||
percentage,
|
||||
} = useActiveUpload();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
|
||||
const fieldId = useId();
|
||||
const labelId = label ? `${fieldId}-label` : undefined;
|
||||
const descriptionId = description ? `${fieldId}-description` : undefined;
|
||||
|
||||
const imageUrl = value || entry?.url;
|
||||
|
||||
const uploadOptions: UploadStrategyConfig = {
|
||||
showToastOnRestrictionFail: true,
|
||||
restrictions: {
|
||||
allowedFileTypes: [UploadInputType.image],
|
||||
maxFileSize: TwoMB,
|
||||
},
|
||||
metadata: {
|
||||
diskPrefix,
|
||||
disk: Disk.public,
|
||||
},
|
||||
onSuccess: (entry: FileEntry) => {
|
||||
onChange?.(entry.url);
|
||||
},
|
||||
onError: message => {
|
||||
if (message) {
|
||||
toast.danger(message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const inputFieldClassNames = getInputFieldClassNames({
|
||||
description,
|
||||
descriptionPosition: 'top',
|
||||
invalid,
|
||||
});
|
||||
|
||||
let VariantElement: JSXElementConstructor<VariantProps>;
|
||||
if (variant === 'avatar') {
|
||||
VariantElement = AvatarVariant;
|
||||
} else if (variant === 'square') {
|
||||
VariantElement = SquareVariant;
|
||||
} else {
|
||||
VariantElement = InputVariant;
|
||||
}
|
||||
|
||||
const removeButton = showRemoveButton ? (
|
||||
<Button
|
||||
variant="link"
|
||||
color="danger"
|
||||
size="xs"
|
||||
disabled={isDeletingEntry || !imageUrl || disabled}
|
||||
onClick={() => {
|
||||
deleteEntry({
|
||||
onSuccess: () => onChange?.(''),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Remove image" />
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const useDefaultButton =
|
||||
defaultValue != null && value !== defaultValue ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onChange?.(defaultValue);
|
||||
}}
|
||||
>
|
||||
<Trans message="Use default" />
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={clsx('text-sm', className)}>
|
||||
{label && (
|
||||
<div id={labelId} className={inputFieldClassNames.label}>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className={inputFieldClassNames.description}>{description}</div>
|
||||
)}
|
||||
<div aria-labelledby={labelId} aria-describedby={descriptionId}>
|
||||
<Field
|
||||
fieldClassNames={inputFieldClassNames}
|
||||
errorMessage={errorMessage}
|
||||
invalid={invalid}
|
||||
>
|
||||
<VariantElement
|
||||
inputFieldClassNames={inputFieldClassNames}
|
||||
placeholderIcon={placeholderIcon}
|
||||
previewSize={previewSize}
|
||||
isLoading={uploadStatus === 'inProgress'}
|
||||
imageUrl={imageUrl}
|
||||
removeButton={removeButton}
|
||||
useDefaultButton={useDefaultButton}
|
||||
showEditButtonOnHover={showEditButtonOnHover}
|
||||
stretchPreview={stretchPreview}
|
||||
previewRadius={previewRadius}
|
||||
handleUpload={handleUpload}
|
||||
disabled={disabled}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
// if file is already uploaded (from form or via props) set
|
||||
// required to false, otherwise farm validation will always fail
|
||||
required={imageUrl ? false : required}
|
||||
accept={UploadInputType.image}
|
||||
type="file"
|
||||
disabled={uploadStatus === 'inProgress'}
|
||||
className="sr-only"
|
||||
onChange={e => {
|
||||
if (e.target.files?.length) {
|
||||
uploadFile(e.target.files[0], uploadOptions);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</VariantElement>
|
||||
{uploadStatus === 'inProgress' && (
|
||||
<ProgressBar
|
||||
className="absolute left-0 right-0 top-0"
|
||||
size="xs"
|
||||
value={percentage}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VariantProps {
|
||||
children: ReactElement<ComponentPropsWithRef<'input'>>;
|
||||
inputFieldClassNames: InputFieldStyle;
|
||||
previewSize?: ImageSelectorProps['previewSize'];
|
||||
placeholderIcon?: ImageSelectorProps['placeholderIcon'];
|
||||
isLoading?: boolean;
|
||||
imageUrl?: string;
|
||||
removeButton?: ReactElement<ButtonBaseProps> | null;
|
||||
useDefaultButton?: ReactElement<ButtonBaseProps> | null;
|
||||
showEditButtonOnHover?: boolean;
|
||||
stretchPreview?: boolean;
|
||||
previewRadius?: string;
|
||||
handleUpload: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
function InputVariant({
|
||||
children,
|
||||
inputFieldClassNames,
|
||||
imageUrl,
|
||||
previewSize,
|
||||
stretchPreview,
|
||||
isLoading,
|
||||
handleUpload,
|
||||
removeButton,
|
||||
useDefaultButton,
|
||||
disabled,
|
||||
}: VariantProps) {
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
className={`${previewSize} relative mb-10 overflow-hidden rounded border bg-fg-base/8 p-6`}
|
||||
>
|
||||
<img
|
||||
className={clsx(
|
||||
'mx-auto h-full rounded',
|
||||
stretchPreview ? 'object-cover' : 'object-contain',
|
||||
)}
|
||||
onClick={() => handleUpload()}
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleUpload()}
|
||||
disabled={isLoading || disabled}
|
||||
className="mr-10"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="xs"
|
||||
>
|
||||
<Trans message="Replace" />
|
||||
</Button>
|
||||
{removeButton && cloneElement(removeButton, {variant: 'outline'})}
|
||||
{useDefaultButton &&
|
||||
cloneElement(useDefaultButton, {variant: 'outline'})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return cloneElement(children, {
|
||||
className: clsx(
|
||||
inputFieldClassNames.input,
|
||||
'py-8',
|
||||
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function SquareVariant({
|
||||
children,
|
||||
placeholderIcon,
|
||||
previewSize,
|
||||
imageUrl,
|
||||
stretchPreview,
|
||||
handleUpload,
|
||||
removeButton,
|
||||
useDefaultButton,
|
||||
previewRadius = 'rounded',
|
||||
showEditButtonOnHover = false,
|
||||
disabled,
|
||||
}: VariantProps) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={clsx(
|
||||
previewSize,
|
||||
previewRadius,
|
||||
!imageUrl && 'border',
|
||||
'group z-20 flex flex-col items-center justify-center gap-14 bg-fg-base/8 bg-center bg-no-repeat',
|
||||
stretchPreview ? 'bg-cover' : 'bg-contain p-6',
|
||||
)}
|
||||
style={imageUrl ? {backgroundImage: `url(${imageUrl})`} : undefined}
|
||||
onClick={() => handleUpload()}
|
||||
>
|
||||
{placeholderIcon &&
|
||||
!imageUrl &&
|
||||
cloneElement(placeholderIcon, {size: 'lg'})}
|
||||
<Button
|
||||
variant="raised"
|
||||
color="white"
|
||||
size="xs"
|
||||
className={clsx(
|
||||
showEditButtonOnHover && 'invisible group-hover:visible',
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Trans message="Replace image" />
|
||||
) : (
|
||||
<Trans message="Upload image" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
{(removeButton || useDefaultButton) && (
|
||||
<div className="mt-8">
|
||||
{removeButton && cloneElement(removeButton, {variant: 'link'})}
|
||||
{useDefaultButton &&
|
||||
cloneElement(useDefaultButton, {variant: 'link'})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarVariant({
|
||||
children,
|
||||
placeholderIcon,
|
||||
previewSize,
|
||||
isLoading,
|
||||
imageUrl,
|
||||
removeButton,
|
||||
useDefaultButton,
|
||||
handleUpload,
|
||||
previewRadius = 'rounded-full',
|
||||
disabled,
|
||||
}: VariantProps) {
|
||||
if (!placeholderIcon) {
|
||||
placeholderIcon = (
|
||||
<AvatarPlaceholderIcon
|
||||
viewBox="0 0 48 48"
|
||||
className={clsx(
|
||||
'h-full w-full bg-primary-light/40 text-primary/40',
|
||||
previewRadius,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={clsx('relative', previewSize)}
|
||||
onClick={() => handleUpload()}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
className={clsx('h-full w-full object-cover', previewRadius)}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
placeholderIcon
|
||||
)}
|
||||
<div className="absolute -bottom-6 -right-6 rounded-full bg-paper shadow-xl">
|
||||
<IconButton
|
||||
disabled={isLoading || disabled}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
color="primary"
|
||||
radius="rounded-full"
|
||||
>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
{(removeButton || useDefaultButton) && (
|
||||
<div className="mt-14">
|
||||
{removeButton && cloneElement(removeButton, {variant: 'link'})}
|
||||
{useDefaultButton &&
|
||||
cloneElement(useDefaultButton, {variant: 'link'})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormImageSelectorProps extends ImageSelectorProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormImageSelector(props: FormImageSelectorProps) {
|
||||
const {
|
||||
field: {onChange, value = null},
|
||||
fieldState: {error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<ImageSelectorProps> = {
|
||||
onChange,
|
||||
value,
|
||||
invalid: error != null,
|
||||
errorMessage: error ? <Trans message="Please select an image." /> : null,
|
||||
};
|
||||
|
||||
return <ImageSelector {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
Reference in New Issue
Block a user