-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] Image Input(모임 생성 페이지 Thumbnail Field) 제작 #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
05901ae
0c35920
29f9927
11973d0
e9e9bee
7feaf4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import Image from 'next/image'; | ||
|
|
||
| import { Icon } from '@/components/icon'; | ||
| import { ImageInput, ImageInputProps } from '@/components/ui'; | ||
| import { cn } from '@/lib/utils'; | ||
|
|
||
| type ImageUploadPropsWithoutChildren = Omit<ImageInputProps, 'children'>; | ||
|
|
||
| const ThumbnailField = ({ value, onChange, initialImages }: ImageUploadPropsWithoutChildren) => { | ||
| return ( | ||
| <ImageInput | ||
| initialImages={initialImages} | ||
| maxFiles={3} | ||
| mode='append' | ||
| multiple={true} | ||
| value={value} | ||
| onChange={onChange} | ||
| > | ||
| {(images, onRemoveImageClick, onFileSelectClick) => ( | ||
| <div className='flex flex-row gap-2'> | ||
| <button | ||
| className={cn( | ||
| 'flex-center bg-mono-white group aspect-square w-full max-w-20 cursor-pointer rounded-2xl border-1 border-gray-300', // 기본 스타일 | ||
| 'hover:bg-gray-50', // hover 스타일 | ||
| 'transition-all duration-300', // animation 스타일 | ||
| )} | ||
| aria-label='이미지 선택 버튼' | ||
| type='button' | ||
| onClick={onFileSelectClick} | ||
| > | ||
| <Icon | ||
| id='plus' | ||
| className={cn( | ||
| 'size-6 text-gray-600', // 기본 스타일 | ||
| 'group-hover:scale-120', // hover 스타일 | ||
| 'transition-all duration-300', // animation 스타일 | ||
| )} | ||
| /> | ||
| </button> | ||
| {Object.entries(images).map(([url, _file]) => ( | ||
| <div key={url} className='relative aspect-square w-full max-w-20'> | ||
| <Image | ||
| className='border-mono-black/5 h-full w-full rounded-2xl border-1 object-cover' | ||
| alt='팀 이미지' | ||
| fill | ||
| src={url} | ||
| /> | ||
| <button | ||
| className={cn( | ||
| 'flex-center bg-mono-white/80 group absolute top-1.5 right-2 size-4 cursor-pointer rounded-full', // 기본 스타일 | ||
| 'hover:bg-mono-white hover:scale-110', // hover 스타일 | ||
| 'transition-all duration-300', // animation 스타일 | ||
| )} | ||
| aria-label='이미지 삭제 버튼' | ||
| onClick={() => onRemoveImageClick(url)} | ||
| > | ||
| <Icon id='small-x' className='size-1.5 text-gray-700' /> | ||
| </button> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </ImageInput> | ||
| ); | ||
| }; | ||
|
|
||
| export default ThumbnailField; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| import React, { useEffect, useRef, useState } from 'react'; | ||
|
|
||
| export type ImageRecord = Record<string, File | null>; | ||
|
|
||
| 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<ImageRecord>(() => { | ||
| // initialImages 처리 | ||
| return initialImages.reduce((acc, url) => { | ||
| acc[url] = null; | ||
| return acc; | ||
| }, {} as ImageRecord); | ||
| }); | ||
| const inputRef = useRef<HTMLInputElement>(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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. replace 모드는 이미 추가되어 있는 이미지를 누르면 적용되는건가요?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이렇게 말씀하시니까 그렇게 보이기도 하네요 ... |
||
|
|
||
| // 전체 이미지 | ||
| 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<HTMLInputElement>) => { | ||
| const files = Array.from(e.target.files || []); | ||
| addImages(files); | ||
| e.target.value = ''; | ||
| }; | ||
|
|
||
| const updateImages = (newImages: ImageRecord) => { | ||
| if (!isControlled) { | ||
| setInternalImages(newImages); | ||
| } | ||
| onChange?.(newImages); | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <input | ||
| ref={inputRef} | ||
| style={{ display: 'none' }} | ||
| accept={accept} | ||
| multiple={multiple} | ||
| type='file' | ||
| onChange={handleFileChange} | ||
| /> | ||
|
|
||
| {/* eslint-disable-next-line react-hooks/refs */} | ||
| {children(images, onRemoveImageClick, onFileSelectClick)} | ||
| </> | ||
| ); | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
스타일 별로 묶어서 작성하니까 보기 좋아요!👍