first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

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

View 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;
}
}

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

View 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',
};
}
}

View 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)} />;
}

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

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

View 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)}
/>
);
}