54
common/resources/client/ui/images/avatar-group.tsx
Executable file
54
common/resources/client/ui/images/avatar-group.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import React, {Children, Fragment, ReactElement, ReactNode} from 'react';
|
||||
import {AvatarProps} from '@common/ui/images/avatar';
|
||||
import clsx from 'clsx';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
interface AvatarGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function AvatarGroup(props: AvatarGroupProps) {
|
||||
const children = Children.toArray(
|
||||
props.children
|
||||
) as ReactElement<AvatarProps>[];
|
||||
|
||||
if (!children.length) return null;
|
||||
|
||||
const firstLink = children[0].props.link;
|
||||
const label = children[0].props.label;
|
||||
|
||||
return (
|
||||
<div className={clsx('pl-10 flex isolate items-center', props.className)}>
|
||||
<Fragment>
|
||||
{children.map((avatar, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{zIndex: 5 - index}}
|
||||
className={clsx(
|
||||
'relative border-2 border-bg-alt bg-alt rounded-full -ml-10 overflow-hidden flex-shrink-0'
|
||||
)}
|
||||
>
|
||||
{avatar}
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
<div className="text-sm whitespace-nowrap ml-10">
|
||||
{firstLink && label ? (
|
||||
<Link to={firstLink} className="hover:underline">
|
||||
{label}
|
||||
</Link>
|
||||
) : null}
|
||||
{children.length > 1 && (
|
||||
<span>
|
||||
{' '}
|
||||
<Trans
|
||||
message="+ :count more"
|
||||
values={{count: children.length - 1}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
common/resources/client/ui/images/avatar.tsx
Executable file
95
common/resources/client/ui/images/avatar.tsx
Executable file
@@ -0,0 +1,95 @@
|
||||
import clsx from 'clsx';
|
||||
import React, {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {AvatarPlaceholderIcon} from '@common/auth/ui/account-settings/avatar/avatar-placeholder-icon';
|
||||
|
||||
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | string;
|
||||
|
||||
export interface AvatarProps extends ComponentPropsWithoutRef<any> {
|
||||
className?: string;
|
||||
src?: string;
|
||||
label?: string;
|
||||
circle?: boolean;
|
||||
size?: Size;
|
||||
link?: string;
|
||||
fallback?: 'initials' | 'generic';
|
||||
lazy?: boolean;
|
||||
}
|
||||
export const Avatar = forwardRef<HTMLImageElement, AvatarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
circle,
|
||||
size = 'md',
|
||||
src,
|
||||
link,
|
||||
label,
|
||||
fallback = 'generic',
|
||||
lazy = true,
|
||||
...domProps
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
let renderedAvatar = src ? (
|
||||
<img
|
||||
ref={ref}
|
||||
src={src}
|
||||
alt={label}
|
||||
loading={lazy ? 'lazy' : undefined}
|
||||
className="block h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-alt dark:bg-chip">
|
||||
<AvatarPlaceholderIcon
|
||||
viewBox="0 0 48 48"
|
||||
className="h-full w-full text-muted"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (label) {
|
||||
renderedAvatar = <Tooltip label={label}>{renderedAvatar}</Tooltip>;
|
||||
}
|
||||
|
||||
const wrapperProps: ComponentProps<any> = {
|
||||
...domProps,
|
||||
className: clsx(
|
||||
className,
|
||||
'relative block overflow-hidden select-none flex-shrink-0',
|
||||
getSizeClassName(size),
|
||||
circle ? 'rounded-full' : 'rounded',
|
||||
),
|
||||
};
|
||||
|
||||
return link ? (
|
||||
<Link {...wrapperProps} to={link}>
|
||||
{renderedAvatar}
|
||||
</Link>
|
||||
) : (
|
||||
<div {...wrapperProps}>{renderedAvatar}</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function getSizeClassName(size: Size) {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'w-18 h-18';
|
||||
case 'sm':
|
||||
return 'w-24 h-24';
|
||||
case 'md':
|
||||
return 'w-32 h-32';
|
||||
case 'lg':
|
||||
return 'w-40 h-40';
|
||||
case 'xl':
|
||||
return 'w-60 h-60';
|
||||
// allow overriding with custom classNames
|
||||
default:
|
||||
return size;
|
||||
}
|
||||
}
|
||||
13
common/resources/client/ui/images/icon.tsx
Executable file
13
common/resources/client/ui/images/icon.tsx
Executable file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
type IconProps = {
|
||||
name: string;
|
||||
className?: string;
|
||||
};
|
||||
export function Icon({name, className = 'w-24 h-24'}: IconProps) {
|
||||
return (
|
||||
<svg className={`fill-current inline-block ${className}`}>
|
||||
<use href={`/icons/merged.svg#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
63
common/resources/client/ui/images/illustrated-message.tsx
Executable file
63
common/resources/client/ui/images/illustrated-message.tsx
Executable file
@@ -0,0 +1,63 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {InputSize} from '../forms/input-field/input-size';
|
||||
|
||||
export interface IllustratedMessageProps {
|
||||
className?: string;
|
||||
size?: InputSize;
|
||||
image?: ReactNode;
|
||||
imageHeight?: string;
|
||||
imageMargin?: string;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}
|
||||
export function IllustratedMessage({
|
||||
image,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
size = 'md',
|
||||
imageHeight,
|
||||
imageMargin = 'mb-24',
|
||||
}: IllustratedMessageProps) {
|
||||
const style = getSizeClassName(size, imageHeight);
|
||||
return (
|
||||
<div className={clsx('text-center', className)}>
|
||||
{image && <div className={clsx(style.image, imageMargin)}>{image}</div>}
|
||||
{title && (
|
||||
<div className={clsx(style.title, 'mb-2 text-main')}>{title}</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className={clsx(style.description, 'text-muted')}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{action && <div className="mt-20">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSizeClassName(size: InputSize, imageHeight?: string) {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return {
|
||||
image: imageHeight || 'h-60',
|
||||
title: 'text-sm',
|
||||
description: 'text-xs',
|
||||
};
|
||||
case 'sm':
|
||||
return {
|
||||
image: imageHeight || 'h-80',
|
||||
title: 'text-base',
|
||||
description: 'text-sm',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
image: imageHeight || 'h-128',
|
||||
title: 'text-lg',
|
||||
description: 'text-base',
|
||||
};
|
||||
}
|
||||
}
|
||||
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)} />;
|
||||
}
|
||||
54
common/resources/client/ui/images/mixed-image.tsx
Executable file
54
common/resources/client/ui/images/mixed-image.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import {ComponentType, HTMLAttributes, memo} from 'react';
|
||||
import {SvgImage} from './svg-image/svg-image';
|
||||
import {SvgIconProps} from '../../icons/svg-icon';
|
||||
import {isAbsoluteUrl} from '@common/utils/urls/is-absolute-url';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLElement> {
|
||||
src: string | ComponentType<SvgIconProps>;
|
||||
className?: string;
|
||||
}
|
||||
export const MixedImage = memo(({src, className, ...domProps}: Props) => {
|
||||
let type: 'svg' | 'image' | 'icon' | null = null;
|
||||
|
||||
if (!src) {
|
||||
type = null;
|
||||
} else if (typeof src === 'object') {
|
||||
type = 'icon';
|
||||
} else if (
|
||||
(src as string).endsWith('.svg') &&
|
||||
!isAbsoluteUrl(src as string)
|
||||
) {
|
||||
type = 'svg';
|
||||
} else {
|
||||
type = 'image';
|
||||
}
|
||||
|
||||
if (type === 'svg') {
|
||||
return (
|
||||
<SvgImage
|
||||
{...domProps}
|
||||
className={className}
|
||||
src={src as string}
|
||||
height={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
return (
|
||||
<img {...domProps} className={className} src={src as string} alt="" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'icon') {
|
||||
const Icon = src;
|
||||
return (
|
||||
<Icon
|
||||
{...(domProps as HTMLAttributes<SVGElement>)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
50
common/resources/client/ui/images/svg-image/svg-image.tsx
Executable file
50
common/resources/client/ui/images/svg-image/svg-image.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
import axios from 'axios';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {memo} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {getAssetUrl} from '@common/utils/urls/get-asset-url';
|
||||
|
||||
type DangerousHtml = {__html: string} | undefined;
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
className?: string;
|
||||
height?: string | false;
|
||||
}
|
||||
export const SvgImage = memo(({src, className, height = 'h-full'}: Props) => {
|
||||
const {data: svgString} = useSvgImageContent(src);
|
||||
// render container even if image is not loaded yet, so there's
|
||||
// no layout shift if height is provided via className
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'svg-image-container inline-block bg-no-repeat',
|
||||
height,
|
||||
className,
|
||||
)}
|
||||
dangerouslySetInnerHTML={svgString}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function useSvgImageContent(src: string) {
|
||||
return useQuery({
|
||||
queryKey: ['svgImage', getAssetUrl(src)],
|
||||
queryFn: () => fetchSvgImageContent(src),
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
enabled: !!src,
|
||||
});
|
||||
}
|
||||
|
||||
function fetchSvgImageContent(src: string): Promise<DangerousHtml> {
|
||||
return axios
|
||||
.get(src, {
|
||||
responseType: 'text',
|
||||
})
|
||||
.then(response => {
|
||||
return {__html: response.data};
|
||||
});
|
||||
}
|
||||
19
common/resources/client/ui/images/user-avatar.tsx
Executable file
19
common/resources/client/ui/images/user-avatar.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {Avatar, AvatarProps} from '@common/ui/images/avatar';
|
||||
import {User} from '@common/auth/user';
|
||||
import {useContext} from 'react';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
interface UserAvatarProps extends Omit<AvatarProps, 'label' | 'src' | 'link'> {
|
||||
user?: User;
|
||||
}
|
||||
export function UserAvatar({user, ...props}: UserAvatarProps) {
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
return (
|
||||
<Avatar
|
||||
{...props}
|
||||
label={user?.display_name}
|
||||
src={user?.avatar}
|
||||
link={user?.id && auth.getUserProfileLink?.(user)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user