@poodle-kit/ui 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/components/image-uploader/image-uploader.d.mts +4 -0
- package/dist/components/image-uploader/image-uploader.d.ts +4 -0
- package/dist/components/image-uploader/image-uploader.js +471 -0
- package/dist/components/image-uploader/image-uploader.js.map +1 -0
- package/dist/components/image-uploader/image-uploader.mjs +452 -0
- package/dist/components/image-uploader/image-uploader.mjs.map +1 -0
- package/dist/image-uploader-BFvQ4s0a.d.mts +100 -0
- package/dist/image-uploader-BFvQ4s0a.d.ts +100 -0
- package/dist/index.css +557 -49
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +84 -13
- package/dist/index.d.ts +84 -13
- package/dist/index.js +976 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +975 -33
- package/dist/index.mjs.map +1 -1
- package/dist/lib/cn.d.mts +5 -0
- package/dist/lib/cn.d.ts +5 -0
- package/dist/lib/cn.js +35 -0
- package/dist/lib/cn.js.map +1 -0
- package/dist/lib/cn.mjs +10 -0
- package/dist/lib/cn.mjs.map +1 -0
- package/dist/theme/index.d.mts +287 -0
- package/dist/theme/index.d.ts +287 -0
- package/dist/theme/index.js +386 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/index.mjs +355 -0
- package/dist/theme/index.mjs.map +1 -0
- package/dist/theme/theme.css +110 -0
- package/package.json +27 -9
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/image-uploader/image-uploader.tsx","../../../src/lib/cn.ts","../../../src/icons/index.tsx","../../../src/components/image-uploader/use-image-uploader.ts"],"sourcesContent":["'use client';\r\n\r\nimport { forwardRef, useId } from 'react';\r\nimport type { ReactNode } from 'react';\r\nimport { cva, type VariantProps } from 'class-variance-authority';\r\nimport { cn } from '../../lib/cn';\r\nimport { PlusIcon, XIcon } from '../../icons';\r\nimport {\r\n useImageUploader,\r\n type ExistingImage,\r\n type NewImageFile,\r\n type UseImageUploaderOptions,\r\n} from './use-image-uploader';\r\n\r\n// ─── CVA Variants ─────────────────────────────────────────────────────────────\r\n\r\nconst imageUploaderVariants = cva('flex gap-2', {\r\n variants: {\r\n layout: {\r\n /** 가로 스크롤 행 (기본값) */\r\n row: 'flex-row flex-nowrap overflow-x-auto',\r\n /** 자동 줄바꿈 격자 */\r\n grid: 'flex-wrap',\r\n },\r\n },\r\n defaultVariants: { layout: 'row' },\r\n});\r\n\r\n// ─── Props ────────────────────────────────────────────────────────────────────\r\n\r\nexport interface ImageUploaderProps\r\n extends\r\n UseImageUploaderOptions,\r\n VariantProps<typeof imageUploaderVariants> {\r\n className?: string;\r\n /** true이면 모든 인터랙션을 비활성화해요 */\r\n disabled?: boolean;\r\n /**\r\n * 이미지 추가 버튼의 내용을 교체할 수 있어요.\r\n * 기본값은 + 아이콘과 현재/최대 개수를 표시해요.\r\n */\r\n placeholder?: ReactNode;\r\n}\r\n\r\n// ─── Private Sub-components ──────────────────────────────────────────────────\r\n\r\ninterface AddButtonProps {\r\n onClick: () => void;\r\n disabled?: boolean;\r\n current: number;\r\n max: number;\r\n children?: ReactNode;\r\n}\r\n\r\nfunction AddButton({\r\n onClick,\r\n disabled,\r\n current,\r\n max,\r\n children,\r\n}: AddButtonProps) {\r\n return (\r\n <button\r\n type=\"button\"\r\n onClick={onClick}\r\n disabled={disabled}\r\n aria-label={`이미지 추가 (${current}/${max})`}\r\n className={cn(\r\n 'shrink-0 w-20 h-20',\r\n 'flex flex-col items-center justify-center gap-1',\r\n 'rounded-lg',\r\n 'border-2 border-dashed border-border',\r\n 'bg-muted text-muted-foreground',\r\n 'cursor-pointer select-none',\r\n 'transition-colors duration-150',\r\n 'hover:bg-accent hover:text-accent-foreground hover:border-border',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n )}\r\n >\r\n {children ?? (\r\n <>\r\n <PlusIcon className=\"w-5 h-5\" />\r\n <span className=\"text-[10px] font-medium leading-none tabular-nums\">\r\n {current}/{max}\r\n </span>\r\n </>\r\n )}\r\n </button>\r\n );\r\n}\r\n\r\ninterface ImageItemProps {\r\n src: string;\r\n alt: string;\r\n onRemove: () => void;\r\n disabled?: boolean;\r\n}\r\n\r\nfunction ImageItem({ src, alt, onRemove, disabled }: ImageItemProps) {\r\n return (\r\n <div className=\"relative shrink-0 w-20 h-20 group/item\">\r\n <img\r\n src={src}\r\n alt={alt}\r\n draggable={false}\r\n className=\"w-full h-full object-cover rounded-lg\"\r\n />\r\n {!disabled && (\r\n <button\r\n type=\"button\"\r\n onClick={onRemove}\r\n aria-label={`${alt} 삭제`}\r\n className={cn(\r\n 'absolute -top-1.5 -right-1.5',\r\n 'w-5 h-5 rounded-full',\r\n 'flex items-center justify-center',\r\n 'bg-foreground text-background',\r\n 'transition-all duration-150',\r\n 'opacity-0 scale-75',\r\n 'group-hover/item:opacity-100 group-hover/item:scale-100',\r\n 'group-focus-within/item:opacity-100 group-focus-within/item:scale-100',\r\n 'hover:scale-110 active:scale-90',\r\n 'focus-visible:opacity-100 focus-visible:scale-100',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',\r\n )}\r\n >\r\n <XIcon strokeWidth=\"2.5\" />\r\n </button>\r\n )}\r\n </div>\r\n );\r\n}\r\n\r\n// ─── Main Component ───────────────────────────────────────────────────────────\r\n\r\nexport const ImageUploader = forwardRef<\r\n HTMLDivElement,\r\n ImageUploaderProps\r\n>(\r\n (\r\n {\r\n className,\r\n layout,\r\n disabled,\r\n placeholder,\r\n initialImages,\r\n maxImages = 4,\r\n maxSizeMb,\r\n accept,\r\n onFileSelect,\r\n onDeleteExisting,\r\n onError,\r\n },\r\n ref,\r\n ) => {\r\n const uid = useId();\r\n\r\n const {\r\n existingImages,\r\n newImages,\r\n totalCount,\r\n isDragActive,\r\n inputRef,\r\n openFilePicker,\r\n removeExisting,\r\n removeNew,\r\n dragHandlers,\r\n inputProps,\r\n } = useImageUploader({\r\n initialImages,\r\n maxImages,\r\n maxSizeMb,\r\n accept,\r\n onFileSelect,\r\n onDeleteExisting,\r\n onError,\r\n });\r\n\r\n const canAdd = !disabled && totalCount < maxImages;\r\n\r\n return (\r\n <div ref={ref} className={cn('flex flex-col gap-2', className)}>\r\n {/* Screen reader live region — count 변경 시 자동 공지 */}\r\n <div\r\n role=\"status\"\r\n aria-live=\"polite\"\r\n aria-atomic=\"true\"\r\n className=\"sr-only\"\r\n >\r\n {totalCount > 0\r\n ? `이미지 ${totalCount}개 선택됨. 최대 ${maxImages}개까지 추가할 수 있어요.`\r\n : `이미지를 추가해주세요. 최대 ${maxImages}개까지 추가할 수 있어요.`}\r\n </div>\r\n\r\n {/* Drag 활성 시 screen reader 긴급 공지 */}\r\n {isDragActive && (\r\n <span role=\"alert\" className=\"sr-only\">\r\n 파일을 여기에 놓아주세요\r\n </span>\r\n )}\r\n\r\n {/* 숨겨진 파일 input (탭 순서에서 제외, 버튼이 대신 처리) */}\r\n <input\r\n ref={inputRef}\r\n id={`${uid}-input`}\r\n type=\"file\"\r\n tabIndex={-1}\r\n aria-hidden=\"true\"\r\n className=\"sr-only\"\r\n disabled={disabled}\r\n {...inputProps}\r\n />\r\n\r\n {/* Drop zone + 이미지 목록 */}\r\n <div\r\n role=\"group\"\r\n aria-label={`이미지 업로더, ${totalCount}/${maxImages}개 선택됨`}\r\n className={cn(\r\n imageUploaderVariants({ layout }),\r\n 'rounded-lg p-1 -ml-1',\r\n 'transition-all duration-200',\r\n isDragActive &&\r\n 'ring-2 ring-primary ring-offset-2 bg-primary/5',\r\n )}\r\n {...(canAdd ? dragHandlers : {})}\r\n >\r\n {/* 이미지 추가 버튼 */}\r\n {canAdd && (\r\n <AddButton\r\n onClick={openFilePicker}\r\n disabled={disabled}\r\n current={totalCount}\r\n max={maxImages}\r\n >\r\n {placeholder}\r\n </AddButton>\r\n )}\r\n\r\n {/* 기존 이미지 (sequence 순 정렬) */}\r\n {sortBySequence(existingImages).map((img, index) => (\r\n <ImageItem\r\n key={`existing-${img.id}`}\r\n src={img.url}\r\n alt={`이미지 ${index + 1}`}\r\n onRemove={() => removeExisting(img.id)}\r\n disabled={disabled}\r\n />\r\n ))}\r\n\r\n {/* 새로 선택한 이미지 */}\r\n {newImages.map((item, index) => (\r\n <ImageItem\r\n key={`new-${item.id}`}\r\n src={item.preview}\r\n alt={`새 이미지 ${existingImages.length + index + 1}`}\r\n onRemove={() => removeNew(item.id)}\r\n disabled={disabled}\r\n />\r\n ))}\r\n </div>\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nImageUploader.displayName = 'ImageUploader';\r\n\r\n// ─── Helpers ──────────────────────────────────────────────────────────────────\r\n\r\nfunction sortBySequence(images: ExistingImage[]): ExistingImage[] {\r\n return [...images].sort(\r\n (a, b) => (a.sequence ?? 0) - (b.sequence ?? 0),\r\n );\r\n}\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport { imageUploaderVariants };\r\nexport type { ExistingImage, NewImageFile };\r\n","import { type ClassValue, clsx } from 'clsx';\r\nimport { twMerge } from 'tailwind-merge';\r\n\r\nexport function cn(...inputs: ClassValue[]) {\r\n return twMerge(clsx(inputs));\r\n}\r\n","import type { SVGProps } from 'react';\r\n\r\n// ─── Base ─────────────────────────────────────────────────────────────────────\r\n\r\ntype IconProps = SVGProps<SVGSVGElement>;\r\n\r\nfunction createIcon(paths: React.ReactNode, defaultSize = 24) {\r\n return function Icon({\r\n width = defaultSize,\r\n height = defaultSize,\r\n ...props\r\n }: IconProps) {\r\n return (\r\n <svg\r\n xmlns=\"http://www.w3.org/2000/svg\"\r\n width={width}\r\n height={height}\r\n viewBox=\"0 0 24 24\"\r\n fill=\"none\"\r\n stroke=\"currentColor\"\r\n strokeWidth=\"2\"\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n aria-hidden=\"true\"\r\n {...props}\r\n >\r\n {paths}\r\n </svg>\r\n );\r\n };\r\n}\r\n\r\n// ─── Icons ────────────────────────────────────────────────────────────────────\r\n\r\nexport const PlusIcon = createIcon(\r\n <>\r\n <path d=\"M5 12h14\" />\r\n <path d=\"M12 5v14\" />\r\n </>,\r\n);\r\n\r\nexport const XIcon = createIcon(\r\n <>\r\n <path d=\"M18 6 6 18\" />\r\n <path d=\"m6 6 12 12\" />\r\n </>,\r\n 12,\r\n);\r\n","'use client';\r\n\r\nimport {\r\n useCallback,\r\n useEffect,\r\n useMemo,\r\n useRef,\r\n useState,\r\n} from 'react';\r\nimport type React from 'react';\r\n\r\n// ─── Public Types ────────────────────────────────────────────────────────────\r\n\r\n/** 서버에서 받은 기존 이미지 */\r\nexport interface ExistingImage {\r\n id: number | string;\r\n url: string;\r\n /** 이미지 순서 (정렬에 사용) */\r\n sequence?: number;\r\n}\r\n\r\n/** 새로 선택한 파일 (미리보기 포함) */\r\nexport interface NewImageFile {\r\n file: File;\r\n /** `URL.createObjectURL()` 로 생성된 미리보기 URL */\r\n preview: string;\r\n /** React key / 삭제 식별자 */\r\n id: string;\r\n}\r\n\r\n/** 유효성 검사 에러 Union 타입 */\r\nexport type ImageUploaderError =\r\n | { type: 'MAX_IMAGES'; message: string; maxImages: number }\r\n | {\r\n type: 'MAX_SIZE';\r\n message: string;\r\n maxSizeMb: number;\r\n file: File;\r\n }\r\n | {\r\n type: 'INVALID_TYPE';\r\n message: string;\r\n file: File;\r\n accept: string;\r\n };\r\n\r\nexport interface UseImageUploaderOptions {\r\n /** 수정 모드에서 서버 이미지를 전달해요 */\r\n initialImages?: ExistingImage[];\r\n /** 최대 업로드 가능 이미지 수 (기본값: 4) */\r\n maxImages?: number;\r\n /** 파일 1개의 최대 크기 (MB). 미설정 시 제한 없음 */\r\n maxSizeMb?: number;\r\n /** 허용할 파일 형식 (기본값: 'image/*') */\r\n accept?: string;\r\n /** 현재 선택된 전체 File[] 배열이 변경될 때 호출돼요 */\r\n onFileSelect?: (files: File[]) => void;\r\n /** 기존 이미지가 삭제될 때 삭제된 id 배열을 전달해요 */\r\n onDeleteExisting?: (deletedIds: Array<number | string>) => void;\r\n /** 유효성 검사 실패 시 호출돼요 (toast 등 직접 처리) */\r\n onError?: (error: ImageUploaderError) => void;\r\n}\r\n\r\nexport interface UseImageUploaderReturn {\r\n /** 삭제되지 않은 기존 이미지 */\r\n existingImages: ExistingImage[];\r\n /** 새로 선택한 이미지 (미리보기 포함) */\r\n newImages: NewImageFile[];\r\n /** 기존 + 새 이미지 합산 개수 */\r\n totalCount: number;\r\n /** 드래그 활성 여부 */\r\n isDragActive: boolean;\r\n /** 파일 input 요소의 ref */\r\n inputRef: React.RefObject<HTMLInputElement>;\r\n /** 파일 선택 다이얼로그 열기 */\r\n openFilePicker: () => void;\r\n /** 기존 이미지 삭제 */\r\n removeExisting: (id: number | string) => void;\r\n /** 새로 선택한 이미지 삭제 */\r\n removeNew: (id: string) => void;\r\n /** drop zone에 spread할 drag 이벤트 핸들러 */\r\n dragHandlers: {\r\n onDragOver: React.DragEventHandler;\r\n onDragLeave: React.DragEventHandler;\r\n onDrop: React.DragEventHandler;\r\n };\r\n /** file input에 spread할 props */\r\n inputProps: {\r\n onChange: React.ChangeEventHandler<HTMLInputElement>;\r\n accept: string;\r\n multiple: boolean;\r\n };\r\n}\r\n\r\n// ─── Helpers ─────────────────────────────────────────────────────────────────\r\n\r\nfunction matchesAccept(file: File, accept: string): boolean {\r\n return accept\r\n .split(',')\r\n .map((s) => s.trim().toLowerCase())\r\n .some((pattern) => {\r\n if (pattern === '*' || pattern === '*/*') return true;\r\n if (pattern.startsWith('.'))\r\n return file.name.toLowerCase().endsWith(pattern);\r\n if (pattern.endsWith('/*'))\r\n return file.type.startsWith(pattern.slice(0, -2));\r\n return file.type === pattern;\r\n });\r\n}\r\n\r\n// ─── Hook ────────────────────────────────────────────────────────────────────\r\n\r\nexport function useImageUploader({\r\n initialImages,\r\n maxImages = 4,\r\n maxSizeMb,\r\n accept = 'image/*',\r\n onFileSelect,\r\n onDeleteExisting,\r\n onError,\r\n}: UseImageUploaderOptions): UseImageUploaderReturn {\r\n const [newImages, setNewImages] = useState<NewImageFile[]>([]);\r\n const [deletedIds, setDeletedIds] = useState<\r\n Array<number | string>\r\n >([]);\r\n const [isDragActive, setIsDragActive] = useState(false);\r\n const inputRef = useRef<HTMLInputElement>(null);\r\n\r\n // 콜백 refs — 매 렌더 후 갱신해 stale closure 없이 최신 값 참조\r\n const onFileSelectRef = useRef(onFileSelect);\r\n const onDeleteExistingRef = useRef(onDeleteExisting);\r\n const onErrorRef = useRef(onError);\r\n useEffect(() => {\r\n onFileSelectRef.current = onFileSelect;\r\n onDeleteExistingRef.current = onDeleteExisting;\r\n onErrorRef.current = onError;\r\n });\r\n\r\n // 상태 refs — 콜백 내에서 최신 상태를 직접 읽기 위해 사용\r\n const newImagesRef = useRef<NewImageFile[]>([]);\r\n const deletedIdsRef = useRef<Array<number | string>>([]);\r\n const existingCountRef = useRef(0);\r\n\r\n const existingImages = useMemo(\r\n () =>\r\n (initialImages ?? []).filter(\r\n (img) => !deletedIds.includes(img.id),\r\n ),\r\n [initialImages, deletedIds],\r\n );\r\n\r\n // 렌더 후 refs 동기화\r\n useEffect(() => {\r\n newImagesRef.current = newImages;\r\n }, [newImages]);\r\n useEffect(() => {\r\n deletedIdsRef.current = deletedIds;\r\n }, [deletedIds]);\r\n useEffect(() => {\r\n existingCountRef.current = existingImages.length;\r\n }, [existingImages.length]);\r\n\r\n // 언마운트 시 모든 preview URL 해제\r\n useEffect(() => {\r\n return () => {\r\n newImagesRef.current.forEach((item) =>\r\n URL.revokeObjectURL(item.preview),\r\n );\r\n };\r\n }, []);\r\n\r\n const processFiles = useCallback(\r\n (files: File[]) => {\r\n const currentTotal =\r\n existingCountRef.current + newImagesRef.current.length;\r\n const onErr = onErrorRef.current;\r\n const onSelect = onFileSelectRef.current;\r\n\r\n if (currentTotal >= maxImages) {\r\n onErr?.({\r\n type: 'MAX_IMAGES',\r\n message: `이미지는 최대 ${maxImages}장까지 추가할 수 있어요.`,\r\n maxImages,\r\n });\r\n return;\r\n }\r\n\r\n const remainingSlots = maxImages - currentTotal;\r\n\r\n if (files.length > remainingSlots) {\r\n onErr?.({\r\n type: 'MAX_IMAGES',\r\n message: `이미지는 최대 ${maxImages}장까지 추가할 수 있어요.`,\r\n maxImages,\r\n });\r\n }\r\n\r\n const validFiles: NewImageFile[] = [];\r\n\r\n for (const file of files.slice(0, remainingSlots)) {\r\n if (!matchesAccept(file, accept)) {\r\n onErr?.({\r\n type: 'INVALID_TYPE',\r\n message: `지원하지 않는 파일 형식이에요.`,\r\n file,\r\n accept,\r\n });\r\n continue;\r\n }\r\n\r\n if (\r\n maxSizeMb !== undefined &&\r\n file.size > maxSizeMb * 1024 * 1024\r\n ) {\r\n onErr?.({\r\n type: 'MAX_SIZE',\r\n message: `${file.name}의 크기가 ${maxSizeMb}MB를 초과해요.`,\r\n maxSizeMb,\r\n file,\r\n });\r\n continue;\r\n }\r\n\r\n validFiles.push({\r\n file,\r\n preview: URL.createObjectURL(file),\r\n id: crypto.randomUUID(),\r\n });\r\n }\r\n\r\n if (validFiles.length === 0) return;\r\n\r\n const next = [...newImagesRef.current, ...validFiles];\r\n newImagesRef.current = next;\r\n setNewImages(next);\r\n onSelect?.(next.map((item) => item.file));\r\n },\r\n [maxImages, accept, maxSizeMb],\r\n );\r\n\r\n const removeExisting = useCallback((id: number | string) => {\r\n const next = [...deletedIdsRef.current, id];\r\n deletedIdsRef.current = next;\r\n setDeletedIds(next);\r\n onDeleteExistingRef.current?.(next);\r\n }, []);\r\n\r\n const removeNew = useCallback((id: string) => {\r\n const current = newImagesRef.current;\r\n const toRemove = current.find((item) => item.id === id);\r\n if (toRemove) URL.revokeObjectURL(toRemove.preview);\r\n const next = current.filter((item) => item.id !== id);\r\n newImagesRef.current = next;\r\n setNewImages(next);\r\n onFileSelectRef.current?.(next.map((item) => item.file));\r\n }, []);\r\n\r\n const handleChange = useCallback(\r\n (e: React.ChangeEvent<HTMLInputElement>) => {\r\n if (!e.target.files) return;\r\n processFiles(Array.from(e.target.files));\r\n e.target.value = '';\r\n },\r\n [processFiles],\r\n );\r\n\r\n const handleDragOver = useCallback((e: React.DragEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setIsDragActive(true);\r\n }, []);\r\n\r\n const handleDragLeave = useCallback((e: React.DragEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n // 자식 요소로 이동하는 경우는 무시\r\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\r\n setIsDragActive(false);\r\n }, []);\r\n\r\n const handleDrop = useCallback(\r\n (e: React.DragEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setIsDragActive(false);\r\n processFiles(Array.from(e.dataTransfer.files));\r\n },\r\n [processFiles],\r\n );\r\n\r\n return {\r\n existingImages,\r\n newImages,\r\n totalCount: existingImages.length + newImages.length,\r\n isDragActive,\r\n inputRef,\r\n openFilePicker: () => inputRef.current?.click(),\r\n removeExisting,\r\n removeNew,\r\n dragHandlers: {\r\n onDragOver: handleDragOver,\r\n onDragLeave: handleDragLeave,\r\n onDrop: handleDrop,\r\n },\r\n inputProps: {\r\n onChange: handleChange,\r\n accept,\r\n multiple: true,\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":["import_react","import_jsx_runtime"]}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/components/image-uploader/image-uploader.tsx
|
|
4
|
+
import { forwardRef, useId } from "react";
|
|
5
|
+
import { cva } from "class-variance-authority";
|
|
6
|
+
|
|
7
|
+
// src/lib/cn.ts
|
|
8
|
+
import { clsx } from "clsx";
|
|
9
|
+
import { twMerge } from "tailwind-merge";
|
|
10
|
+
function cn(...inputs) {
|
|
11
|
+
return twMerge(clsx(inputs));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/icons/index.tsx
|
|
15
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
+
function createIcon(paths, defaultSize = 24) {
|
|
17
|
+
return function Icon({
|
|
18
|
+
width = defaultSize,
|
|
19
|
+
height = defaultSize,
|
|
20
|
+
...props
|
|
21
|
+
}) {
|
|
22
|
+
return /* @__PURE__ */ jsx(
|
|
23
|
+
"svg",
|
|
24
|
+
{
|
|
25
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
26
|
+
width,
|
|
27
|
+
height,
|
|
28
|
+
viewBox: "0 0 24 24",
|
|
29
|
+
fill: "none",
|
|
30
|
+
stroke: "currentColor",
|
|
31
|
+
strokeWidth: "2",
|
|
32
|
+
strokeLinecap: "round",
|
|
33
|
+
strokeLinejoin: "round",
|
|
34
|
+
"aria-hidden": "true",
|
|
35
|
+
...props,
|
|
36
|
+
children: paths
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
var PlusIcon = createIcon(
|
|
42
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
43
|
+
/* @__PURE__ */ jsx("path", { d: "M5 12h14" }),
|
|
44
|
+
/* @__PURE__ */ jsx("path", { d: "M12 5v14" })
|
|
45
|
+
] })
|
|
46
|
+
);
|
|
47
|
+
var XIcon = createIcon(
|
|
48
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
49
|
+
/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }),
|
|
50
|
+
/* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })
|
|
51
|
+
] }),
|
|
52
|
+
12
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// src/components/image-uploader/use-image-uploader.ts
|
|
56
|
+
import {
|
|
57
|
+
useCallback,
|
|
58
|
+
useEffect,
|
|
59
|
+
useMemo,
|
|
60
|
+
useRef,
|
|
61
|
+
useState
|
|
62
|
+
} from "react";
|
|
63
|
+
function matchesAccept(file, accept) {
|
|
64
|
+
return accept.split(",").map((s) => s.trim().toLowerCase()).some((pattern) => {
|
|
65
|
+
if (pattern === "*" || pattern === "*/*") return true;
|
|
66
|
+
if (pattern.startsWith("."))
|
|
67
|
+
return file.name.toLowerCase().endsWith(pattern);
|
|
68
|
+
if (pattern.endsWith("/*"))
|
|
69
|
+
return file.type.startsWith(pattern.slice(0, -2));
|
|
70
|
+
return file.type === pattern;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function useImageUploader({
|
|
74
|
+
initialImages,
|
|
75
|
+
maxImages = 4,
|
|
76
|
+
maxSizeMb,
|
|
77
|
+
accept = "image/*",
|
|
78
|
+
onFileSelect,
|
|
79
|
+
onDeleteExisting,
|
|
80
|
+
onError
|
|
81
|
+
}) {
|
|
82
|
+
const [newImages, setNewImages] = useState([]);
|
|
83
|
+
const [deletedIds, setDeletedIds] = useState([]);
|
|
84
|
+
const [isDragActive, setIsDragActive] = useState(false);
|
|
85
|
+
const inputRef = useRef(null);
|
|
86
|
+
const onFileSelectRef = useRef(onFileSelect);
|
|
87
|
+
const onDeleteExistingRef = useRef(onDeleteExisting);
|
|
88
|
+
const onErrorRef = useRef(onError);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
onFileSelectRef.current = onFileSelect;
|
|
91
|
+
onDeleteExistingRef.current = onDeleteExisting;
|
|
92
|
+
onErrorRef.current = onError;
|
|
93
|
+
});
|
|
94
|
+
const newImagesRef = useRef([]);
|
|
95
|
+
const deletedIdsRef = useRef([]);
|
|
96
|
+
const existingCountRef = useRef(0);
|
|
97
|
+
const existingImages = useMemo(
|
|
98
|
+
() => (initialImages != null ? initialImages : []).filter(
|
|
99
|
+
(img) => !deletedIds.includes(img.id)
|
|
100
|
+
),
|
|
101
|
+
[initialImages, deletedIds]
|
|
102
|
+
);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
newImagesRef.current = newImages;
|
|
105
|
+
}, [newImages]);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
deletedIdsRef.current = deletedIds;
|
|
108
|
+
}, [deletedIds]);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
existingCountRef.current = existingImages.length;
|
|
111
|
+
}, [existingImages.length]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
return () => {
|
|
114
|
+
newImagesRef.current.forEach(
|
|
115
|
+
(item) => URL.revokeObjectURL(item.preview)
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
}, []);
|
|
119
|
+
const processFiles = useCallback(
|
|
120
|
+
(files) => {
|
|
121
|
+
const currentTotal = existingCountRef.current + newImagesRef.current.length;
|
|
122
|
+
const onErr = onErrorRef.current;
|
|
123
|
+
const onSelect = onFileSelectRef.current;
|
|
124
|
+
if (currentTotal >= maxImages) {
|
|
125
|
+
onErr == null ? void 0 : onErr({
|
|
126
|
+
type: "MAX_IMAGES",
|
|
127
|
+
message: `\uC774\uBBF8\uC9C0\uB294 \uCD5C\uB300 ${maxImages}\uC7A5\uAE4C\uC9C0 \uCD94\uAC00\uD560 \uC218 \uC788\uC5B4\uC694.`,
|
|
128
|
+
maxImages
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const remainingSlots = maxImages - currentTotal;
|
|
133
|
+
if (files.length > remainingSlots) {
|
|
134
|
+
onErr == null ? void 0 : onErr({
|
|
135
|
+
type: "MAX_IMAGES",
|
|
136
|
+
message: `\uC774\uBBF8\uC9C0\uB294 \uCD5C\uB300 ${maxImages}\uC7A5\uAE4C\uC9C0 \uCD94\uAC00\uD560 \uC218 \uC788\uC5B4\uC694.`,
|
|
137
|
+
maxImages
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const validFiles = [];
|
|
141
|
+
for (const file of files.slice(0, remainingSlots)) {
|
|
142
|
+
if (!matchesAccept(file, accept)) {
|
|
143
|
+
onErr == null ? void 0 : onErr({
|
|
144
|
+
type: "INVALID_TYPE",
|
|
145
|
+
message: `\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uD30C\uC77C \uD615\uC2DD\uC774\uC5D0\uC694.`,
|
|
146
|
+
file,
|
|
147
|
+
accept
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (maxSizeMb !== void 0 && file.size > maxSizeMb * 1024 * 1024) {
|
|
152
|
+
onErr == null ? void 0 : onErr({
|
|
153
|
+
type: "MAX_SIZE",
|
|
154
|
+
message: `${file.name}\uC758 \uD06C\uAE30\uAC00 ${maxSizeMb}MB\uB97C \uCD08\uACFC\uD574\uC694.`,
|
|
155
|
+
maxSizeMb,
|
|
156
|
+
file
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
validFiles.push({
|
|
161
|
+
file,
|
|
162
|
+
preview: URL.createObjectURL(file),
|
|
163
|
+
id: crypto.randomUUID()
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (validFiles.length === 0) return;
|
|
167
|
+
const next = [...newImagesRef.current, ...validFiles];
|
|
168
|
+
newImagesRef.current = next;
|
|
169
|
+
setNewImages(next);
|
|
170
|
+
onSelect == null ? void 0 : onSelect(next.map((item) => item.file));
|
|
171
|
+
},
|
|
172
|
+
[maxImages, accept, maxSizeMb]
|
|
173
|
+
);
|
|
174
|
+
const removeExisting = useCallback((id) => {
|
|
175
|
+
var _a;
|
|
176
|
+
const next = [...deletedIdsRef.current, id];
|
|
177
|
+
deletedIdsRef.current = next;
|
|
178
|
+
setDeletedIds(next);
|
|
179
|
+
(_a = onDeleteExistingRef.current) == null ? void 0 : _a.call(onDeleteExistingRef, next);
|
|
180
|
+
}, []);
|
|
181
|
+
const removeNew = useCallback((id) => {
|
|
182
|
+
var _a;
|
|
183
|
+
const current = newImagesRef.current;
|
|
184
|
+
const toRemove = current.find((item) => item.id === id);
|
|
185
|
+
if (toRemove) URL.revokeObjectURL(toRemove.preview);
|
|
186
|
+
const next = current.filter((item) => item.id !== id);
|
|
187
|
+
newImagesRef.current = next;
|
|
188
|
+
setNewImages(next);
|
|
189
|
+
(_a = onFileSelectRef.current) == null ? void 0 : _a.call(onFileSelectRef, next.map((item) => item.file));
|
|
190
|
+
}, []);
|
|
191
|
+
const handleChange = useCallback(
|
|
192
|
+
(e) => {
|
|
193
|
+
if (!e.target.files) return;
|
|
194
|
+
processFiles(Array.from(e.target.files));
|
|
195
|
+
e.target.value = "";
|
|
196
|
+
},
|
|
197
|
+
[processFiles]
|
|
198
|
+
);
|
|
199
|
+
const handleDragOver = useCallback((e) => {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
e.stopPropagation();
|
|
202
|
+
setIsDragActive(true);
|
|
203
|
+
}, []);
|
|
204
|
+
const handleDragLeave = useCallback((e) => {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
e.stopPropagation();
|
|
207
|
+
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
208
|
+
setIsDragActive(false);
|
|
209
|
+
}, []);
|
|
210
|
+
const handleDrop = useCallback(
|
|
211
|
+
(e) => {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
setIsDragActive(false);
|
|
215
|
+
processFiles(Array.from(e.dataTransfer.files));
|
|
216
|
+
},
|
|
217
|
+
[processFiles]
|
|
218
|
+
);
|
|
219
|
+
return {
|
|
220
|
+
existingImages,
|
|
221
|
+
newImages,
|
|
222
|
+
totalCount: existingImages.length + newImages.length,
|
|
223
|
+
isDragActive,
|
|
224
|
+
inputRef,
|
|
225
|
+
openFilePicker: () => {
|
|
226
|
+
var _a;
|
|
227
|
+
return (_a = inputRef.current) == null ? void 0 : _a.click();
|
|
228
|
+
},
|
|
229
|
+
removeExisting,
|
|
230
|
+
removeNew,
|
|
231
|
+
dragHandlers: {
|
|
232
|
+
onDragOver: handleDragOver,
|
|
233
|
+
onDragLeave: handleDragLeave,
|
|
234
|
+
onDrop: handleDrop
|
|
235
|
+
},
|
|
236
|
+
inputProps: {
|
|
237
|
+
onChange: handleChange,
|
|
238
|
+
accept,
|
|
239
|
+
multiple: true
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/components/image-uploader/image-uploader.tsx
|
|
245
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
246
|
+
var imageUploaderVariants = cva("flex gap-2", {
|
|
247
|
+
variants: {
|
|
248
|
+
layout: {
|
|
249
|
+
/** 가로 스크롤 행 (기본값) */
|
|
250
|
+
row: "flex-row flex-nowrap overflow-x-auto",
|
|
251
|
+
/** 자동 줄바꿈 격자 */
|
|
252
|
+
grid: "flex-wrap"
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
defaultVariants: { layout: "row" }
|
|
256
|
+
});
|
|
257
|
+
function AddButton({
|
|
258
|
+
onClick,
|
|
259
|
+
disabled,
|
|
260
|
+
current,
|
|
261
|
+
max,
|
|
262
|
+
children
|
|
263
|
+
}) {
|
|
264
|
+
return /* @__PURE__ */ jsx2(
|
|
265
|
+
"button",
|
|
266
|
+
{
|
|
267
|
+
type: "button",
|
|
268
|
+
onClick,
|
|
269
|
+
disabled,
|
|
270
|
+
"aria-label": `\uC774\uBBF8\uC9C0 \uCD94\uAC00 (${current}/${max})`,
|
|
271
|
+
className: cn(
|
|
272
|
+
"shrink-0 w-20 h-20",
|
|
273
|
+
"flex flex-col items-center justify-center gap-1",
|
|
274
|
+
"rounded-lg",
|
|
275
|
+
"border-2 border-dashed border-border",
|
|
276
|
+
"bg-muted text-muted-foreground",
|
|
277
|
+
"cursor-pointer select-none",
|
|
278
|
+
"transition-colors duration-150",
|
|
279
|
+
"hover:bg-accent hover:text-accent-foreground hover:border-border",
|
|
280
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
281
|
+
"disabled:pointer-events-none disabled:opacity-50"
|
|
282
|
+
),
|
|
283
|
+
children: children != null ? children : /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
284
|
+
/* @__PURE__ */ jsx2(PlusIcon, { className: "w-5 h-5" }),
|
|
285
|
+
/* @__PURE__ */ jsxs2("span", { className: "text-[10px] font-medium leading-none tabular-nums", children: [
|
|
286
|
+
current,
|
|
287
|
+
"/",
|
|
288
|
+
max
|
|
289
|
+
] })
|
|
290
|
+
] })
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
function ImageItem({ src, alt, onRemove, disabled }) {
|
|
295
|
+
return /* @__PURE__ */ jsxs2("div", { className: "relative shrink-0 w-20 h-20 group/item", children: [
|
|
296
|
+
/* @__PURE__ */ jsx2(
|
|
297
|
+
"img",
|
|
298
|
+
{
|
|
299
|
+
src,
|
|
300
|
+
alt,
|
|
301
|
+
draggable: false,
|
|
302
|
+
className: "w-full h-full object-cover rounded-lg"
|
|
303
|
+
}
|
|
304
|
+
),
|
|
305
|
+
!disabled && /* @__PURE__ */ jsx2(
|
|
306
|
+
"button",
|
|
307
|
+
{
|
|
308
|
+
type: "button",
|
|
309
|
+
onClick: onRemove,
|
|
310
|
+
"aria-label": `${alt} \uC0AD\uC81C`,
|
|
311
|
+
className: cn(
|
|
312
|
+
"absolute -top-1.5 -right-1.5",
|
|
313
|
+
"w-5 h-5 rounded-full",
|
|
314
|
+
"flex items-center justify-center",
|
|
315
|
+
"bg-foreground text-background",
|
|
316
|
+
"transition-all duration-150",
|
|
317
|
+
"opacity-0 scale-75",
|
|
318
|
+
"group-hover/item:opacity-100 group-hover/item:scale-100",
|
|
319
|
+
"group-focus-within/item:opacity-100 group-focus-within/item:scale-100",
|
|
320
|
+
"hover:scale-110 active:scale-90",
|
|
321
|
+
"focus-visible:opacity-100 focus-visible:scale-100",
|
|
322
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
323
|
+
),
|
|
324
|
+
children: /* @__PURE__ */ jsx2(XIcon, { strokeWidth: "2.5" })
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
] });
|
|
328
|
+
}
|
|
329
|
+
var ImageUploader = forwardRef(
|
|
330
|
+
({
|
|
331
|
+
className,
|
|
332
|
+
layout,
|
|
333
|
+
disabled,
|
|
334
|
+
placeholder,
|
|
335
|
+
initialImages,
|
|
336
|
+
maxImages = 4,
|
|
337
|
+
maxSizeMb,
|
|
338
|
+
accept,
|
|
339
|
+
onFileSelect,
|
|
340
|
+
onDeleteExisting,
|
|
341
|
+
onError
|
|
342
|
+
}, ref) => {
|
|
343
|
+
const uid = useId();
|
|
344
|
+
const {
|
|
345
|
+
existingImages,
|
|
346
|
+
newImages,
|
|
347
|
+
totalCount,
|
|
348
|
+
isDragActive,
|
|
349
|
+
inputRef,
|
|
350
|
+
openFilePicker,
|
|
351
|
+
removeExisting,
|
|
352
|
+
removeNew,
|
|
353
|
+
dragHandlers,
|
|
354
|
+
inputProps
|
|
355
|
+
} = useImageUploader({
|
|
356
|
+
initialImages,
|
|
357
|
+
maxImages,
|
|
358
|
+
maxSizeMb,
|
|
359
|
+
accept,
|
|
360
|
+
onFileSelect,
|
|
361
|
+
onDeleteExisting,
|
|
362
|
+
onError
|
|
363
|
+
});
|
|
364
|
+
const canAdd = !disabled && totalCount < maxImages;
|
|
365
|
+
return /* @__PURE__ */ jsxs2("div", { ref, className: cn("flex flex-col gap-2", className), children: [
|
|
366
|
+
/* @__PURE__ */ jsx2(
|
|
367
|
+
"div",
|
|
368
|
+
{
|
|
369
|
+
role: "status",
|
|
370
|
+
"aria-live": "polite",
|
|
371
|
+
"aria-atomic": "true",
|
|
372
|
+
className: "sr-only",
|
|
373
|
+
children: totalCount > 0 ? `\uC774\uBBF8\uC9C0 ${totalCount}\uAC1C \uC120\uD0DD\uB428. \uCD5C\uB300 ${maxImages}\uAC1C\uAE4C\uC9C0 \uCD94\uAC00\uD560 \uC218 \uC788\uC5B4\uC694.` : `\uC774\uBBF8\uC9C0\uB97C \uCD94\uAC00\uD574\uC8FC\uC138\uC694. \uCD5C\uB300 ${maxImages}\uAC1C\uAE4C\uC9C0 \uCD94\uAC00\uD560 \uC218 \uC788\uC5B4\uC694.`
|
|
374
|
+
}
|
|
375
|
+
),
|
|
376
|
+
isDragActive && /* @__PURE__ */ jsx2("span", { role: "alert", className: "sr-only", children: "\uD30C\uC77C\uC744 \uC5EC\uAE30\uC5D0 \uB193\uC544\uC8FC\uC138\uC694" }),
|
|
377
|
+
/* @__PURE__ */ jsx2(
|
|
378
|
+
"input",
|
|
379
|
+
{
|
|
380
|
+
ref: inputRef,
|
|
381
|
+
id: `${uid}-input`,
|
|
382
|
+
type: "file",
|
|
383
|
+
tabIndex: -1,
|
|
384
|
+
"aria-hidden": "true",
|
|
385
|
+
className: "sr-only",
|
|
386
|
+
disabled,
|
|
387
|
+
...inputProps
|
|
388
|
+
}
|
|
389
|
+
),
|
|
390
|
+
/* @__PURE__ */ jsxs2(
|
|
391
|
+
"div",
|
|
392
|
+
{
|
|
393
|
+
role: "group",
|
|
394
|
+
"aria-label": `\uC774\uBBF8\uC9C0 \uC5C5\uB85C\uB354, ${totalCount}/${maxImages}\uAC1C \uC120\uD0DD\uB428`,
|
|
395
|
+
className: cn(
|
|
396
|
+
imageUploaderVariants({ layout }),
|
|
397
|
+
"rounded-lg p-1 -ml-1",
|
|
398
|
+
"transition-all duration-200",
|
|
399
|
+
isDragActive && "ring-2 ring-primary ring-offset-2 bg-primary/5"
|
|
400
|
+
),
|
|
401
|
+
...canAdd ? dragHandlers : {},
|
|
402
|
+
children: [
|
|
403
|
+
canAdd && /* @__PURE__ */ jsx2(
|
|
404
|
+
AddButton,
|
|
405
|
+
{
|
|
406
|
+
onClick: openFilePicker,
|
|
407
|
+
disabled,
|
|
408
|
+
current: totalCount,
|
|
409
|
+
max: maxImages,
|
|
410
|
+
children: placeholder
|
|
411
|
+
}
|
|
412
|
+
),
|
|
413
|
+
sortBySequence(existingImages).map((img, index) => /* @__PURE__ */ jsx2(
|
|
414
|
+
ImageItem,
|
|
415
|
+
{
|
|
416
|
+
src: img.url,
|
|
417
|
+
alt: `\uC774\uBBF8\uC9C0 ${index + 1}`,
|
|
418
|
+
onRemove: () => removeExisting(img.id),
|
|
419
|
+
disabled
|
|
420
|
+
},
|
|
421
|
+
`existing-${img.id}`
|
|
422
|
+
)),
|
|
423
|
+
newImages.map((item, index) => /* @__PURE__ */ jsx2(
|
|
424
|
+
ImageItem,
|
|
425
|
+
{
|
|
426
|
+
src: item.preview,
|
|
427
|
+
alt: `\uC0C8 \uC774\uBBF8\uC9C0 ${existingImages.length + index + 1}`,
|
|
428
|
+
onRemove: () => removeNew(item.id),
|
|
429
|
+
disabled
|
|
430
|
+
},
|
|
431
|
+
`new-${item.id}`
|
|
432
|
+
))
|
|
433
|
+
]
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
] });
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
ImageUploader.displayName = "ImageUploader";
|
|
440
|
+
function sortBySequence(images) {
|
|
441
|
+
return [...images].sort(
|
|
442
|
+
(a, b) => {
|
|
443
|
+
var _a, _b;
|
|
444
|
+
return ((_a = a.sequence) != null ? _a : 0) - ((_b = b.sequence) != null ? _b : 0);
|
|
445
|
+
}
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
export {
|
|
449
|
+
ImageUploader,
|
|
450
|
+
imageUploaderVariants
|
|
451
|
+
};
|
|
452
|
+
//# sourceMappingURL=image-uploader.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/image-uploader/image-uploader.tsx","../../../src/lib/cn.ts","../../../src/icons/index.tsx","../../../src/components/image-uploader/use-image-uploader.ts"],"sourcesContent":["'use client';\r\n\r\nimport { forwardRef, useId } from 'react';\r\nimport type { ReactNode } from 'react';\r\nimport { cva, type VariantProps } from 'class-variance-authority';\r\nimport { cn } from '../../lib/cn';\r\nimport { PlusIcon, XIcon } from '../../icons';\r\nimport {\r\n useImageUploader,\r\n type ExistingImage,\r\n type NewImageFile,\r\n type UseImageUploaderOptions,\r\n} from './use-image-uploader';\r\n\r\n// ─── CVA Variants ─────────────────────────────────────────────────────────────\r\n\r\nconst imageUploaderVariants = cva('flex gap-2', {\r\n variants: {\r\n layout: {\r\n /** 가로 스크롤 행 (기본값) */\r\n row: 'flex-row flex-nowrap overflow-x-auto',\r\n /** 자동 줄바꿈 격자 */\r\n grid: 'flex-wrap',\r\n },\r\n },\r\n defaultVariants: { layout: 'row' },\r\n});\r\n\r\n// ─── Props ────────────────────────────────────────────────────────────────────\r\n\r\nexport interface ImageUploaderProps\r\n extends\r\n UseImageUploaderOptions,\r\n VariantProps<typeof imageUploaderVariants> {\r\n className?: string;\r\n /** true이면 모든 인터랙션을 비활성화해요 */\r\n disabled?: boolean;\r\n /**\r\n * 이미지 추가 버튼의 내용을 교체할 수 있어요.\r\n * 기본값은 + 아이콘과 현재/최대 개수를 표시해요.\r\n */\r\n placeholder?: ReactNode;\r\n}\r\n\r\n// ─── Private Sub-components ──────────────────────────────────────────────────\r\n\r\ninterface AddButtonProps {\r\n onClick: () => void;\r\n disabled?: boolean;\r\n current: number;\r\n max: number;\r\n children?: ReactNode;\r\n}\r\n\r\nfunction AddButton({\r\n onClick,\r\n disabled,\r\n current,\r\n max,\r\n children,\r\n}: AddButtonProps) {\r\n return (\r\n <button\r\n type=\"button\"\r\n onClick={onClick}\r\n disabled={disabled}\r\n aria-label={`이미지 추가 (${current}/${max})`}\r\n className={cn(\r\n 'shrink-0 w-20 h-20',\r\n 'flex flex-col items-center justify-center gap-1',\r\n 'rounded-lg',\r\n 'border-2 border-dashed border-border',\r\n 'bg-muted text-muted-foreground',\r\n 'cursor-pointer select-none',\r\n 'transition-colors duration-150',\r\n 'hover:bg-accent hover:text-accent-foreground hover:border-border',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n )}\r\n >\r\n {children ?? (\r\n <>\r\n <PlusIcon className=\"w-5 h-5\" />\r\n <span className=\"text-[10px] font-medium leading-none tabular-nums\">\r\n {current}/{max}\r\n </span>\r\n </>\r\n )}\r\n </button>\r\n );\r\n}\r\n\r\ninterface ImageItemProps {\r\n src: string;\r\n alt: string;\r\n onRemove: () => void;\r\n disabled?: boolean;\r\n}\r\n\r\nfunction ImageItem({ src, alt, onRemove, disabled }: ImageItemProps) {\r\n return (\r\n <div className=\"relative shrink-0 w-20 h-20 group/item\">\r\n <img\r\n src={src}\r\n alt={alt}\r\n draggable={false}\r\n className=\"w-full h-full object-cover rounded-lg\"\r\n />\r\n {!disabled && (\r\n <button\r\n type=\"button\"\r\n onClick={onRemove}\r\n aria-label={`${alt} 삭제`}\r\n className={cn(\r\n 'absolute -top-1.5 -right-1.5',\r\n 'w-5 h-5 rounded-full',\r\n 'flex items-center justify-center',\r\n 'bg-foreground text-background',\r\n 'transition-all duration-150',\r\n 'opacity-0 scale-75',\r\n 'group-hover/item:opacity-100 group-hover/item:scale-100',\r\n 'group-focus-within/item:opacity-100 group-focus-within/item:scale-100',\r\n 'hover:scale-110 active:scale-90',\r\n 'focus-visible:opacity-100 focus-visible:scale-100',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',\r\n )}\r\n >\r\n <XIcon strokeWidth=\"2.5\" />\r\n </button>\r\n )}\r\n </div>\r\n );\r\n}\r\n\r\n// ─── Main Component ───────────────────────────────────────────────────────────\r\n\r\nexport const ImageUploader = forwardRef<\r\n HTMLDivElement,\r\n ImageUploaderProps\r\n>(\r\n (\r\n {\r\n className,\r\n layout,\r\n disabled,\r\n placeholder,\r\n initialImages,\r\n maxImages = 4,\r\n maxSizeMb,\r\n accept,\r\n onFileSelect,\r\n onDeleteExisting,\r\n onError,\r\n },\r\n ref,\r\n ) => {\r\n const uid = useId();\r\n\r\n const {\r\n existingImages,\r\n newImages,\r\n totalCount,\r\n isDragActive,\r\n inputRef,\r\n openFilePicker,\r\n removeExisting,\r\n removeNew,\r\n dragHandlers,\r\n inputProps,\r\n } = useImageUploader({\r\n initialImages,\r\n maxImages,\r\n maxSizeMb,\r\n accept,\r\n onFileSelect,\r\n onDeleteExisting,\r\n onError,\r\n });\r\n\r\n const canAdd = !disabled && totalCount < maxImages;\r\n\r\n return (\r\n <div ref={ref} className={cn('flex flex-col gap-2', className)}>\r\n {/* Screen reader live region — count 변경 시 자동 공지 */}\r\n <div\r\n role=\"status\"\r\n aria-live=\"polite\"\r\n aria-atomic=\"true\"\r\n className=\"sr-only\"\r\n >\r\n {totalCount > 0\r\n ? `이미지 ${totalCount}개 선택됨. 최대 ${maxImages}개까지 추가할 수 있어요.`\r\n : `이미지를 추가해주세요. 최대 ${maxImages}개까지 추가할 수 있어요.`}\r\n </div>\r\n\r\n {/* Drag 활성 시 screen reader 긴급 공지 */}\r\n {isDragActive && (\r\n <span role=\"alert\" className=\"sr-only\">\r\n 파일을 여기에 놓아주세요\r\n </span>\r\n )}\r\n\r\n {/* 숨겨진 파일 input (탭 순서에서 제외, 버튼이 대신 처리) */}\r\n <input\r\n ref={inputRef}\r\n id={`${uid}-input`}\r\n type=\"file\"\r\n tabIndex={-1}\r\n aria-hidden=\"true\"\r\n className=\"sr-only\"\r\n disabled={disabled}\r\n {...inputProps}\r\n />\r\n\r\n {/* Drop zone + 이미지 목록 */}\r\n <div\r\n role=\"group\"\r\n aria-label={`이미지 업로더, ${totalCount}/${maxImages}개 선택됨`}\r\n className={cn(\r\n imageUploaderVariants({ layout }),\r\n 'rounded-lg p-1 -ml-1',\r\n 'transition-all duration-200',\r\n isDragActive &&\r\n 'ring-2 ring-primary ring-offset-2 bg-primary/5',\r\n )}\r\n {...(canAdd ? dragHandlers : {})}\r\n >\r\n {/* 이미지 추가 버튼 */}\r\n {canAdd && (\r\n <AddButton\r\n onClick={openFilePicker}\r\n disabled={disabled}\r\n current={totalCount}\r\n max={maxImages}\r\n >\r\n {placeholder}\r\n </AddButton>\r\n )}\r\n\r\n {/* 기존 이미지 (sequence 순 정렬) */}\r\n {sortBySequence(existingImages).map((img, index) => (\r\n <ImageItem\r\n key={`existing-${img.id}`}\r\n src={img.url}\r\n alt={`이미지 ${index + 1}`}\r\n onRemove={() => removeExisting(img.id)}\r\n disabled={disabled}\r\n />\r\n ))}\r\n\r\n {/* 새로 선택한 이미지 */}\r\n {newImages.map((item, index) => (\r\n <ImageItem\r\n key={`new-${item.id}`}\r\n src={item.preview}\r\n alt={`새 이미지 ${existingImages.length + index + 1}`}\r\n onRemove={() => removeNew(item.id)}\r\n disabled={disabled}\r\n />\r\n ))}\r\n </div>\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nImageUploader.displayName = 'ImageUploader';\r\n\r\n// ─── Helpers ──────────────────────────────────────────────────────────────────\r\n\r\nfunction sortBySequence(images: ExistingImage[]): ExistingImage[] {\r\n return [...images].sort(\r\n (a, b) => (a.sequence ?? 0) - (b.sequence ?? 0),\r\n );\r\n}\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport { imageUploaderVariants };\r\nexport type { ExistingImage, NewImageFile };\r\n","import { type ClassValue, clsx } from 'clsx';\r\nimport { twMerge } from 'tailwind-merge';\r\n\r\nexport function cn(...inputs: ClassValue[]) {\r\n return twMerge(clsx(inputs));\r\n}\r\n","import type { SVGProps } from 'react';\r\n\r\n// ─── Base ─────────────────────────────────────────────────────────────────────\r\n\r\ntype IconProps = SVGProps<SVGSVGElement>;\r\n\r\nfunction createIcon(paths: React.ReactNode, defaultSize = 24) {\r\n return function Icon({\r\n width = defaultSize,\r\n height = defaultSize,\r\n ...props\r\n }: IconProps) {\r\n return (\r\n <svg\r\n xmlns=\"http://www.w3.org/2000/svg\"\r\n width={width}\r\n height={height}\r\n viewBox=\"0 0 24 24\"\r\n fill=\"none\"\r\n stroke=\"currentColor\"\r\n strokeWidth=\"2\"\r\n strokeLinecap=\"round\"\r\n strokeLinejoin=\"round\"\r\n aria-hidden=\"true\"\r\n {...props}\r\n >\r\n {paths}\r\n </svg>\r\n );\r\n };\r\n}\r\n\r\n// ─── Icons ────────────────────────────────────────────────────────────────────\r\n\r\nexport const PlusIcon = createIcon(\r\n <>\r\n <path d=\"M5 12h14\" />\r\n <path d=\"M12 5v14\" />\r\n </>,\r\n);\r\n\r\nexport const XIcon = createIcon(\r\n <>\r\n <path d=\"M18 6 6 18\" />\r\n <path d=\"m6 6 12 12\" />\r\n </>,\r\n 12,\r\n);\r\n","'use client';\r\n\r\nimport {\r\n useCallback,\r\n useEffect,\r\n useMemo,\r\n useRef,\r\n useState,\r\n} from 'react';\r\nimport type React from 'react';\r\n\r\n// ─── Public Types ────────────────────────────────────────────────────────────\r\n\r\n/** 서버에서 받은 기존 이미지 */\r\nexport interface ExistingImage {\r\n id: number | string;\r\n url: string;\r\n /** 이미지 순서 (정렬에 사용) */\r\n sequence?: number;\r\n}\r\n\r\n/** 새로 선택한 파일 (미리보기 포함) */\r\nexport interface NewImageFile {\r\n file: File;\r\n /** `URL.createObjectURL()` 로 생성된 미리보기 URL */\r\n preview: string;\r\n /** React key / 삭제 식별자 */\r\n id: string;\r\n}\r\n\r\n/** 유효성 검사 에러 Union 타입 */\r\nexport type ImageUploaderError =\r\n | { type: 'MAX_IMAGES'; message: string; maxImages: number }\r\n | {\r\n type: 'MAX_SIZE';\r\n message: string;\r\n maxSizeMb: number;\r\n file: File;\r\n }\r\n | {\r\n type: 'INVALID_TYPE';\r\n message: string;\r\n file: File;\r\n accept: string;\r\n };\r\n\r\nexport interface UseImageUploaderOptions {\r\n /** 수정 모드에서 서버 이미지를 전달해요 */\r\n initialImages?: ExistingImage[];\r\n /** 최대 업로드 가능 이미지 수 (기본값: 4) */\r\n maxImages?: number;\r\n /** 파일 1개의 최대 크기 (MB). 미설정 시 제한 없음 */\r\n maxSizeMb?: number;\r\n /** 허용할 파일 형식 (기본값: 'image/*') */\r\n accept?: string;\r\n /** 현재 선택된 전체 File[] 배열이 변경될 때 호출돼요 */\r\n onFileSelect?: (files: File[]) => void;\r\n /** 기존 이미지가 삭제될 때 삭제된 id 배열을 전달해요 */\r\n onDeleteExisting?: (deletedIds: Array<number | string>) => void;\r\n /** 유효성 검사 실패 시 호출돼요 (toast 등 직접 처리) */\r\n onError?: (error: ImageUploaderError) => void;\r\n}\r\n\r\nexport interface UseImageUploaderReturn {\r\n /** 삭제되지 않은 기존 이미지 */\r\n existingImages: ExistingImage[];\r\n /** 새로 선택한 이미지 (미리보기 포함) */\r\n newImages: NewImageFile[];\r\n /** 기존 + 새 이미지 합산 개수 */\r\n totalCount: number;\r\n /** 드래그 활성 여부 */\r\n isDragActive: boolean;\r\n /** 파일 input 요소의 ref */\r\n inputRef: React.RefObject<HTMLInputElement>;\r\n /** 파일 선택 다이얼로그 열기 */\r\n openFilePicker: () => void;\r\n /** 기존 이미지 삭제 */\r\n removeExisting: (id: number | string) => void;\r\n /** 새로 선택한 이미지 삭제 */\r\n removeNew: (id: string) => void;\r\n /** drop zone에 spread할 drag 이벤트 핸들러 */\r\n dragHandlers: {\r\n onDragOver: React.DragEventHandler;\r\n onDragLeave: React.DragEventHandler;\r\n onDrop: React.DragEventHandler;\r\n };\r\n /** file input에 spread할 props */\r\n inputProps: {\r\n onChange: React.ChangeEventHandler<HTMLInputElement>;\r\n accept: string;\r\n multiple: boolean;\r\n };\r\n}\r\n\r\n// ─── Helpers ─────────────────────────────────────────────────────────────────\r\n\r\nfunction matchesAccept(file: File, accept: string): boolean {\r\n return accept\r\n .split(',')\r\n .map((s) => s.trim().toLowerCase())\r\n .some((pattern) => {\r\n if (pattern === '*' || pattern === '*/*') return true;\r\n if (pattern.startsWith('.'))\r\n return file.name.toLowerCase().endsWith(pattern);\r\n if (pattern.endsWith('/*'))\r\n return file.type.startsWith(pattern.slice(0, -2));\r\n return file.type === pattern;\r\n });\r\n}\r\n\r\n// ─── Hook ────────────────────────────────────────────────────────────────────\r\n\r\nexport function useImageUploader({\r\n initialImages,\r\n maxImages = 4,\r\n maxSizeMb,\r\n accept = 'image/*',\r\n onFileSelect,\r\n onDeleteExisting,\r\n onError,\r\n}: UseImageUploaderOptions): UseImageUploaderReturn {\r\n const [newImages, setNewImages] = useState<NewImageFile[]>([]);\r\n const [deletedIds, setDeletedIds] = useState<\r\n Array<number | string>\r\n >([]);\r\n const [isDragActive, setIsDragActive] = useState(false);\r\n const inputRef = useRef<HTMLInputElement>(null);\r\n\r\n // 콜백 refs — 매 렌더 후 갱신해 stale closure 없이 최신 값 참조\r\n const onFileSelectRef = useRef(onFileSelect);\r\n const onDeleteExistingRef = useRef(onDeleteExisting);\r\n const onErrorRef = useRef(onError);\r\n useEffect(() => {\r\n onFileSelectRef.current = onFileSelect;\r\n onDeleteExistingRef.current = onDeleteExisting;\r\n onErrorRef.current = onError;\r\n });\r\n\r\n // 상태 refs — 콜백 내에서 최신 상태를 직접 읽기 위해 사용\r\n const newImagesRef = useRef<NewImageFile[]>([]);\r\n const deletedIdsRef = useRef<Array<number | string>>([]);\r\n const existingCountRef = useRef(0);\r\n\r\n const existingImages = useMemo(\r\n () =>\r\n (initialImages ?? []).filter(\r\n (img) => !deletedIds.includes(img.id),\r\n ),\r\n [initialImages, deletedIds],\r\n );\r\n\r\n // 렌더 후 refs 동기화\r\n useEffect(() => {\r\n newImagesRef.current = newImages;\r\n }, [newImages]);\r\n useEffect(() => {\r\n deletedIdsRef.current = deletedIds;\r\n }, [deletedIds]);\r\n useEffect(() => {\r\n existingCountRef.current = existingImages.length;\r\n }, [existingImages.length]);\r\n\r\n // 언마운트 시 모든 preview URL 해제\r\n useEffect(() => {\r\n return () => {\r\n newImagesRef.current.forEach((item) =>\r\n URL.revokeObjectURL(item.preview),\r\n );\r\n };\r\n }, []);\r\n\r\n const processFiles = useCallback(\r\n (files: File[]) => {\r\n const currentTotal =\r\n existingCountRef.current + newImagesRef.current.length;\r\n const onErr = onErrorRef.current;\r\n const onSelect = onFileSelectRef.current;\r\n\r\n if (currentTotal >= maxImages) {\r\n onErr?.({\r\n type: 'MAX_IMAGES',\r\n message: `이미지는 최대 ${maxImages}장까지 추가할 수 있어요.`,\r\n maxImages,\r\n });\r\n return;\r\n }\r\n\r\n const remainingSlots = maxImages - currentTotal;\r\n\r\n if (files.length > remainingSlots) {\r\n onErr?.({\r\n type: 'MAX_IMAGES',\r\n message: `이미지는 최대 ${maxImages}장까지 추가할 수 있어요.`,\r\n maxImages,\r\n });\r\n }\r\n\r\n const validFiles: NewImageFile[] = [];\r\n\r\n for (const file of files.slice(0, remainingSlots)) {\r\n if (!matchesAccept(file, accept)) {\r\n onErr?.({\r\n type: 'INVALID_TYPE',\r\n message: `지원하지 않는 파일 형식이에요.`,\r\n file,\r\n accept,\r\n });\r\n continue;\r\n }\r\n\r\n if (\r\n maxSizeMb !== undefined &&\r\n file.size > maxSizeMb * 1024 * 1024\r\n ) {\r\n onErr?.({\r\n type: 'MAX_SIZE',\r\n message: `${file.name}의 크기가 ${maxSizeMb}MB를 초과해요.`,\r\n maxSizeMb,\r\n file,\r\n });\r\n continue;\r\n }\r\n\r\n validFiles.push({\r\n file,\r\n preview: URL.createObjectURL(file),\r\n id: crypto.randomUUID(),\r\n });\r\n }\r\n\r\n if (validFiles.length === 0) return;\r\n\r\n const next = [...newImagesRef.current, ...validFiles];\r\n newImagesRef.current = next;\r\n setNewImages(next);\r\n onSelect?.(next.map((item) => item.file));\r\n },\r\n [maxImages, accept, maxSizeMb],\r\n );\r\n\r\n const removeExisting = useCallback((id: number | string) => {\r\n const next = [...deletedIdsRef.current, id];\r\n deletedIdsRef.current = next;\r\n setDeletedIds(next);\r\n onDeleteExistingRef.current?.(next);\r\n }, []);\r\n\r\n const removeNew = useCallback((id: string) => {\r\n const current = newImagesRef.current;\r\n const toRemove = current.find((item) => item.id === id);\r\n if (toRemove) URL.revokeObjectURL(toRemove.preview);\r\n const next = current.filter((item) => item.id !== id);\r\n newImagesRef.current = next;\r\n setNewImages(next);\r\n onFileSelectRef.current?.(next.map((item) => item.file));\r\n }, []);\r\n\r\n const handleChange = useCallback(\r\n (e: React.ChangeEvent<HTMLInputElement>) => {\r\n if (!e.target.files) return;\r\n processFiles(Array.from(e.target.files));\r\n e.target.value = '';\r\n },\r\n [processFiles],\r\n );\r\n\r\n const handleDragOver = useCallback((e: React.DragEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setIsDragActive(true);\r\n }, []);\r\n\r\n const handleDragLeave = useCallback((e: React.DragEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n // 자식 요소로 이동하는 경우는 무시\r\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\r\n setIsDragActive(false);\r\n }, []);\r\n\r\n const handleDrop = useCallback(\r\n (e: React.DragEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setIsDragActive(false);\r\n processFiles(Array.from(e.dataTransfer.files));\r\n },\r\n [processFiles],\r\n );\r\n\r\n return {\r\n existingImages,\r\n newImages,\r\n totalCount: existingImages.length + newImages.length,\r\n isDragActive,\r\n inputRef,\r\n openFilePicker: () => inputRef.current?.click(),\r\n removeExisting,\r\n removeNew,\r\n dragHandlers: {\r\n onDragOver: handleDragOver,\r\n onDragLeave: handleDragLeave,\r\n onDrop: handleDrop,\r\n },\r\n inputProps: {\r\n onChange: handleChange,\r\n accept,\r\n multiple: true,\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":["Fragment","jsx","jsxs"]}
|