196 lines
4.9 KiB
TypeScript
196 lines
4.9 KiB
TypeScript
// frontend/src/components/Avatar.tsx
|
|
|
|
import type { ImgHTMLAttributes } from 'react'
|
|
|
|
export type AvatarSize = 6 | 8 | 9 | 10 | 12 | 14 | 16
|
|
export type AvatarShape = 'circle' | 'rounded'
|
|
export type AvatarStatus = 'gray' | 'red' | 'green'
|
|
export type AvatarStatusPosition = 'top' | 'bottom'
|
|
|
|
export type AvatarProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'size'> & {
|
|
src?: string
|
|
name?: string
|
|
initials?: string
|
|
size?: AvatarSize
|
|
shape?: AvatarShape
|
|
status?: AvatarStatus
|
|
statusPosition?: AvatarStatusPosition
|
|
/**
|
|
* Online-Status der Person. true = online (grün), false = offline (grau).
|
|
* Wird ignoriert, wenn `status` explizit gesetzt ist.
|
|
*/
|
|
online?: boolean
|
|
showPlaceholderIcon?: boolean
|
|
wrapperClassName?: string
|
|
}
|
|
|
|
function classNames(...classes: Array<string | false | null | undefined>) {
|
|
return classes.filter(Boolean).join(' ')
|
|
}
|
|
|
|
const sizeClassNames: Record<AvatarSize, string> = {
|
|
6: 'size-6',
|
|
8: 'size-8',
|
|
9: 'size-9',
|
|
10: 'size-10',
|
|
12: 'size-12',
|
|
14: 'size-14',
|
|
16: 'size-16',
|
|
}
|
|
|
|
const initialsSizeClassNames: Record<AvatarSize, string> = {
|
|
6: 'text-xs',
|
|
8: 'text-sm',
|
|
9: 'text-sm',
|
|
10: 'text-base',
|
|
12: 'text-lg',
|
|
14: 'text-xl',
|
|
16: 'text-2xl',
|
|
}
|
|
|
|
const statusSizeClassNames: Record<AvatarSize, string> = {
|
|
6: 'size-1.5',
|
|
8: 'size-2',
|
|
9: 'size-2',
|
|
10: 'size-2.5',
|
|
12: 'size-3',
|
|
14: 'size-3.5',
|
|
16: 'size-4',
|
|
}
|
|
|
|
const statusColorClassNames: Record<AvatarStatus, string> = {
|
|
gray: 'bg-gray-300 dark:bg-gray-500',
|
|
red: 'bg-red-400 dark:bg-red-500',
|
|
green: 'bg-green-400 dark:bg-green-500',
|
|
}
|
|
|
|
function getInitials(name?: string, initials?: string) {
|
|
if (initials) {
|
|
return initials.slice(0, 2).toUpperCase()
|
|
}
|
|
|
|
if (!name) {
|
|
return ''
|
|
}
|
|
|
|
return name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part.charAt(0))
|
|
.join('')
|
|
.toUpperCase()
|
|
}
|
|
|
|
function PlaceholderIcon({ className }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0 1 12.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 1 1-8 0 4 4 0 0 1 8 0z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export default function Avatar({
|
|
src,
|
|
alt,
|
|
name,
|
|
initials,
|
|
size = 10,
|
|
shape = 'circle',
|
|
status,
|
|
statusPosition = 'bottom',
|
|
online,
|
|
showPlaceholderIcon = true,
|
|
wrapperClassName,
|
|
className,
|
|
...props
|
|
}: AvatarProps) {
|
|
const roundedClassName = shape === 'circle' ? 'rounded-full' : 'rounded-md'
|
|
const avatarInitials = getInitials(name, initials)
|
|
|
|
// `status` hat Vorrang; ansonsten leitet sich der Punkt aus `online` ab.
|
|
const effectiveStatus: AvatarStatus | undefined =
|
|
status ?? (online === undefined ? undefined : online ? 'green' : 'gray')
|
|
const statusLabel =
|
|
online === undefined ? undefined : online ? 'Online' : 'Offline'
|
|
|
|
const avatar = src ? (
|
|
<img
|
|
{...props}
|
|
src={src}
|
|
alt={alt ?? name ?? ''}
|
|
className={classNames(
|
|
'inline-block bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
|
|
sizeClassNames[size],
|
|
roundedClassName,
|
|
className,
|
|
)}
|
|
/>
|
|
) : avatarInitials ? (
|
|
<span
|
|
className={classNames(
|
|
'inline-flex items-center justify-center bg-gray-500 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
|
|
sizeClassNames[size],
|
|
roundedClassName,
|
|
className,
|
|
)}
|
|
aria-label={name}
|
|
>
|
|
<span
|
|
className={classNames(
|
|
'font-medium text-white',
|
|
initialsSizeClassNames[size],
|
|
)}
|
|
>
|
|
{avatarInitials}
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<span
|
|
className={classNames(
|
|
'inline-block overflow-hidden bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10',
|
|
sizeClassNames[size],
|
|
roundedClassName,
|
|
className,
|
|
)}
|
|
aria-label={name}
|
|
>
|
|
{showPlaceholderIcon && (
|
|
<PlaceholderIcon className="size-full text-gray-300 dark:text-gray-600" />
|
|
)}
|
|
</span>
|
|
)
|
|
|
|
if (!effectiveStatus) {
|
|
return avatar
|
|
}
|
|
|
|
return (
|
|
<span className={classNames('relative inline-block', wrapperClassName)}>
|
|
{avatar}
|
|
|
|
<span
|
|
role={statusLabel ? 'status' : undefined}
|
|
aria-label={statusLabel}
|
|
title={statusLabel}
|
|
className={classNames(
|
|
'absolute right-0 block rounded-full ring-2 ring-white dark:ring-gray-900',
|
|
statusPosition === 'top' ? 'top-0' : 'bottom-0',
|
|
statusSizeClassNames[size],
|
|
statusColorClassNames[effectiveStatus],
|
|
shape === 'rounded' &&
|
|
statusPosition === 'top' &&
|
|
'translate-x-1/2 -translate-y-1/2 transform',
|
|
shape === 'rounded' &&
|
|
statusPosition === 'bottom' &&
|
|
'translate-x-1/2 translate-y-1/2 transform',
|
|
)}
|
|
/>
|
|
</span>
|
|
)
|
|
} |