diff --git a/public/icons/icon-small-x.svg b/public/icons/icon-small-x.svg new file mode 100644 index 00000000..57354fb0 --- /dev/null +++ b/public/icons/icon-small-x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/sprite.svg b/public/sprite.svg index 2b4f29ea..bce1dc42 100644 --- a/public/sprite.svg +++ b/public/sprite.svg @@ -147,6 +147,14 @@ d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16M21 21l-4.35-4.35" /> + + + ; + +const ThumbnailField = ({ value, onChange, initialImages }: ImageUploadPropsWithoutChildren) => { + return ( + + {(images, onRemoveImageClick, onFileSelectClick) => ( + + + + + {Object.entries(images).map(([url, _file]) => ( + + + onRemoveImageClick(url)} + > + + + + ))} + + )} + + ); +}; + +export default ThumbnailField; diff --git a/src/components/ui/imageinput/index.tsx b/src/components/ui/imageinput/index.tsx new file mode 100644 index 00000000..52071a33 --- /dev/null +++ b/src/components/ui/imageinput/index.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useRef, useState } from 'react'; + +export type ImageRecord = Record; + +export interface ImageInputProps { + value?: ImageRecord; + children: ( + images: ImageRecord, + onRemoveImageClick: (url: string) => void, + onFileSelectClick: () => void, + ) => React.ReactNode; + onChange?: (images: ImageRecord) => void; + maxFiles?: number; + accept?: string; + multiple?: boolean; + mode?: 'replace' | 'append'; + initialImages?: string[]; +} + +export const ImageInput = ({ + value, + children, + onChange, + maxFiles = 1, + accept = 'image/*', + multiple = false, + mode = 'replace', + initialImages = [], +}: ImageInputProps) => { + const [internalImages, setInternalImages] = useState(() => { + // initialImages 처리 + return initialImages.reduce((acc, url) => { + acc[url] = null; + return acc; + }, {} as ImageRecord); + }); + const inputRef = useRef(null); + + const isControlled = value !== undefined && onChange !== undefined; + const images = isControlled ? value : internalImages; + + useEffect(() => { + return () => { + Object.keys(images).forEach((url) => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + }; + }, [images]); + + const addImages = (files: File[]) => { + const newImages: ImageRecord = {}; + + // 선택된 파일들에 대해 blob URL 생성 + files.forEach((file) => { + const url = URL.createObjectURL(file); + newImages[url] = file; + }); + + // append 모드면 이미지 계속 쌓임 + // replace 모드면 이미지 교체 + const nextImages = mode === 'append' ? { ...images, ...newImages } : newImages; + + // 전체 이미지 + const entries = Object.entries(nextImages); + + // 최대 선택가능한 이미지 갯수만 적용 + const limitedEntries = entries.slice(0, maxFiles); + + // 최대 갯수 초과한 이미지들에 대해 revoke URL 적용 + const removedEntries = entries.slice(maxFiles); + + removedEntries.forEach(([url]) => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + + // 최대 선택가능한 이미지 갯수에 대해서만 상태 업데이트 + const limitedImages = limitedEntries.slice(0, maxFiles).reduce((acc, [url, file]) => { + acc[url] = file; + return acc; + }, {} as ImageRecord); + + updateImages(limitedImages); + }; + + const onFileSelectClick = () => { + inputRef.current?.click(); + }; + + const onRemoveImageClick = (url: string) => { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + + const newImages = { ...images }; + delete newImages[url]; + + updateImages(newImages); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + addImages(files); + e.target.value = ''; + }; + + const updateImages = (newImages: ImageRecord) => { + if (!isControlled) { + setInternalImages(newImages); + } + onChange?.(newImages); + }; + + return ( + <> + + + {/* eslint-disable-next-line react-hooks/refs */} + {children(images, onRemoveImageClick, onFileSelectClick)} + > + ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 81a9885c..809ac185 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,5 +1,7 @@ export { Button } from './button'; export { Hint } from './hint'; +export type { ImageInputProps, ImageRecord } from './imageinput'; +export { ImageInput } from './imageinput'; export { Input } from './input'; export { Label } from './label'; export { diff --git a/src/types/icons/index.ts b/src/types/icons/index.ts index d58c58c9..1c1b12aa 100644 --- a/src/types/icons/index.ts +++ b/src/types/icons/index.ts @@ -12,6 +12,7 @@ export const ICONS = [ { id: 'plus', enableChangeColor: true }, { id: 'plus-circle', enableChangeColor: false }, { id: 'search', enableChangeColor: true }, + { id: 'small-x', enableChangeColor: true }, { id: 'unread-false', enableChangeColor: true }, { id: 'unread-true', enableChangeColor: true }, { id: 'user', enableChangeColor: true },