Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions public/icons/icon-small-x.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions public/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions scripts/sprite/generate-sprite.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const INCLUDE_COLOR_TRANSFORM = [
// 'plus-circle',
'plus',
'search',
'small-x',
'unread-false',
'unread-true',
'user',
Expand Down
67 changes: 67 additions & 0 deletions src/components/pages/post-meetup/fields/thumbnails-field/index.tsx
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 스타일
Comment on lines +33 to +36
Copy link
Contributor

@wooktori wooktori Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스타일 별로 묶어서 작성하니까 보기 좋아요!👍

)}
/>
</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;
132 changes: 132 additions & 0 deletions src/components/ui/imageinput/index.tsx
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace 모드는 이미 추가되어 있는 이미지를 누르면 적용되는건가요?

Copy link
Member Author

@Chiman2937 Chiman2937 Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 말씀하시니까 그렇게 보이기도 하네요 ...
제 의도는 예를들어 이미지가 이미 선택되어있는 상태에서 추가로 이미지를 선택하면 이게 교체될꺼냐(replace), 아니면 쌓일거냐(append)를 결정하려고 했어요!


// 전체 이미지
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)}
</>
);
};
2 changes: 2 additions & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/types/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down