/** * @title Avatar * @description 사용자의 프로필 이미지를 표시하는 컴포넌트예요. */'use client';import * as React from 'react';import { Avatar as AvatarPrimitive, Slot } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: avatar.yaml, avatar-stack.yamltype AvatarSize = '20' | '24' | '36' | '42' | '48' | '56' | '64' | '80' | '96' | '108';type AvatarBadgeMask = 'none' | 'circle' | 'flower' | 'shield';const CIRCLE_SVG_MASK = '<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="16" cy="16" r="16" fill="white"/></svg>';const FLOWER_SVG_MASK = '<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M29.9115 8C28.4089 5.42609 25.682 4.02087 22.8994 4.10435C21.5637 1.68348 19.0037 0 15.9985 0C12.9933 0 10.4333 1.65565 9.09762 4.10435C6.32893 4.03478 3.60197 5.42609 2.09936 8C0.596754 10.5739 0.76371 13.6348 2.19675 16C0.749797 18.3652 0.596754 21.4261 2.09936 24C3.60197 26.5739 6.32893 27.9791 9.11154 27.8957C10.4472 30.3165 13.0072 32 16.0124 32C19.0176 32 21.5776 30.3443 22.9133 27.8957C25.682 27.9652 28.4089 26.5739 29.9115 24C31.4141 21.4261 31.2472 18.3652 29.8141 16C31.2611 13.6348 31.4141 10.5739 29.9115 8Z" fill="white"/></svg>';const SHIELD_SVG_MASK = '<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18.137 31.4527C18.1171 31.4616 18.0943 31.4721 18.0531 31.491C17.8282 31.5996 16.9959 32.0019 16.0001 32C15.0096 32.002 14.1895 31.6075 13.986 31.5096L13.8629 31.4527C13.5452 31.3105 13.1039 31.1059 12.5795 30.8436C11.541 30.3242 10.1273 29.554 8.68691 28.5676C7.2706 27.5976 5.67543 26.3154 4.39746 24.7323C3.12739 23.159 1.92743 20.9794 1.92743 18.3179V7.77468C1.92743 6.02645 3.02031 4.4647 4.66276 3.86583L14.5747 0.25173C15.4953 -0.0839101 16.5047 -0.0839101 17.4252 0.25173L27.3372 3.86583C28.9796 4.4647 30.0725 6.02646 30.0725 7.77468V18.3179C30.0725 20.9794 28.8726 23.159 27.6025 24.7323C26.3245 26.3154 24.7294 27.5976 23.313 28.5676C21.8727 29.554 20.4589 30.3242 19.4205 30.8436C18.8961 31.1059 18.4547 31.3106 18.137 31.4527Z" fill="white"/></svg>';const toDataUrl = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;const badgeMaskSvg: Record<Exclude<AvatarBadgeMask, 'none'>, string> = { circle: CIRCLE_SVG_MASK, flower: FLOWER_SVG_MASK, shield: SHIELD_SVG_MASK,};// 얇은 선이 남지 않도록 border-radius 대신 원형 SVG 마스크를 쓰고,// 뱃지 자리는 같은 모양의 마스크로 빼내요(SEED 와 동일).function getMaskStyle(badgeMask: AvatarBadgeMask, size: AvatarSize): React.CSSProperties { const circle = toDataUrl(CIRCLE_SVG_MASK); if (badgeMask === 'none' || size === '20') { return { WebkitMaskImage: circle, maskImage: circle, WebkitMaskSize: '100% 100%', maskSize: '100% 100%', WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat', transform: 'translateZ(0)', }; } const image = `${circle}, ${toDataUrl(badgeMaskSvg[badgeMask])}`; const maskSize = '100% 100%, var(--avatar-badge-mask-size) var(--avatar-badge-mask-size)'; const maskPosition = '0 0, var(--avatar-badge-mask-offset) var(--avatar-badge-mask-offset)'; return { WebkitMaskImage: image, maskImage: image, WebkitMaskSize: maskSize, maskSize, WebkitMaskPosition: maskPosition, maskPosition, WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat', WebkitMaskComposite: 'source-out', maskComposite: 'subtract', transform: 'translateZ(0)', };}const avatarVariants = cva( 'relative isolate box-border inline-flex shrink-0 select-none items-center justify-center rounded-full align-top', { variants: { size: { '20': 'size-[20px] [--avatar-stroke-width:1px]', '24': 'size-[24px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:12px] [--avatar-badge-mask-offset:14px] [--avatar-badge-size:10px] [--avatar-badge-offset:15px]', '36': 'size-[36px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:18px] [--avatar-badge-mask-offset:20px] [--avatar-badge-size:14px] [--avatar-badge-offset:22px]', '42': 'size-[42px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:20px] [--avatar-badge-mask-offset:24px] [--avatar-badge-size:16px] [--avatar-badge-offset:26px]', '48': 'size-[48px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:22px] [--avatar-badge-mask-offset:28px] [--avatar-badge-size:18px] [--avatar-badge-offset:30px]', '56': 'size-[56px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:24px] [--avatar-badge-mask-offset:34px] [--avatar-badge-size:20px] [--avatar-badge-offset:36px]', '64': 'size-[64px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:26px] [--avatar-badge-mask-offset:40px] [--avatar-badge-size:22px] [--avatar-badge-offset:42px]', '80': 'size-[80px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:32px] [--avatar-badge-mask-offset:52px] [--avatar-badge-size:28px] [--avatar-badge-offset:54px]', '96': 'size-[96px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:38px] [--avatar-badge-mask-offset:62px] [--avatar-badge-size:32px] [--avatar-badge-offset:65px]', '108': 'size-[108px] [--avatar-stroke-width:1px] [--avatar-badge-mask-size:44px] [--avatar-badge-mask-offset:70px] [--avatar-badge-size:36px] [--avatar-badge-offset:74px]', }, badgeMask: { none: '', circle: '', flower: '', shield: '', }, }, defaultVariants: { size: '48', badgeMask: 'none', }, },);// Avatar Stack 안에서의 item 스타일 (avatar-stack.yaml): 겹침 간격 + bg-layer-default 색 테두리const avatarStackItemVariants = cva('rounded-full bg-clip-padding', { variants: { size: { '20': 'not-first:-ml-[5px] shadow-[0_0_0_1px_var(--rb-color-bg-layer-default)] [clip-path:inset(-1px)]', '24': 'not-first:-ml-[6px] shadow-[0_0_0_1px_var(--rb-color-bg-layer-default)] [clip-path:inset(-1px)]', '36': 'not-first:-ml-[8px] shadow-[0_0_0_2px_var(--rb-color-bg-layer-default)] [clip-path:inset(-2px)]', '42': 'not-first:-ml-[10px] shadow-[0_0_0_2px_var(--rb-color-bg-layer-default)] [clip-path:inset(-2px)]', '48': 'not-first:-ml-[12px] shadow-[0_0_0_2px_var(--rb-color-bg-layer-default)] [clip-path:inset(-2px)]', '56': 'not-first:-ml-[13px] shadow-[0_0_0_3px_var(--rb-color-bg-layer-default)] [clip-path:inset(-3px)]', '64': 'not-first:-ml-[16px] shadow-[0_0_0_3px_var(--rb-color-bg-layer-default)] [clip-path:inset(-3px)]', '80': 'not-first:-ml-[20px] shadow-[0_0_0_4px_var(--rb-color-bg-layer-default)] [clip-path:inset(-4px)]', '96': 'not-first:-ml-[24px] shadow-[0_0_0_5px_var(--rb-color-bg-layer-default)] [clip-path:inset(-5px)]', '108': 'not-first:-ml-[27px] shadow-[0_0_0_5px_var(--rb-color-bg-layer-default)] [clip-path:inset(-5px)]', }, }, defaultVariants: { size: '48' },});const AvatarContext = React.createContext<{ size: AvatarSize; badgeMask: AvatarBadgeMask } | null>(null);const AvatarStackContext = React.createContext<{ size: AvatarSize } | null>(null);export interface AvatarProps extends Omit<React.ComponentProps<typeof AvatarPrimitive.Root>, 'asChild'>, VariantProps<typeof avatarVariants> { /** 프로필 이미지 주소예요. */ src?: string; /** 이미지 대체 텍스트예요. */ alt?: string; /** 이미지가 없거나 불러오지 못했을 때 보여줄 요소예요. 주로 IdentityPlaceholder 를 넣어요. */ fallback?: React.ReactNode;}function Avatar({ className, size, badgeMask, src, alt, fallback, children, style, ...props }: AvatarProps) { const stack = React.useContext(AvatarStackContext); const resolvedSize: AvatarSize = size ?? stack?.size ?? '48'; const resolvedMask: AvatarBadgeMask = badgeMask ?? 'none'; const maskStyle = getMaskStyle(resolvedMask, resolvedSize); return ( <AvatarContext.Provider value={{ size: resolvedSize, badgeMask: resolvedMask }}> <AvatarPrimitive.Root data-slot="avatar" data-size={resolvedSize} data-badge-mask={resolvedMask} className={cn( avatarVariants({ size: resolvedSize, badgeMask: resolvedMask }), stack && avatarStackItemVariants({ size: resolvedSize }), className, )} style={style} {...props} > <AvatarPrimitive.Fallback data-slot="avatar-fallback" className="absolute inset-0 -z-1 flex size-full items-center justify-center overflow-hidden rounded-full" style={maskStyle} > {fallback} </AvatarPrimitive.Fallback> {src && ( <AvatarPrimitive.Image data-slot="avatar-image" src={src} alt={alt} className="block size-full overflow-hidden object-cover" style={maskStyle} /> )} {/* 테두리(stroke): inset box-shadow 에도 같은 마스크를 걸어 뱃지 자리를 비워요. */} <span aria-hidden data-slot="avatar-stroke" className="pointer-events-none absolute inset-0 rounded-full shadow-[inset_0_0_0_var(--avatar-stroke-width)_var(--rb-color-stroke-neutral-subtle)]" style={maskStyle} /> {children} </AvatarPrimitive.Root> </AvatarContext.Provider> );}export interface AvatarBadgeProps extends React.ComponentProps<'div'> { asChild?: boolean;}/** 아바타 우측 하단에 표시되는 뱃지예요. size 20 에서는 표시되지 않아요. */function AvatarBadge({ className, asChild = false, ...props }: AvatarBadgeProps) { const ctx = React.useContext(AvatarContext); const Comp = asChild ? Slot.Root : 'div'; if (ctx?.size === '20') return null; return ( <Comp data-slot="avatar-badge" className={cn( 'absolute top-(--avatar-badge-offset) left-(--avatar-badge-offset) z-1 box-border flex size-(--avatar-badge-size) items-center justify-center', ctx?.badgeMask === 'circle' && 'rounded-full', className, )} {...props} /> );}export interface AvatarStackProps extends React.ComponentProps<'div'> { /** 스택 안 모든 아바타의 크기예요. */ size?: AvatarSize;}/** 여러 Avatar 를 겹쳐서 보여줘요. */function AvatarStack({ className, size = '48', ...props }: AvatarStackProps) { return ( <AvatarStackContext.Provider value={{ size }}> <div data-slot="avatar-stack" data-size={size} className={cn('box-border inline-flex items-center', className)} {...props} /> </AvatarStackContext.Provider> );}export { Avatar, AvatarBadge, AvatarStack, avatarVariants };export type { AvatarSize, AvatarBadgeMask };