/** * @title Attachment Input * @description 사진·영상·파일 첨부를 다루는 입력으로, 선택·미리보기·업로드 진행·실패·삭제 상태를 관리해요. */import * as React from 'react';import { Camera, Paperclip, RotateCw, Upload, X } from 'lucide-react';import { cn } from '@/lib/utils';import { ActionButton } from '@/components/ui/action-button';// SEED Rootage spec: attachment-input.yaml, attachment-input-trigger.yaml, attachment-input-item.yaml,// attachment-input-item-action-button.yaml, attachment-input-item-remove-button.yaml, attachment-input-dropzone.yaml// You may implement your own i18n for these labelsconst LABEL_SELECT_FILE = '파일 선택';const LABEL_DROP_FILE = '또는 여기로 드래그해서 업로드';const LABEL_RETRY = '재시도';const LABEL_REMOVE_FILE = '파일 제거';/* ------------------------------------------------------------------------------------------------- * Types & helpers * -----------------------------------------------------------------------------------------------*/export type AttachmentAcceptType = 'image' | 'general';export type AttachmentFileStatus = 'idle' | 'uploading' | 'error';export interface AttachmentFileEntry { id: string; file: File; /** 업로드 상태예요. 업로드 로직은 사용처에서 구현하고 이 값만 바꿔 주세요. */ status?: AttachmentFileStatus; /** 0~100. `uploading` 상태에서 진행률을 표시해요. 없으면 무한 스피너를 보여줘요. */ progress?: number; /** 이미 업로드된 파일처럼 로컬 File 대신 보여줄 썸네일 URL 이에요. */ previewUrl?: string;}let entrySeed = 0;export function createFileEntry(file: File, init?: Partial<Omit<AttachmentFileEntry, 'file'>>): AttachmentFileEntry { entrySeed += 1; return { id: `attachment-${Date.now().toString(36)}-${entrySeed}`, file, status: 'idle', ...init };}export function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; const units = ['KB', 'MB', 'GB']; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;}function getAcceptType(accept?: string): AttachmentAcceptType { if (!accept) return 'general'; const list = accept.split(',').map((s) => s.trim()).filter(Boolean); return list.length > 0 && list.every((s) => s.startsWith('image/') || s.startsWith('video/')) ? 'image' : 'general';}function matchesAccept(file: File, accept?: string) { if (!accept) return true; const name = file.name.toLowerCase(); const type = file.type.toLowerCase(); return accept .split(',') .map((s) => s.trim().toLowerCase()) .filter(Boolean) .some((rule) => { if (rule.startsWith('.')) return name.endsWith(rule); if (rule.endsWith('/*')) return type.startsWith(rule.slice(0, -1)); return type === rule; });}// SEED removeButtonMask: Remove Button 자리에 원형 구멍을 뚫어 주변 배경과 상관없이 간격이 투명하게 보이도록 해요.const CIRCLE_SVG_MASK = `url("data:image/svg+xml;utf8,${encodeURIComponent( '<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="12" fill="white"/></svg>',)}")`;const MASK_POSITION = '0 0, right calc(0px - var(--remove-button-mask-offset)) top calc(0px - var(--remove-button-mask-offset))';const MASK_SIZE = '100% 100%, var(--remove-button-mask-size) var(--remove-button-mask-size)';const removeButtonMaskStyle: React.CSSProperties = { WebkitMaskImage: `linear-gradient(black, black), ${CIRCLE_SVG_MASK}`, WebkitMaskSize: MASK_SIZE, WebkitMaskPosition: MASK_POSITION, WebkitMaskRepeat: 'no-repeat', WebkitMaskComposite: 'source-out', maskImage: `linear-gradient(black, black), ${CIRCLE_SVG_MASK}`, maskSize: MASK_SIZE, maskPosition: MASK_POSITION, maskRepeat: 'no-repeat', maskComposite: 'subtract', transform: 'translateZ(0)',};function Spinner({ value, className }: { value?: number; className?: string }) { const r = 10; const c = 2 * Math.PI * r; const determinate = typeof value === 'number'; return ( <svg viewBox="0 0 24 24" fill="none" aria-hidden className={cn('size-x6', !determinate && 'animate-spin', className)} > <circle cx="12" cy="12" r={r} stroke="currentColor" strokeOpacity="0.3" strokeWidth="2.5" /> <circle cx="12" cy="12" r={r} stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={determinate ? c * (1 - Math.min(100, Math.max(0, value)) / 100) : c * 0.75} transform="rotate(-90 12 12)" className="transition-[stroke-dashoffset] duration-(--rb-duration-d3) ease-easing" /> </svg> );}const pressable = [ 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'transition-[background-color,scale,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'enabled:active:scale-[0.97]',];/* ------------------------------------------------------------------------------------------------- * AttachmentInputTrigger * -----------------------------------------------------------------------------------------------*/export interface AttachmentInputTriggerProps extends React.ComponentProps<'button'> { type?: never; acceptType?: AttachmentAcceptType; itemCount?: number; maxItemCount?: number;}function AttachmentInputTrigger({ className, acceptType = 'general', itemCount = 0, maxItemCount, disabled, 'aria-label': ariaLabel = LABEL_SELECT_FILE, ...props}: AttachmentInputTriggerProps) { const Icon = acceptType === 'image' ? Camera : Paperclip; return ( <button type="button" data-slot="attachment-input-trigger" aria-label={ariaLabel} disabled={disabled} className={cn( 'flex size-[80px] shrink-0 cursor-pointer flex-col items-center justify-center gap-x1 rounded-r3 bg-bg-transparent p-0', 'inset-ring inset-ring-stroke-neutral-weak', pressable, 'enabled:active:bg-bg-transparent-pressed disabled:cursor-not-allowed', className, )} {...props} > <Icon aria-hidden className={cn('size-x6 shrink-0', disabled ? 'text-fg-disabled' : 'text-fg-neutral-subtle')} /> {typeof maxItemCount === 'number' && ( <span data-slot="attachment-input-trigger-item-count" className="text-t2 font-regular"> <span data-empty={itemCount === 0 || undefined} className={cn(disabled ? 'text-fg-disabled' : itemCount === 0 ? 'text-fg-neutral-subtle' : 'text-fg-neutral')} > {itemCount} </span> <span className={disabled ? 'text-fg-disabled' : 'text-fg-neutral-subtle'}>/{maxItemCount}</span> </span> )} </button> );}/* ------------------------------------------------------------------------------------------------- * AttachmentInputItem * -----------------------------------------------------------------------------------------------*/export interface AttachmentInputItemProps extends Omit<React.ComponentProps<'li'>, 'children'> { entry: AttachmentFileEntry; /** `image` 는 80px 썸네일, `general` 은 160px 파일 카드로 보여줘요. */ type?: AttachmentAcceptType; /** 썸네일 하단 배지 라벨이에요. 주로 첫 번째 항목의 "대표 사진" 에 사용해요. (image 타입 전용) */ badge?: React.ReactNode; readOnly?: boolean; disabled?: boolean; onRemove?: () => void; onRetry?: () => void;}function useObjectUrl(file: File, enabled: boolean) { const [url, setUrl] = React.useState<string>(); React.useEffect(() => { if (!enabled) return; const next = URL.createObjectURL(file); setUrl(next); return () => { URL.revokeObjectURL(next); setUrl(undefined); }; }, [file, enabled]); return url;}function AttachmentInputItem({ className, style, entry, type = 'general', badge, readOnly = false, disabled = false, onRemove, onRetry, ...props}: AttachmentInputItemProps) { const isImage = type === 'image'; const isPreviewable = isImage && entry.file.type.startsWith('image/'); const objectUrl = useObjectUrl(entry.file, isPreviewable && !entry.previewUrl); const src = entry.previewUrl ?? objectUrl; const status = entry.status ?? 'idle'; const hasOverlay = status === 'uploading' || (status === 'error' && !!onRetry); const showRemove = !readOnly && !!onRemove; const maskStyle = showRemove ? removeButtonMaskStyle : undefined; const muted = readOnly || disabled; return ( <li data-slot="attachment-input-item" data-type={type} data-status={status} data-readonly={readOnly || undefined} className={cn( 'relative isolate flex h-[80px] shrink-0 items-center justify-center gap-x2_5 rounded-r3', '[--remove-button-mask-offset:var(--rb-dimension-x1_5)] [--remove-button-mask-size:var(--rb-dimension-x6)]', isImage ? 'w-[80px] transition-opacity duration-(--rb-duration-d2)' : 'w-[160px] px-x4', isImage && muted && 'opacity-50', className, )} style={style} {...props} > {/* stroke layer */} <span aria-hidden data-slot="attachment-input-item-stroke" className={cn( 'pointer-events-none absolute inset-0 rounded-[inherit] inset-ring', isImage ? 'z-10 inset-ring-stroke-neutral-subtle' : 'inset-ring-stroke-neutral-weak', )} style={maskStyle} /> {isImage ? ( src ? ( <img data-slot="attachment-input-item-image" src={src} alt={entry.file.name} className="size-full rounded-[inherit] object-cover" style={maskStyle} /> ) : ( <span data-slot="attachment-input-item-image" className="flex size-full items-center justify-center rounded-[inherit] bg-bg-neutral-weak text-fg-neutral-subtle [&_svg]:size-x6" style={maskStyle} > <Paperclip aria-hidden /> </span> ) ) : ( !hasOverlay && ( <> <span data-slot="attachment-input-item-thumbnail" className={cn( 'flex size-x12 shrink-0 items-center justify-center rounded-r2 bg-bg-neutral-weak [&_svg]:size-x6', muted ? 'text-fg-disabled' : 'text-fg-neutral-subtle', )} > <Paperclip aria-hidden /> </span> <span data-slot="attachment-input-item-metadata" className="flex min-w-0 grow flex-col"> <span data-slot="attachment-input-item-name" className={cn( 'line-clamp-2 overflow-hidden break-all text-t2 font-medium', muted ? 'text-fg-disabled' : 'text-fg-neutral', )} > {entry.file.name} </span> <span data-slot="attachment-input-item-size" className={cn('text-t1 font-regular', muted ? 'text-fg-disabled' : 'text-fg-neutral-subtle')} > {formatBytes(entry.file.size)} </span> </span> </> ) )} {isImage && badge && status === 'idle' && ( <span data-slot="attachment-input-item-badge" className="absolute inset-x-0 bottom-0 flex h-x6 items-center justify-center rounded-b-r3 bg-bg-overlay px-x1" > <span className="truncate text-t2 font-medium text-palette-static-white">{badge}</span> </span> )} {hasOverlay && ( <span data-slot="attachment-input-item-backdrop" className={cn( 'absolute inset-0 flex flex-col items-center justify-center rounded-[inherit]', isImage && 'bg-bg-overlay', )} style={maskStyle} > {status === 'uploading' ? ( <span role="status" aria-label="업로드 중" className={isImage ? 'text-palette-static-white' : 'text-fg-neutral'}> <Spinner value={entry.progress} /> </span> ) : ( <button type="button" data-slot="attachment-input-item-action-button" onClick={onRetry} disabled={disabled} className={cn( 'flex size-full cursor-pointer flex-col items-center justify-center gap-x1 rounded-[inherit] border-none bg-transparent p-0 text-center text-t2 font-medium', '[&_svg]:size-x6', pressable, isImage ? 'text-palette-static-white' : 'text-fg-neutral-subtle', )} > <RotateCw aria-hidden /> {LABEL_RETRY} </button> )} </span> )} {showRemove && ( <button type="button" data-slot="attachment-input-item-remove-button" aria-label={`${entry.file.name} ${LABEL_REMOVE_FILE}`} onClick={onRemove} disabled={disabled} className={cn( 'absolute -top-x1 -right-x1 z-20 flex size-x5 cursor-pointer items-center justify-center rounded-full border-none bg-bg-layer-default p-0', 'inset-ring inset-ring-stroke-neutral-weak', pressable, 'enabled:active:scale-[0.95] enabled:active:bg-bg-layer-default-pressed disabled:cursor-not-allowed', '[&_svg]:size-x2_5', disabled ? 'text-fg-disabled' : 'text-fg-neutral', )} > <X aria-hidden strokeWidth={3} /> </button> )} </li> );}/* ------------------------------------------------------------------------------------------------- * AttachmentInput * -----------------------------------------------------------------------------------------------*/export interface AttachmentInputProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange' | 'children'> { /** `<input type="file">` 의 accept 와 같아요. 모두 image/video 면 Media(image) 타입이 돼요. */ accept?: string; /** @default 10 */ maxFiles?: number; value?: AttachmentFileEntry[]; defaultValue?: AttachmentFileEntry[]; onValueChange?: (entries: AttachmentFileEntry[]) => void; /** accept 또는 maxFiles 조건으로 거절된 파일을 받아요. */ onFileReject?: (files: File[]) => void; disabled?: boolean; readOnly?: boolean; /** Trigger 대신 드래그 앤 드롭 영역을 보여줘요. */ dropzone?: boolean; /** 항목별 배지를 정해요. 예: `(_, index) => index === 0 && '대표 사진'` */ getItemBadge?: (entry: AttachmentFileEntry, index: number) => React.ReactNode; /** 지정하면 `error` 상태 항목에 재시도 버튼을 보여줘요. */ onRetry?: (entry: AttachmentFileEntry) => void; name?: string; inputProps?: React.ComponentProps<'input'>;}function AttachmentInput({ className, accept, maxFiles = 10, value, defaultValue, onValueChange, onFileReject, disabled = false, readOnly = false, dropzone = false, getItemBadge, onRetry, name, inputProps, ...props}: AttachmentInputProps) { const inputRef = React.useRef<HTMLInputElement>(null); const [internal, setInternal] = React.useState<AttachmentFileEntry[]>(defaultValue ?? []); const entries = value ?? internal; const acceptType = getAcceptType(accept); const [draggingOver, setDraggingOver] = React.useState(false); const commit = (next: AttachmentFileEntry[]) => { if (value === undefined) setInternal(next); onValueChange?.(next); }; const addFiles = (files: File[]) => { if (disabled || readOnly || files.length === 0) return; const accepted: File[] = []; const rejected: File[] = []; const room = Math.max(0, maxFiles - entries.length); for (const file of files) { if (matchesAccept(file, accept) && accepted.length < room) accepted.push(file); else rejected.push(file); } if (rejected.length) onFileReject?.(rejected); if (accepted.length) commit([...entries, ...accepted.map((file) => createFileEntry(file))]); }; const remove = (id: string) => commit(entries.filter((e) => e.id !== id)); const openPicker = () => inputRef.current?.click(); const full = entries.length >= maxFiles; const items = entries.map((entry, index) => ( <AttachmentInputItem key={entry.id} entry={entry} type={acceptType} badge={getItemBadge?.(entry, index)} readOnly={readOnly} disabled={disabled} onRemove={() => remove(entry.id)} onRetry={onRetry ? () => onRetry(entry) : undefined} /> )); return ( <div data-slot="attachment-input" data-disabled={disabled || undefined} data-readonly={readOnly || undefined} className={cn('relative flex flex-col gap-x2', className)} {...props} > <input ref={inputRef} type="file" hidden name={name} accept={accept} multiple={maxFiles > 1} disabled={disabled || readOnly} {...inputProps} onChange={(event) => { addFiles(Array.from(event.currentTarget.files ?? [])); event.currentTarget.value = ''; inputProps?.onChange?.(event); }} /> {dropzone && ( <div data-slot="attachment-input-dropzone" data-dragging-over={draggingOver || undefined} data-disabled={disabled || undefined} onDragOver={(event) => { if (disabled || readOnly) return; event.preventDefault(); setDraggingOver(true); }} onDragLeave={() => setDraggingOver(false)} onDrop={(event) => { event.preventDefault(); setDraggingOver(false); addFiles(Array.from(event.dataTransfer.files)); }} className={cn( 'flex h-[120px] flex-col items-center justify-center gap-x2 rounded-r3 border border-dashed border-palette-gray-500 px-x4', 'transition-[border-color] duration-(--rb-duration-d2)', 'data-[dragging-over]:border-solid data-[dragging-over]:border-stroke-neutral-solid', 'data-[disabled]:cursor-not-allowed', )} > <ActionButton variant="neutralWeak" size="small" disabled={disabled || readOnly || full} onClick={openPicker} > <Upload /> {LABEL_SELECT_FILE} </ActionButton> <span data-slot="attachment-input-dropzone-label" className={cn('text-center text-t4 font-medium', disabled ? 'text-fg-disabled' : 'text-fg-neutral-subtle')} > {LABEL_DROP_FILE} </span> </div> )} {(!dropzone || entries.length > 0) && ( <div data-slot="attachment-input-container" className="-mt-x1 flex gap-x2 overflow-x-auto pt-x1 pr-x1 [scrollbar-width:none]" > {!dropzone && !readOnly && ( <AttachmentInputTrigger acceptType={acceptType} itemCount={entries.length} maxItemCount={maxFiles} disabled={disabled || full} onClick={openPicker} /> )} <ul data-slot="attachment-input-item-group" className="m-0 flex list-none gap-x2 p-0"> {items} </ul> </div> )} </div> );}export { AttachmentInput, AttachmentInputTrigger, AttachmentInputItem };