teg/frontend/src/components/Avatar.tsx
2026-06-12 16:51:33 +02:00

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