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
15 changes: 0 additions & 15 deletions src/apis/upload/mutations.ts

This file was deleted.

233 changes: 233 additions & 0 deletions src/components/common/ImageUploader/ImageUploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import AddPhotoAlternateIcon from '@/assets/svg/add-photo-alternate.svg';
import ChevronLeft from '@/assets/svg/chevron-left.svg';
import ChevronRight from '@/assets/svg/chevron-right.svg';
import { cn } from '@/utils/ts/cn';
import { useImageCarousel } from './hooks/useImageCarousel';
import { useImagePreparation } from './hooks/useImagePreparation';
import { type ImageUploaderLayout, type ImageUploaderSelectionMode, type ImageUploadItem } from './types';

interface ImageUploaderProps {
className?: string;
disabled?: boolean;
layout: ImageUploaderLayout;
onChange: (images: ImageUploadItem[]) => void;
onPreparingChange?: (isPreparing: boolean) => void;
onPreviewClick?: (image: ImageUploadItem, index: number) => void;
previewAlt?: (index: number) => string;
selectionMode: ImageUploaderSelectionMode;
value: ImageUploadItem[];
}

interface ImageUploaderEmptyButtonProps {
className: string;
disabled: boolean;
isPreparing: boolean;
onClick: () => void;
}

function ImageUploaderEmptyButton({ className, disabled, isPreparing, onClick }: ImageUploaderEmptyButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
'border-text-200 text-text-500 flex flex-col items-center justify-center gap-2 border-[0.7px] bg-white disabled:cursor-not-allowed disabled:opacity-50',
className
)}
>
<AddPhotoAlternateIcon aria-hidden="true" className="size-15" />
<p className="text-center leading-[1.6] font-semibold whitespace-pre-line">
{isPreparing ? '이미지를 준비하고 있어요' : '이미지를 추가해주세요'}
</p>
</button>
);
}

interface ImageUploaderActionButtonProps {
action: 'change' | 'delete';
disabled: boolean;
onClick: () => void;
}

function ImageUploaderActionButton({ action, disabled, onClick }: ImageUploaderActionButtonProps) {
const isDeleteAction = action === 'delete';

return (
<button
type="button"
aria-label={isDeleteAction ? '이미지 삭제' : '이미지 변경'}
onClick={onClick}
disabled={disabled}
className={cn(
'absolute right-3 flex size-6 items-center justify-center rounded-full bg-[#9f9f9f] text-[18px] leading-none text-white',
isDeleteAction ? 'top-3' : 'bottom-3',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<span>{isDeleteAction ? '×' : '+'}</span>
</button>
);
}

function ImageUploader({
className,
disabled = false,
layout,
onChange,
onPreparingChange,
onPreviewClick,
previewAlt = (index) => `업로드 이미지 ${index + 1}`,
selectionMode,
value,
}: ImageUploaderProps) {
const {
currentImage,
currentIndex,
deleteCurrentImage,
goToNextImage,
goToPreviousImage,
selectImage,
setCurrentImageIndex,
} = useImageCarousel({
images: value,
onChange,
});
const { fileInputRef, handleImageSelect, isDisabled, isPreparing, openFilePicker } = useImagePreparation({
disabled,
images: value,
onChange,
onPreparingChange,
selectionMode,
setCurrentImageIndex,
});

const isSquareLayout = layout === 'square';

const renderEmptyState = () => (
<ImageUploaderEmptyButton
onClick={openFilePicker}
disabled={isDisabled}
isPreparing={isPreparing}
className={isSquareLayout ? 'size-full rounded-sm' : 'h-56.5 w-full rounded-[20px]'}
/>
);

const renderPreviewImage = (image: ImageUploadItem, index: number) => {
const imageNode = (
<img
src={image.previewUrl}
alt={previewAlt(index)}
className={isSquareLayout ? 'max-h-full w-full object-contain' : 'h-full w-full object-cover'}
/>
);

if (!onPreviewClick) return imageNode;

return (
<button type="button" onClick={() => onPreviewClick(image, index)} className="h-full w-full">
{imageNode}
</button>
);
Comment on lines +127 to +131
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

이미지 미리보기 버튼에 접근성 레이블 추가

onPreviewClick 핸들러가 있는 버튼에 aria-label이 없어 스크린 리더 사용자가 버튼 용도를 알 수 없습니다.

♿ 접근성 개선 제안
     return (
-      <button type="button" onClick={() => onPreviewClick(image, index)} className="h-full w-full">
+      <button
+        type="button"
+        onClick={() => onPreviewClick(image, index)}
+        aria-label={`${previewAlt(index)} 미리보기`}
+        className="h-full w-full"
+      >
         {imageNode}
       </button>
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<button type="button" onClick={() => onPreviewClick(image, index)} className="h-full w-full">
{imageNode}
</button>
);
return (
<button
type="button"
onClick={() => onPreviewClick(image, index)}
aria-label={`${previewAlt(index)} 미리보기`}
className="h-full w-full"
>
{imageNode}
</button>
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/ImageUploader/ImageUploader.tsx` around lines 127 -
131, The image preview button in ImageUploader renders <button type="button"
onClick={() => onPreviewClick(image, index)}> without an accessibility label;
update the button element in the ImageUploader component to include an
appropriate aria-label (e.g., using image.alt when available or a fallback like
"Preview image {index + 1}") so screen readers convey the button's purpose when
invoking onPreviewClick; ensure the aria-label is descriptive and unique per
image (use index or image metadata) to assist navigation for assistive
technologies.

};

const renderSquarePreview = () => {
if (!currentImage) return renderEmptyState();

return (
<>
<div className="flex h-full items-center justify-center overflow-hidden rounded-sm bg-white p-4">
{renderPreviewImage(currentImage, currentIndex)}
</div>
<ImageUploaderActionButton action="delete" onClick={deleteCurrentImage} disabled={isDisabled} />
<ImageUploaderActionButton action="change" onClick={openFilePicker} disabled={isDisabled} />
</>
);
};

const renderWidePreview = () => {
if (!currentImage) return renderEmptyState();

return (
<div className="flex flex-col gap-3">
<div className="border-text-200 relative h-56.5 overflow-hidden rounded-[20px] border-[0.7px] bg-white">
{renderPreviewImage(currentImage, currentIndex)}
<ImageUploaderActionButton action="delete" onClick={deleteCurrentImage} disabled={isDisabled} />

{value.length > 1 && (
<>
<button
type="button"
onClick={goToPreviousImage}
disabled={isDisabled}
aria-label="이전 이미지"
className="absolute top-1/2 left-3 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 shadow-[0_0_3px_rgba(0,0,0,0.15)] disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft aria-hidden="true" className="h-4 w-4 text-indigo-700" />
</button>
<button
type="button"
onClick={goToNextImage}
disabled={isDisabled}
aria-label="다음 이미지"
className="absolute top-1/2 right-3 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 shadow-[0_0_3px_rgba(0,0,0,0.15)] disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronRight aria-hidden="true" className="h-4 w-4 text-indigo-700" />
</button>
</>
)}
</div>

{selectionMode === 'multiple' && (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{value.map((image, index) => (
<button
key={image.id}
type="button"
onClick={() => selectImage(index)}
aria-label={`${index + 1}번 이미지 보기`}
className={cn(
'h-2 w-2 rounded-full transition-colors',
index === currentIndex ? 'bg-primary-500' : 'bg-text-200'
)}
/>
))}
</div>

<button
type="button"
onClick={openFilePicker}
disabled={isDisabled}
className="bg-primary-500 rounded-full px-4 py-2 text-[13px] leading-[1.6] font-semibold text-white"
>
이미지 추가
</button>
Comment on lines +198 to +205
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

"이미지 추가" 버튼 disabled 스타일 누락

다른 버튼들과 달리 이 버튼에는 disabled:cursor-not-allowed disabled:opacity-50 스타일이 없습니다.

🎨 스타일 일관성 수정
             <button
               type="button"
               onClick={openFilePicker}
               disabled={isDisabled}
-              className="bg-primary-500 rounded-full px-4 py-2 text-[13px] leading-[1.6] font-semibold text-white"
+              className="bg-primary-500 rounded-full px-4 py-2 text-[13px] leading-[1.6] font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
             >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={openFilePicker}
disabled={isDisabled}
className="bg-primary-500 rounded-full px-4 py-2 text-[13px] leading-[1.6] font-semibold text-white"
>
이미지 추가
</button>
<button
type="button"
onClick={openFilePicker}
disabled={isDisabled}
className="bg-primary-500 rounded-full px-4 py-2 text-[13px] leading-[1.6] font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
이미지 추가
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/common/ImageUploader/ImageUploader.tsx` around lines 198 -
205, The "이미지 추가" button in the ImageUploader component lacks the disabled
styling, so update the button (the element that calls openFilePicker and uses
isDisabled) to include the same disabled utility classes used elsewhere (e.g.
disabled:cursor-not-allowed disabled:opacity-50) in its className; ensure the
classes are applied conditionally via the existing isDisabled prop so the button
shows consistent disabled cursor and opacity when isDisabled is true.

</div>
)}
</div>
);
};

const renderContent = () => {
if (layout === 'square') return renderSquarePreview();
return renderWidePreview();
};

return (
<div className={className}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple={selectionMode === 'multiple'}
onChange={handleImageSelect}
disabled={isDisabled}
className="hidden"
/>
{renderContent()}
</div>
);
}

export default ImageUploader;
60 changes: 60 additions & 0 deletions src/components/common/ImageUploader/hooks/useImageCarousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react';
import type { ImageUploadItem } from '../types';

interface UseImageCarouselOptions {
images: ImageUploadItem[];
onChange: (images: ImageUploadItem[]) => void;
}

interface UseImageCarouselReturn {
currentImage: ImageUploadItem | undefined;
currentIndex: number;
deleteCurrentImage: () => void;
goToNextImage: () => void;
goToPreviousImage: () => void;
selectImage: (index: number) => void;
setCurrentImageIndex: Dispatch<SetStateAction<number>>;
}

export function useImageCarousel({ images, onChange }: UseImageCarouselOptions): UseImageCarouselReturn {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const currentImage = images[currentImageIndex] ?? images[0];
const currentIndex = images[currentImageIndex] ? currentImageIndex : 0;

const deleteCurrentImage = useCallback(() => {
if (!currentImage) return;

const nextImages = images.filter((image) => image.id !== currentImage.id);
onChange(nextImages);

if (currentIndex >= nextImages.length) {
setCurrentImageIndex(Math.max(nextImages.length - 1, 0));
}
}, [currentImage, currentIndex, images, onChange]);

const goToPreviousImage = useCallback(() => {
if (images.length <= 1) return;

setCurrentImageIndex(currentIndex === 0 ? images.length - 1 : currentIndex - 1);
}, [currentIndex, images.length]);

const goToNextImage = useCallback(() => {
if (images.length <= 1) return;

setCurrentImageIndex(currentIndex === images.length - 1 ? 0 : currentIndex + 1);
}, [currentIndex, images.length]);

const selectImage = useCallback((index: number) => {
setCurrentImageIndex(index);
}, []);

return {
currentImage,
currentIndex,
deleteCurrentImage,
goToNextImage,
goToPreviousImage,
selectImage,
setCurrentImageIndex,
};
}
Loading
Loading