/** * @title Image Frame * @description 사용자가 업로드한 이미지를 고정된 비율과 모서리로 표시하는 컴포넌트예요. */'use client';import * as React from 'react';import { Toggle as TogglePrimitive } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: image-frame.yaml, image-frame-floater.yaml,// image-frame-indicator.yaml, image-frame-reaction-button.yamltype ImageLoadingState = 'loading' | 'loaded' | 'error';const imageFrameVariants = cva('relative isolate block overflow-hidden', { variants: { borderRadius: { none: 'rounded-none', r0_5: 'rounded-r0_5', r1: 'rounded-r1', r1_5: 'rounded-r1_5', r2: 'rounded-r2', r2_5: 'rounded-r2_5', r3: 'rounded-r3', r3_5: 'rounded-r3_5', r4: 'rounded-r4', r5: 'rounded-r5', r6: 'rounded-r6', full: 'rounded-full', }, stroke: { // stroke=true: 1px $color.stroke.neutral-subtle 를 이미지 위에 inset 으로 그려요. true: "after:pointer-events-none after:absolute after:inset-0 after:z-1 after:rounded-[inherit] after:shadow-[inset_0_0_0_1px_var(--rb-color-stroke-neutral-subtle)] after:content-['']", false: '', }, }, defaultVariants: { borderRadius: 'r2', stroke: false, },});export interface ImageFrameProps extends Omit<React.ComponentProps<'div'>, 'children'>, VariantProps<typeof imageFrameVariants> { /** 가로/세로 비율이에요. 기본값은 4/3 이에요. */ ratio?: number; src: string; alt: string; /** 이미지를 불러오지 못했거나 불러오는 중에 보여줄 요소예요. */ fallback?: React.ReactNode; loading?: 'eager' | 'lazy'; decoding?: 'async' | 'auto' | 'sync'; crossOrigin?: 'anonymous' | 'use-credentials' | ''; referrerPolicy?: React.HTMLAttributeReferrerPolicy; sizes?: string; srcSet?: string; onLoad?: React.ReactEventHandler<HTMLImageElement>; onError?: React.ReactEventHandler<HTMLImageElement>; /** ImageFrameFloater 로 감싼 오버레이 요소예요. */ children?: React.ReactNode;}function ImageFrame({ className, style, ratio = 4 / 3, borderRadius, stroke, src, alt, fallback, loading, decoding, crossOrigin, referrerPolicy, sizes, srcSet, onLoad, onError, children, ...props}: ImageFrameProps) { const imgRef = React.useRef<HTMLImageElement>(null); const [state, setState] = React.useState<ImageLoadingState>('loading'); // 프리렌더된 HTML 에서는 하이드레이션 전에 이미 load/error 가 끝났을 수 있어요. React.useEffect(() => { const img = imgRef.current; setState('loading'); if (img?.complete) setState(img.naturalWidth > 0 ? 'loaded' : 'error'); }, [src]); return ( <div data-slot="image-frame" data-loading-state={state} className={cn(imageFrameVariants({ borderRadius, stroke }), className)} style={{ aspectRatio: String(ratio), ...style }} {...props} > <img ref={imgRef} data-slot="image-frame-content" data-loading-state={state} src={src} alt={alt} loading={loading} decoding={decoding} crossOrigin={crossOrigin} referrerPolicy={referrerPolicy} sizes={sizes} srcSet={srcSet} onLoad={(e) => { setState('loaded'); onLoad?.(e); }} onError={(e) => { setState('error'); onError?.(e); }} className={cn( 'block size-full rounded-[inherit] object-cover', state === 'error' && 'hidden', state !== 'loaded' && 'pointer-events-none', )} /> {fallback && state !== 'loaded' && ( <div data-slot="image-frame-fallback" className="absolute inset-0 -z-1 size-full"> {fallback} </div> )} {children} </div> );}////////////////////////////////////////////////////////////////////////////////const imageFrameFloaterVariants = cva('absolute z-2', { variants: { placement: { 'top-start': 'top-(--floater-offset-y) left-(--floater-offset-x)', 'top-center': 'top-(--floater-offset-y) left-1/2 -translate-x-1/2', 'top-end': 'top-(--floater-offset-y) right-(--floater-offset-x)', 'middle-start': 'top-1/2 left-(--floater-offset-x) -translate-y-1/2', 'middle-center': 'top-1/2 left-1/2 -translate-1/2', 'middle-end': 'top-1/2 right-(--floater-offset-x) -translate-y-1/2', 'bottom-start': 'bottom-(--floater-offset-y) left-(--floater-offset-x)', 'bottom-center': 'bottom-(--floater-offset-y) left-1/2 -translate-x-1/2', 'bottom-end': 'bottom-(--floater-offset-y) right-(--floater-offset-x)', }, }, defaultVariants: { placement: 'bottom-end', },});export interface ImageFrameFloaterProps extends React.ComponentProps<'div'>, VariantProps<typeof imageFrameFloaterVariants> { /** 가로 여백이에요. CSS 길이 값. 기본값은 $dimension.x1_5 (6px) 예요. */ offsetX?: string | number; /** 세로 여백이에요. CSS 길이 값. 기본값은 $dimension.x1_5 (6px) 예요. */ offsetY?: string | number;}const toLength = (v: string | number) => (typeof v === 'number' ? `${v}px` : v);/** ImageFrame 안 네 모서리(또는 가운데)에 오버레이 요소를 배치해요. */function ImageFrameFloater({ className, style, placement, offsetX = 'var(--rb-dimension-x1_5)', offsetY = 'var(--rb-dimension-x1_5)', ...props}: ImageFrameFloaterProps) { return ( <div data-slot="image-frame-floater" className={cn(imageFrameFloaterVariants({ placement }), className)} style={ { '--floater-offset-x': toLength(offsetX), '--floater-offset-y': toLength(offsetY), ...style, } as React.CSSProperties } {...props} /> );}/////////////////////////////////////////////////////////////////////////////////** 이미지 개수, 영상 길이 같은 메타 정보를 보여줘요. */function ImageFrameIndicator({ className, ...props }: React.ComponentProps<'span'>) { return ( <span data-slot="image-frame-indicator" className={cn( 'box-border inline-flex items-center justify-center rounded-full bg-palette-static-black-alpha-800 px-x1_5 py-x0_5 text-t1 font-medium text-palette-static-white', className, )} {...props} /> );}////////////////////////////////////////////////////////////////////////////////export interface ImageFrameIconProps extends Omit<React.ComponentProps<'span'>, 'children'> { /** 표시할 아이콘 요소예요. (예: lucide-react 아이콘) */ svg: React.ReactNode;}/** 재생 아이콘처럼 콘텐츠 타입을 알려주는 아이콘이에요. 액션은 없어요. */function ImageFrameIcon({ className, svg, ...props }: ImageFrameIconProps) { return ( <span data-slot="image-frame-icon" className={cn( 'inline-flex items-center justify-center text-palette-static-white [&_svg]:size-x6 [&_svg]:shrink-0 [&_svg]:drop-shadow-[0_1px_2px_var(--rb-color-palette-static-black-alpha-400)]', className, )} {...props} > {svg} </span> );}////////////////////////////////////////////////////////////////////////////////const HEART_FILL_PATH = 'M15.5452 10C11.7873 10 9.25 12.9484 9.25 16.6267C9.25 19.8754 11.1219 22.0952 13.1877 23.969C13.7807 24.5069 14.4438 25.0617 15.095 25.6066C15.5434 25.9817 15.9862 26.3522 16.3967 26.7093C17.4501 27.6257 18.4191 28.557 19.1995 29.5994C19.3886 29.8518 19.6856 30.0003 20.001 30C20.3163 29.9997 20.6131 29.8507 20.8016 29.5979C21.5785 28.5562 22.5453 27.6253 23.598 26.7091C24.0105 26.35 24.4568 25.9766 24.9089 25.5984C25.5573 25.0559 26.2176 24.5035 26.807 23.9693C28.8739 22.096 30.75 19.8761 30.75 16.6267C30.75 12.9484 28.2127 10 24.4548 10C22.6365 10 21.1002 11.0545 20 12.4906C18.8998 11.0545 17.3635 10 15.5452 10Z';const HEART_OUTLINE_PATH = 'M15.5452 12C13.0342 12 11.25 13.905 11.25 16.6267C11.25 18.9912 12.5659 20.7048 14.5314 22.4876C15.1157 23.0176 15.7038 23.5087 16.3148 24.019C16.7646 24.3946 17.2269 24.7807 17.7093 25.2003C18.4947 25.8835 19.2814 26.6141 19.9988 27.4215C20.7144 26.614 21.5001 25.8836 22.2849 25.2005C22.7714 24.7771 23.2368 24.3885 23.6895 24.0105C24.2967 23.5035 24.8813 23.0154 25.4639 22.4874C27.4317 20.704 28.75 18.9906 28.75 16.6267C28.75 13.905 26.9658 12 24.4548 12C23.069 12 21.747 12.8325 20.8919 14.5189C20.7215 14.8549 20.3768 15.0667 20 15.0667C19.6233 15.0667 19.2785 14.8549 19.1081 14.5189C18.2531 12.8325 16.931 12 15.5452 12ZM9.25 16.6267C9.25 12.9484 11.7873 10 15.5452 10C17.3146 10 18.8683 10.8364 20 12.2306C21.1317 10.8364 22.6854 10 24.4548 10C28.2127 10 30.75 12.9484 30.75 16.6267C30.75 19.8761 28.8739 22.096 26.807 23.9693C26.2176 24.5035 25.5573 25.0559 24.9089 25.5984C24.4568 25.9766 24.0105 26.35 23.598 26.7091C22.5453 27.6253 21.5785 28.5562 20.8016 29.5979C20.6131 29.8507 20.3163 29.9997 20.001 30C19.6856 30.0003 19.3886 29.8518 19.1995 29.5994C18.4191 28.557 17.4501 27.6257 16.3967 26.7093C15.9862 26.3522 15.5434 25.9817 15.095 25.6066C14.4438 25.0617 13.7807 24.5069 13.1877 23.969C11.1219 22.0952 9.25 19.8754 9.25 16.6267Z';export interface ImageFrameReactionButtonProps extends Omit<React.ComponentProps<typeof TogglePrimitive.Root>, 'children' | 'asChild'> {}/** * 이미지 위에서 관심(좋아요)을 토글하는 하트 버튼이에요. * 보이는 크기는 $dimension.x6, 터치 영역은 $dimension.x10 이에요. */function ImageFrameReactionButton({ className, ...props }: ImageFrameReactionButtonProps) { return ( <TogglePrimitive.Root data-slot="image-frame-reaction-button" className={cn( 'group/reaction relative box-border inline-flex size-x6 cursor-pointer items-center justify-center border-none bg-transparent p-0', 'transition-[scale] duration-(--rb-duration-color-transition) ease-easing active:scale-[0.95]', // 터치 영역 x10 + 포커스 링(inside) "before:absolute before:-inset-x2 before:rounded-r1 before:content-[''] before:outline-2 before:-outline-offset-2 before:outline-transparent", 'outline-none focus-visible:before:outline-stroke-focus-ring', className, )} {...props} > {/* 기본 상태: 반투명 채움 + 흰 외곽선 */} <svg viewBox="8 9 24 24" aria-hidden className="pointer-events-none absolute inset-0 m-auto size-x6 text-palette-static-white drop-shadow-[0_2px_2px_var(--rb-color-palette-static-black-alpha-400)] group-data-[state=on]/reaction:hidden" > <path d={HEART_FILL_PATH} className="fill-palette-static-black-alpha-600" /> <path fillRule="evenodd" clipRule="evenodd" d={HEART_OUTLINE_PATH} fill="currentColor" /> </svg> {/* 선택 상태: 브랜드 색 채움 */} <svg viewBox="8 9 24 24" aria-hidden className="pointer-events-none absolute inset-0 m-auto hidden size-x6 drop-shadow-[0_2px_2px_var(--rb-color-palette-static-black-alpha-400)] group-data-[state=on]/reaction:block" > <path d={HEART_FILL_PATH} className="fill-bg-brand-solid" /> </svg> </TogglePrimitive.Root> );}export { ImageFrame, ImageFrameFloater, ImageFrameIndicator, ImageFrameIcon, ImageFrameReactionButton, imageFrameVariants,};