190 lines
4.9 KiB
TypeScript
190 lines
4.9 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import * as React from 'react'
|
||
|
|
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||
|
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||
|
|
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
|
||
|
|
const avatarVariants = cva(
|
||
|
|
'relative flex shrink-0 overflow-hidden',
|
||
|
|
{
|
||
|
|
variants: {
|
||
|
|
size: {
|
||
|
|
xs: 'size-6 text-[10px]',
|
||
|
|
sm: 'size-8 text-xs',
|
||
|
|
default: 'size-10 text-sm',
|
||
|
|
lg: 'size-12 text-base',
|
||
|
|
xl: 'size-16 text-lg',
|
||
|
|
'2xl': 'size-24 text-2xl',
|
||
|
|
},
|
||
|
|
shape: {
|
||
|
|
circle: 'rounded-full',
|
||
|
|
square: 'rounded-lg',
|
||
|
|
hex: '[clip-path:polygon(25%_0%,75%_0%,100%_50%,75%_100%,25%_100%,0%_50%)]',
|
||
|
|
},
|
||
|
|
ring: {
|
||
|
|
none: '',
|
||
|
|
default: 'ring-2 ring-border ring-offset-2 ring-offset-background',
|
||
|
|
primary: 'ring-2 ring-primary ring-offset-2 ring-offset-background',
|
||
|
|
gradient: 'p-0.5 bg-gradient-to-br from-primary to-secondary',
|
||
|
|
story: 'p-0.5 bg-gradient-to-br from-primary via-secondary to-primary',
|
||
|
|
live: 'p-0.5 bg-secondary animate-pulse',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
defaultVariants: {
|
||
|
|
size: 'default',
|
||
|
|
shape: 'circle',
|
||
|
|
ring: 'none',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
interface AvatarProps
|
||
|
|
extends React.ComponentProps<typeof AvatarPrimitive.Root>,
|
||
|
|
VariantProps<typeof avatarVariants> {
|
||
|
|
status?: 'online' | 'away' | 'busy' | 'offline'
|
||
|
|
}
|
||
|
|
|
||
|
|
function Avatar({
|
||
|
|
className,
|
||
|
|
size,
|
||
|
|
shape,
|
||
|
|
ring,
|
||
|
|
status,
|
||
|
|
children,
|
||
|
|
...props
|
||
|
|
}: AvatarProps) {
|
||
|
|
const hasRingWithPadding = ring === 'gradient' || ring === 'story' || ring === 'live'
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn(avatarVariants({ size, shape, ring }), 'inline-flex')}>
|
||
|
|
<AvatarPrimitive.Root
|
||
|
|
data-slot="avatar"
|
||
|
|
className={cn(
|
||
|
|
'relative flex size-full shrink-0 overflow-hidden bg-muted',
|
||
|
|
shape === 'circle' && 'rounded-full',
|
||
|
|
shape === 'square' && 'rounded-md',
|
||
|
|
shape === 'hex' && '[clip-path:polygon(25%_0%,75%_0%,100%_50%,75%_100%,25%_100%,0%_50%)]',
|
||
|
|
hasRingWithPadding && 'bg-background',
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</AvatarPrimitive.Root>
|
||
|
|
|
||
|
|
{status && (
|
||
|
|
<AvatarStatus status={status} size={size} />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function AvatarImage({
|
||
|
|
className,
|
||
|
|
...props
|
||
|
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||
|
|
return (
|
||
|
|
<AvatarPrimitive.Image
|
||
|
|
data-slot="avatar-image"
|
||
|
|
className={cn('aspect-square size-full object-cover', className)}
|
||
|
|
{...props}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function AvatarFallback({
|
||
|
|
className,
|
||
|
|
...props
|
||
|
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||
|
|
return (
|
||
|
|
<AvatarPrimitive.Fallback
|
||
|
|
data-slot="avatar-fallback"
|
||
|
|
className={cn(
|
||
|
|
'flex size-full items-center justify-center bg-gradient-to-br from-primary to-secondary font-semibold text-primary-foreground',
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
{...props}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
interface AvatarStatusProps {
|
||
|
|
status: 'online' | 'away' | 'busy' | 'offline'
|
||
|
|
size?: VariantProps<typeof avatarVariants>['size']
|
||
|
|
}
|
||
|
|
|
||
|
|
function AvatarStatus({ status, size }: AvatarStatusProps) {
|
||
|
|
const statusStyles = {
|
||
|
|
online: 'bg-success shadow-[0_0_8px_oklch(0.72_0.19_145_/_0.6)]',
|
||
|
|
away: 'bg-warning',
|
||
|
|
busy: 'bg-destructive',
|
||
|
|
offline: 'bg-muted-foreground',
|
||
|
|
}
|
||
|
|
|
||
|
|
const sizeStyles = {
|
||
|
|
xs: 'size-1.5 border',
|
||
|
|
sm: 'size-2 border',
|
||
|
|
default: 'size-2.5 border-2',
|
||
|
|
lg: 'size-3 border-2',
|
||
|
|
xl: 'size-4 border-2',
|
||
|
|
'2xl': 'size-5 border-[3px]',
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<span
|
||
|
|
data-slot="avatar-status"
|
||
|
|
className={cn(
|
||
|
|
'absolute bottom-0 right-0 rounded-full border-background',
|
||
|
|
statusStyles[status],
|
||
|
|
sizeStyles[size || 'default'],
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Avatar Group for stacked avatars
|
||
|
|
interface AvatarGroupProps extends React.ComponentProps<'div'> {
|
||
|
|
max?: number
|
||
|
|
size?: VariantProps<typeof avatarVariants>['size']
|
||
|
|
}
|
||
|
|
|
||
|
|
function AvatarGroup({
|
||
|
|
className,
|
||
|
|
children,
|
||
|
|
max = 4,
|
||
|
|
size = 'default',
|
||
|
|
...props
|
||
|
|
}: AvatarGroupProps) {
|
||
|
|
const childArray = React.Children.toArray(children)
|
||
|
|
const visibleChildren = childArray.slice(0, max)
|
||
|
|
const remainingCount = childArray.length - max
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
data-slot="avatar-group"
|
||
|
|
className={cn('flex -space-x-2', className)}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{visibleChildren.map((child, index) => (
|
||
|
|
<div key={index} className="relative" style={{ zIndex: visibleChildren.length - index }}>
|
||
|
|
{React.isValidElement(child)
|
||
|
|
? React.cloneElement(child as React.ReactElement<AvatarProps>, { size, ring: 'default' })
|
||
|
|
: child
|
||
|
|
}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
{remainingCount > 0 && (
|
||
|
|
<Avatar size={size} ring="default">
|
||
|
|
<AvatarFallback className="bg-muted text-muted-foreground text-xs font-medium">
|
||
|
|
+{remainingCount}
|
||
|
|
</AvatarFallback>
|
||
|
|
</Avatar>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export { Avatar, AvatarImage, AvatarFallback, AvatarStatus, AvatarGroup, avatarVariants }
|