veza/apps/web/src/components/ui/avatar.tsx
senke 39a99168d9 refactor: Phase 4 — Update UI primitives to SUMI design system
- button.tsx: Remove gaming/terminal/nature/glass variants, add link variant,
  add focus-visible glow, remove scale transforms and neon shadows
- card.tsx: Remove glass/glow/glowMagenta variants, update radius to rounded-lg,
  remove hover translate transforms
- modal.tsx: Update backdrop to bg-black/60 + blur(4px), bg-popover,
  SUMI easing curves and durations
- badge.tsx: Terminal variant aliased to success, doc updates
- avatar.tsx: Already migrated, doc updates
- progress.tsx: Fix gradient colors to SUMI semantics
- input.tsx: bg-background, border-border, SUMI focus glow
- textarea.tsx: Add SUMI focus glow

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 01:58:15 +01:00

305 lines
7.2 KiB
TypeScript

import React, { useState } from 'react';
import { cn } from '@/lib/utils';
/**
* Badge configuration for the Avatar component
*/
interface AvatarBadge {
/**
* Number to display in the badge (hidden if dot is true)
*/
count?: number;
/**
* Color variant of the badge
* @default 'primary'
*/
color?: 'primary' | 'destructive' | 'success';
/**
* Show as a small dot instead of a numbered badge
* @default false
*/
dot?: boolean;
}
/**
* AvatarProps - Propriétés du composant Avatar
*
* @interface AvatarProps
*/
interface AvatarProps {
/**
* URL de l'image de l'avatar
*/
src?: string;
/**
* Texte alternatif de l'avatar
*
* @default 'Avatar'
*/
alt?: string;
/**
* Texte de fallback utilisé pour générer les initiales si pas d'image
*/
fallback?: string;
/**
* Taille de l'avatar
*
* - `xs`: 6x6 (24px)
* - `sm`: 8x8 (32px)
* - `md`: 10x10 (40px) - par défaut
* - `lg`: 12x12 (48px)
* - `xl`: 16x16 (64px)
* - `2xl`: 24x24 (96px)
* - `3xl`: 32x32 (128px)
*
* @default 'md'
*/
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
/**
* Statut de présence à afficher (indicateur coloré)
*
* - `online`: Vert (lime)
* - `offline`: Gris
* - `away`: Or
* - `idle`: Or
* - `busy`: Rouge
* - `dnd`: Rouge (do not disturb)
*/
status?: 'online' | 'offline' | 'away' | 'busy' | 'idle' | 'dnd';
/**
* Badge overlay configuration
*/
badge?: AvatarBadge;
/**
* Classes CSS personnalisées
*/
className?: string;
/**
* Fonction appelée lors du clic sur l'avatar
*/
onClick?: () => void;
}
/**
* Avatar - Composant d'avatar avec design system SUMI
*
* Composant d'avatar pour afficher des images de profil ou des initiales.
* Supporte les statuts de présence et plusieurs tailles.
*
* @example
* ```tsx
* // Avatar avec image
* <Avatar src="/avatar.jpg" alt="John Doe" />
*
* // Avatar avec initiales
* <Avatar fallback="John Doe" size="lg" />
*
* // Avatar avec statut
* <Avatar
* src="/avatar.jpg"
* alt="Jane Doe"
* status="online"
* />
*
* // Avatar avec badge
* <Avatar
* src="/avatar.jpg"
* alt="Jane Doe"
* badge={{ count: 3, color: 'destructive' }}
* />
* ```
*
* @component
* @param {AvatarProps} props - Propriétés du composant
* @returns {JSX.Element} Avatar circulaire avec image ou initiales
*/
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
(
{
src,
alt = 'Avatar',
fallback,
size = 'md',
status,
badge,
className = '',
onClick,
},
ref,
) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const sizeClasses = {
xs: 'w-6 h-6 text-[10px]',
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-16 h-16 text-lg',
'2xl': 'w-24 h-24 text-xl',
'3xl': 'w-32 h-32 text-2xl',
};
const statusColors = {
online: 'bg-success',
offline: 'bg-muted',
away: 'bg-warning',
idle: 'bg-warning',
busy: 'bg-destructive',
dnd: 'bg-destructive',
};
const statusSize = {
xs: 'w-1.5 h-1.5 border',
sm: 'w-2 h-2 border',
md: 'w-2.5 h-2.5 border-2',
lg: 'w-3 h-3 border-2',
xl: 'w-4 h-4 border-2',
'2xl': 'w-5 h-5 border-4',
'3xl': 'w-6 h-6 border-4',
};
const badgeColors = {
primary: 'bg-primary text-primary-foreground',
destructive: 'bg-destructive text-destructive-foreground',
success: 'bg-success text-success-foreground',
};
const badgeSizeClasses = {
xs: 'w-2.5 h-2.5 text-[6px]',
sm: 'w-3 h-3 text-[7px]',
md: 'w-4 h-4 text-[8px]',
lg: 'w-4.5 h-4.5 text-[9px]',
xl: 'w-5 h-5 text-[10px]',
'2xl': 'w-6 h-6 text-xs',
'3xl': 'w-8 h-8 text-sm',
};
const badgeDotSizeClasses = {
xs: 'w-1.5 h-1.5',
sm: 'w-2 h-2',
md: 'w-2.5 h-2.5',
lg: 'w-3 h-3',
xl: 'w-3.5 h-3.5',
'2xl': 'w-4 h-4',
'3xl': 'w-5 h-5',
};
const getInitials = (name?: string): string => {
if (!name) return '?';
const parts = name.trim().split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
const initials = getInitials(fallback || alt);
const showFallback = !src || hasError;
return (
<div
ref={ref}
className={cn(
'relative inline-block active:scale-95 transition-transform',
className,
onClick ? 'cursor-pointer' : '',
)}
onClick={onClick}
>
<div
className={cn(
sizeClasses[size],
'rounded-full overflow-hidden bg-card border border-border flex items-center justify-center relative',
)}
>
{showFallback ? (
<span className="font-bold text-muted-foreground">{initials}</span>
) : (
<>
{!isLoaded && (
<div
className="absolute inset-0 rounded-full bg-muted animate-pulse"
aria-hidden="true"
/>
)}
<img
src={src}
alt={alt}
onLoad={() => setIsLoaded(true)}
onError={() => {
setHasError(true);
setIsLoaded(true);
}}
className={cn(
'w-full h-full object-cover transition-opacity duration-200',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
/>
</>
)}
</div>
{status && (
<span
className={cn(
'absolute bottom-0 right-0 rounded-full border-background',
statusColors[status],
statusSize[size],
)}
></span>
)}
{badge && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 rounded-full border-2 border-background flex items-center justify-center font-bold leading-none',
badgeColors[badge.color ?? 'primary'],
badge.dot
? badgeDotSizeClasses[size]
: badgeSizeClasses[size],
)}
>
{!badge.dot && badge.count != null ? badge.count : null}
</span>
)}
</div>
);
},
);
Avatar.displayName = 'Avatar';
// Compatibility exports (for existing code using AvatarImage, AvatarFallback)
export const AvatarImage: React.FC<{
src?: string;
alt?: string;
className?: string;
}> = ({ src, alt, className }) => {
if (!src) return null;
return <img src={src} alt={alt} className={className} />;
};
export const AvatarFallback: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return <span className={className}>{children}</span>;
};
export const AvatarRoot: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return <div className={className}>{children}</div>;
};