[refactor] 이미지 업로드 공통 컴포넌트 정리#283
Conversation
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 53 minutes and 43 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
Walkthrough이미지 업로드 로직을 공통화하는 리팩터입니다. 새로운 Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
이미지 업로드 UI/상태/업로드 로직을 공통 ImageUploader 컴포넌트와 useImageUploader 훅으로 통합해, 여러 화면에 흩어져 있던 중복 구현을 제거하는 리팩터링입니다.
Changes:
- 공통
ImageUploader+useImageUploader(및 준비/캐러셀 유틸 훅) 추가 - 관리자 동아리 프로필 / 모집 공고 작성 / 회비 납부 화면의 이미지 업로드 구현을 공통 컴포넌트로 교체
- 기존
useUploadImage훅 및 upload mutation factory 제거
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/common/ImageUploader/ImageUploader.tsx | 공통 이미지 업로더 UI(와이드/스퀘어, 단일/다중) 구현 |
| src/components/common/ImageUploader/hooks/useImageUploader.ts | 이미지 업로드 mutation + 이미지 상태(기존/로컬) 관리 훅 |
| src/components/common/ImageUploader/hooks/useImagePreparation.ts | 파일 선택/전처리/미리보기 URL 생성 및 준비 상태 관리 |
| src/components/common/ImageUploader/hooks/useImageCarousel.ts | 다중 이미지 캐러셀 인덱스/삭제/이동 로직 |
| src/components/common/ImageUploader/utils/resolveImageUploadItems.ts | ImageUploadItem을 최종 업로드 URL로 해석하는 유틸 |
| src/components/common/ImageUploader/utils/imageUploadItem.ts | ImageUploadItem 생성/previewUrl revoke 유틸 |
| src/components/common/ImageUploader/types.ts | 업로드 아이템/레이아웃/선택 모드 타입 정의 |
| src/components/common/ImageUploader/index.ts | ImageUploader 배럴(export) 구성 |
| src/pages/Manager/ManagedRecruitmentWrite/index.tsx | 모집 공고 이미지 업로드를 ImageUploader/useImageUploader로 교체 |
| src/pages/Manager/ManagedClubProfile/index.tsx | 관리자 동아리 프로필 이미지 업로드를 공통 컴포넌트로 교체 |
| src/pages/Club/Application/clubFeePage.tsx | 회비 납부 인증 이미지 업로드를 공통 컴포넌트로 교체 |
| src/utils/hooks/image/useUploadImage.ts | 기존 단일 업로드 훅 삭제 |
| src/apis/upload/mutations.ts | 기존 upload mutation factory 삭제 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -51,39 +42,24 @@ function ManagedClubInfo() { | |||
| const [description, setDescription] = useState(initialDescription); | |||
| const [location, setLocation] = useState(initialLocation); | |||
| const [introduce, setIntroduce] = useState(initialIntroduce); | |||
| const [imageFile, setImageFile] = useState<File | null>(null); | |||
| const [imagePreview, setImagePreview] = useState(initialImageUrl); | |||
| const { | |||
| images, | |||
| selectedImage: currentImage, | |||
| selectedImageUrl: currentImageUrl, | |||
| setImages, | |||
| isUploadingImages: isUploadingImage, | |||
| uploadError, | |||
| uploadImages, | |||
| } = useImageUploader({ initialImageUrls: initialImageUrl ? [initialImageUrl] : [], target: 'CLUB' }); | |||
There was a problem hiding this comment.
useState(initial*) and useImageUploader({ initialImageUrls }) only apply the initial values on the first mount. Since initialDescription/location/introduce/imageUrl come from a query, if clubDetail changes due to a refetch or a clubId param change while this component stays mounted, the form fields and image state will not reset to the new server values (and hasChanges can become incorrect). Consider syncing state in an effect when the initial* values (or numericClubId) change (e.g., set the text states and call resetImages from the hook).
| const selectedFiles = Array.from(files); | ||
| const previousImageCount = images.length; | ||
| const preparedItems: Array<ImageUploadItem | null> = new Array(selectedFiles.length).fill(null); | ||
| const createdItems: ImageUploadItem[] = []; | ||
| let visiblePreparedCount = 0; | ||
|
|
||
| updatePreparing(true); | ||
|
|
||
| mapWithConcurrencyLimit( | ||
| selectedFiles, | ||
| IMAGE_PREPARATION_CONCURRENCY, | ||
| async (file) => createLocalImageUploadItem(await prepareImageFile(file)), | ||
| (preparedItem, index) => { | ||
| createdItems.push(preparedItem); | ||
|
|
||
| if (!isMountedRef.current) return; | ||
|
|
||
| if (selectionMode === 'single') { | ||
| startTransition(() => { | ||
| onChange([preparedItem]); | ||
| setCurrentImageIndex(0); | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| preparedItems[index] = preparedItem; | ||
|
|
||
| let nextVisiblePreparedCount = visiblePreparedCount; | ||
|
|
||
| while (nextVisiblePreparedCount < preparedItems.length && preparedItems[nextVisiblePreparedCount]) { | ||
| nextVisiblePreparedCount += 1; | ||
| } | ||
|
|
||
| if (nextVisiblePreparedCount === visiblePreparedCount) return; | ||
|
|
||
| const shouldFocusFirstPreparedImage = visiblePreparedCount === 0 && nextVisiblePreparedCount > 0; | ||
| visiblePreparedCount = nextVisiblePreparedCount; | ||
|
|
||
| const orderedPreparedItems = preparedItems | ||
| .slice(0, visiblePreparedCount) | ||
| .filter(Boolean) as ImageUploadItem[]; | ||
|
|
||
| startTransition(() => { | ||
| onChange([...images.slice(0, previousImageCount), ...orderedPreparedItems]); | ||
|
|
||
| if (shouldFocusFirstPreparedImage) { | ||
| setCurrentImageIndex(previousImageCount); | ||
| } | ||
| }); | ||
| } | ||
| ) | ||
| .catch(() => { | ||
| showToast('이미지 처리에 실패했습니다. 다시 시도해주세요.', 'error'); | ||
| }) | ||
| .finally(() => { | ||
| if (!isMountedRef.current) { | ||
| createdItems.forEach(revokeImagePreviewUrl); | ||
| return; | ||
| } | ||
|
|
||
| updatePreparing(false); | ||
| e.target.value = ''; | ||
| }); |
There was a problem hiding this comment.
When mapWithConcurrencyLimit aborts on the first prepareImageFile error, createdItems may already contain items whose previewUrl was created but never committed via onChange (e.g., out-of-order prepared items in multiple mode). In that case those blob: URLs are never revoked, causing a memory leak. Consider tracking which prepared items were actually applied (e.g., an appliedIds set updated whenever onChange runs) and revoking the remaining createdItems on error/abort, or changing the preparation flow so every created item is either committed or revoked.
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (4)
src/pages/Manager/ManagedClubProfile/index.tsx (1)
96-104:className조합은cn()으로 맞춰주세요.여기는 Tailwind 클래스를 템플릿 문자열로 합치고 있어서, 이후 조건부 클래스가 붙으면 중복/충돌 정리가 어려워집니다.
cn(cardClassName, 'relative size-62.5 p-3')형태로 맞추는 편이 일관됩니다.예시
+import { cn } from '@/utils/ts/cn'; ... - className={`${cardClassName} relative size-62.5 p-3`} + className={cn(cardClassName, 'relative size-62.5 p-3')}As per coding guidelines
src/components/**: 동적 TailwindCSS 클래스 조합 시 cn()/clsx()를 사용하는지.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/Manager/ManagedClubProfile/index.tsx` around lines 96 - 104, Replace the template-string Tailwind class composition passed into the ImageUploader's className prop with the project utility (cn) so classes are composed consistently; specifically change the prop on the ImageUploader component that currently uses `${cardClassName} relative size-62.5 p-3` to use cn(cardClassName, 'relative size-62.5 p-3') (ensure cn is imported if missing) so conditional/duplicate class handling follows the src/components guideline.src/components/common/ImageUploader/hooks/useImageUploader.ts (1)
6-8: 상대 경로 import 대신@/*alias를 써주세요.신규
src/**모듈에서 상대 경로가 섞이면 파일 이동이나 import 정렬 때 관리가 번거로워집니다. 이 파일은 alias로 통일하는 편이 저장소 규칙과 맞습니다.예시
-import { createExistingImageUploadItem } from '../utils/imageUploadItem'; -import { resolveImageUploadItemUrls } from '../utils/resolveImageUploadItems'; -import type { ImageUploadItem } from '../types'; +import type { ImageUploadItem } from '@/components/common/ImageUploader/types'; +import { createExistingImageUploadItem } from '@/components/common/ImageUploader/utils/imageUploadItem'; +import { resolveImageUploadItemUrls } from '@/components/common/ImageUploader/utils/resolveImageUploadItems';As per coding guidelines
**/*.{ts,tsx}: Use import alias@/*instead of relative paths.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/common/ImageUploader/hooks/useImageUploader.ts` around lines 6 - 8, The imports in useImageUploader.ts use relative paths; replace them with the repository import alias `@/*` so modules like createExistingImageUploadItem, resolveImageUploadItemUrls and the ImageUploadItem type are imported via the alias (e.g., import { createExistingImageUploadItem } from '@/components/common/ImageUploader/utils/imageUploadItem') to match the codebase rule requiring `@/*` for src imports and avoid mixed relative paths.src/components/common/ImageUploader/ImageUploader.tsx (2)
184-195: Dot indicator에 현재 선택 상태 aria 속성 추가 권장현재 선택된 이미지를 나타내는 dot indicator에
aria-current속성을 추가하면 접근성이 향상됩니다.♿ 접근성 개선 제안
<button key={image.id} type="button" onClick={() => selectImage(index)} aria-label={`${index + 1}번 이미지 보기`} + aria-current={index === currentIndex ? 'true' : undefined} className={cn(🤖 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 184 - 195, The dot indicators rendered in the ImageUploader (the value.map loop) lack an accessibility attribute for the currently selected item; update the button rendering inside the map (where selectImage is used and index compared to currentIndex) to include aria-current={index === currentIndex ? "true" : undefined} (or aria-current="false"/omitted when not current) so the active dot is programmatically conveyed to assistive tech; ensure the attribute is applied to the same button element that has the onClick/selectImage handler and conditional className.
62-63: 하드코딩된 색상 대신 테마 토큰 사용 권장
#9f9f9f는 디자인 토큰으로 대체하는 것이 좋습니다. As per coding guidelines,src/styles/theme.css의 색상 토큰(예:text-*,gray-*)을 우선 사용해야 합니다.🤖 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 62 - 63, The className in ImageUploader uses a hardcoded color '#9f9f9f' inside the cn(...) call; replace that literal with the project's theme token instead (e.g., the appropriate bg-* or CSS variable defined in src/styles/theme.css) so the component uses design tokens; update the string in the className passed to cn (within ImageUploader/ImageUploader.tsx) to use the theme class (for example bg-gray-?? or bg-[var(--color-gray-??)]) rather than '#9f9f9f' and ensure any tests/styles still pass.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/components/common/ImageUploader/ImageUploader.tsx`:
- Around line 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.
- Around line 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.
In `@src/pages/Club/Application/clubFeePage.tsx`:
- Around line 20-37: Wrap the image upload in handleSubmit with a try/catch:
when canSubmitImage is true, set isPreparingImage (setIsPreparingImage) true,
call uploadImages([selectedImage]) inside try, and on success continue to
applyToClub({ answers, feePaymentImageUrl }); on any upload failure catch the
error, clear/reset isPreparingImage, set or surface an upload error state (e.g.,
add/use an uploadError state or existing uploadImages error return) and prevent
calling applyToClub; also ensure isSubmitting booleans remain correct so the UI
shows the error and re-enables submission (refer to handleSubmit, uploadImages,
selectedImage, isPreparingImage, isUploadingImage, canSubmitImage).
In `@src/pages/Manager/ManagedRecruitmentWrite/index.tsx`:
- Around line 45-46: The useParams call is currently typed as { clubId: string }
but at compile-time TypeScript may treat clubId as possibly undefined; update
the code to handle that by either making the param optional in the type and
guarding at runtime or asserting if you know the route guarantees it.
Specifically, change the useParams typing to { clubId?: string } and add a
null-check that returns an error UI (e.g., render ErrorComponent) before
computing clubIdNumber, or if you prefer to assert, keep { clubId: string } and
add an explicit non-null assertion when assigning clubIdNumber; apply the same
pattern to other Manager pages using useParams, referencing the useParams,
clubId, and clubIdNumber identifiers to locate where to adjust.
---
Nitpick comments:
In `@src/components/common/ImageUploader/hooks/useImageUploader.ts`:
- Around line 6-8: The imports in useImageUploader.ts use relative paths;
replace them with the repository import alias `@/*` so modules like
createExistingImageUploadItem, resolveImageUploadItemUrls and the
ImageUploadItem type are imported via the alias (e.g., import {
createExistingImageUploadItem } from
'@/components/common/ImageUploader/utils/imageUploadItem') to match the codebase
rule requiring `@/*` for src imports and avoid mixed relative paths.
In `@src/components/common/ImageUploader/ImageUploader.tsx`:
- Around line 184-195: The dot indicators rendered in the ImageUploader (the
value.map loop) lack an accessibility attribute for the currently selected item;
update the button rendering inside the map (where selectImage is used and index
compared to currentIndex) to include aria-current={index === currentIndex ?
"true" : undefined} (or aria-current="false"/omitted when not current) so the
active dot is programmatically conveyed to assistive tech; ensure the attribute
is applied to the same button element that has the onClick/selectImage handler
and conditional className.
- Around line 62-63: The className in ImageUploader uses a hardcoded color
'#9f9f9f' inside the cn(...) call; replace that literal with the project's theme
token instead (e.g., the appropriate bg-* or CSS variable defined in
src/styles/theme.css) so the component uses design tokens; update the string in
the className passed to cn (within ImageUploader/ImageUploader.tsx) to use the
theme class (for example bg-gray-?? or bg-[var(--color-gray-??)]) rather than
'#9f9f9f' and ensure any tests/styles still pass.
In `@src/pages/Manager/ManagedClubProfile/index.tsx`:
- Around line 96-104: Replace the template-string Tailwind class composition
passed into the ImageUploader's className prop with the project utility (cn) so
classes are composed consistently; specifically change the prop on the
ImageUploader component that currently uses `${cardClassName} relative size-62.5
p-3` to use cn(cardClassName, 'relative size-62.5 p-3') (ensure cn is imported
if missing) so conditional/duplicate class handling follows the src/components
guideline.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 6c9316f7-746c-4336-96ad-8d4048aadd7a
📒 Files selected for processing (13)
src/apis/upload/mutations.tssrc/components/common/ImageUploader/ImageUploader.tsxsrc/components/common/ImageUploader/hooks/useImageCarousel.tssrc/components/common/ImageUploader/hooks/useImagePreparation.tssrc/components/common/ImageUploader/hooks/useImageUploader.tssrc/components/common/ImageUploader/index.tssrc/components/common/ImageUploader/types.tssrc/components/common/ImageUploader/utils/imageUploadItem.tssrc/components/common/ImageUploader/utils/resolveImageUploadItems.tssrc/pages/Club/Application/clubFeePage.tsxsrc/pages/Manager/ManagedClubProfile/index.tsxsrc/pages/Manager/ManagedRecruitmentWrite/index.tsxsrc/utils/hooks/image/useUploadImage.ts
💤 Files with no reviewable changes (2)
- src/utils/hooks/image/useUploadImage.ts
- src/apis/upload/mutations.ts
| return ( | ||
| <button type="button" onClick={() => onPreviewClick(image, index)} className="h-full w-full"> | ||
| {imageNode} | ||
| </button> | ||
| ); |
There was a problem hiding this comment.
이미지 미리보기 버튼에 접근성 레이블 추가
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.
| 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.
| <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> |
There was a problem hiding this comment.
"이미지 추가" 버튼 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.
| <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.
| const { | ||
| images, | ||
| isUploadingImages: isUploadingImage, | ||
| selectedImage, | ||
| setImages, | ||
| uploadImages, | ||
| } = useImageUploader({ target: 'CLUB' }); | ||
|
|
||
| useEffect(() => { | ||
| if (storedClubId == null || storedClubId !== Number(clubId)) { | ||
| navigate(`/clubs/${clubId}/apply`, { replace: true }); | ||
| } | ||
| }, [storedClubId, clubId, navigate]); | ||
| const { showToast } = useToastContext(); | ||
| const { mutateAsync: uploadImage, isPending: isUploadingImage } = useUploadImage('CLUB'); | ||
|
|
||
| const fileInputRef = useRef<HTMLInputElement>(null); | ||
| const [previewUrl, setPreviewUrl] = useState<string | null>(null); | ||
| const [imageFile, setImageFile] = useState<File | null>(null); | ||
| const [isPreparingImage, setIsPreparingImage] = useState(false); | ||
| const { value: isImageOpen, setTrue: openImage, setFalse: closeImage } = useBooleanState(); | ||
| const isSubmitting = isApplyingToClub || isPreparingImage || isUploadingImage; | ||
|
|
||
| useEffect(() => { | ||
| return () => { | ||
| if (previewUrl) URL.revokeObjectURL(previewUrl); | ||
| }; | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); | ||
|
|
||
| const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const file = e.target.files?.[0]; | ||
| if (!file || isPreparingImage) return; | ||
|
|
||
| setIsPreparingImage(true); | ||
|
|
||
| try { | ||
| const preparedFile = await prepareImageFile(file); | ||
|
|
||
| if (previewUrl) URL.revokeObjectURL(previewUrl); | ||
| setImageFile(preparedFile); | ||
| setPreviewUrl(URL.createObjectURL(preparedFile)); | ||
| } catch { | ||
| showToast('이미지 처리에 실패했습니다. 다시 시도해주세요.', 'error'); | ||
| if (fileInputRef.current) fileInputRef.current.value = ''; | ||
| } finally { | ||
| setIsPreparingImage(false); | ||
| } | ||
| }; | ||
| const canSubmitImage = selectedImage?.kind === 'local'; | ||
|
|
||
| const handleSubmit = async () => { | ||
| if (!imageFile) return; | ||
| if (!canSubmitImage) return; | ||
|
|
||
| const { fileUrl } = await uploadImage(imageFile); | ||
| await applyToClub({ answers, feePaymentImageUrl: fileUrl }); | ||
| const [feePaymentImageUrl] = await uploadImages([selectedImage]); | ||
| await applyToClub({ answers, feePaymentImageUrl }); |
There was a problem hiding this comment.
업로드 실패가 사용자에게 보이지 않습니다.
uploadImages([selectedImage])가 실패하면 이 페이지는 uploadError를 노출하지도, handleSubmit에서 에러를 잡지도 않아서 제출이 그냥 멈춘 것처럼 보입니다. 적어도 업로드 단계는 try/catch로 처리하고 실패 메시지를 보여주는 편이 안전합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/pages/Club/Application/clubFeePage.tsx` around lines 20 - 37, Wrap the
image upload in handleSubmit with a try/catch: when canSubmitImage is true, set
isPreparingImage (setIsPreparingImage) true, call uploadImages([selectedImage])
inside try, and on success continue to applyToClub({ answers, feePaymentImageUrl
}); on any upload failure catch the error, clear/reset isPreparingImage, set or
surface an upload error state (e.g., add/use an uploadError state or existing
uploadImages error return) and prevent calling applyToClub; also ensure
isSubmitting booleans remain correct so the UI shows the error and re-enables
submission (refer to handleSubmit, uploadImages, selectedImage,
isPreparingImage, isUploadingImage, canSubmitImage).
| const { clubId } = useParams<{ clubId: string }>(); | ||
| const clubIdNumber = Number(clubId); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: 라우터 설정에서 clubId 파라미터가 필수인지 확인
rg -n 'ManagedRecruitmentWrite' --type=tsx -A 3 -B 3
rg -n 'path.*clubId.*recruitment' src/Repository: BCSDLab/KONECT_FRONT_END
Length of output: 1796
🏁 Script executed:
#!/bin/bash
# 라우터 설정 및 clubId 파라미터 확인
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) 2>/dev/null | head -20
git log --oneline 2>/dev/null | head -5 || echo "Git not available"
ls -la src/ 2>/dev/null || echo "src directory not found"Repository: BCSDLab/KONECT_FRONT_END
Length of output: 1642
🏁 Script executed:
#!/bin/bash
# 라우터 설정 및 ManagedRecruitmentWrite 페이지 확인
find . -type f \( -name "*route*" -o -name "*Router*" -o -name "*App.tsx" \) | grep -E "\.(ts|tsx)$"
cat src/App.tsxRepository: BCSDLab/KONECT_FRONT_END
Length of output: 7715
🏁 Script executed:
#!/bin/bash
# ManagedRecruitmentWrite 페이지 내용 확인
find . -path "*/Manager/ManagedRecruitmentWrite/*" -type f
cat src/pages/Manager/ManagedRecruitmentWrite/index.tsx | head -100Repository: BCSDLab/KONECT_FRONT_END
Length of output: 5082
🏁 Script executed:
#!/bin/bash
# 전체 파일 확인 및 쿼리 함수 검토
wc -l src/pages/Manager/ManagedRecruitmentWrite/index.tsx
cat src/pages/Manager/ManagedRecruitmentWrite/index.tsx | tail -200
# managedQueries 함수 확인
rg -A 5 "recruitment\(" src/apis/club/managedQueries.tsRepository: BCSDLab/KONECT_FRONT_END
Length of output: 9607
🏁 Script executed:
#!/bin/bash
# managedQueries 전체 확인 및 API 호출 함수 검토
cat src/apis/club/managedQueries.ts | head -80
# getClubRecruitment 함수 확인
rg -A 10 "getClubRecruitment" src/apis/club/Repository: BCSDLab/KONECT_FRONT_END
Length of output: 6397
🏁 Script executed:
#!/bin/bash
# 라우터 설정 확인 및 다른 clubId 사용 패턴 비교
rg -B 2 "useParams" src/pages/Manager/ --type=tsx | head -40Repository: BCSDLab/KONECT_FRONT_END
Length of output: 95
🏁 Script executed:
#!/bin/bash
# 다른 Manager 페이지에서 clubId 사용 패턴 확인
rg -B 2 "useParams" src/pages/Manager/ -A 2 | head -60Repository: BCSDLab/KONECT_FRONT_END
Length of output: 4986
useParams의 타입 안전성 개선 필요
라우터에서 :clubId를 필수 파라미터로 정의했으므로 런타임에는 clubId가 항상 존재하지만, TypeScript 타입이 string | undefined를 반영하지 않습니다. 타입을 정정하거나 유효성 검사를 추가하세요.
// 타입 수정 방안 1: 타입 선언
const { clubId } = useParams<{ clubId?: string }>();
if (!clubId) return <ErrorComponent />;
// 방안 2: 타입 단언 (라우터가 보장할 때)
const { clubId } = useParams<{ clubId: string }>();
const clubIdNumber = Number(clubId);다른 Manager 페이지들도 동일한 패턴을 사용하므로 일관되게 수정하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/pages/Manager/ManagedRecruitmentWrite/index.tsx` around lines 45 - 46,
The useParams call is currently typed as { clubId: string } but at compile-time
TypeScript may treat clubId as possibly undefined; update the code to handle
that by either making the param optional in the type and guarding at runtime or
asserting if you know the route guarantees it. Specifically, change the
useParams typing to { clubId?: string } and add a null-check that returns an
error UI (e.g., render ErrorComponent) before computing clubIdNumber, or if you
prefer to assert, keep { clubId: string } and add an explicit non-null assertion
when assigning clubIdNumber; apply the same pattern to other Manager pages using
useParams, referencing the useParams, clubId, and clubIdNumber identifiers to
locate where to adjust.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/components/common/ImageUploader/hooks/useImagePreparation.ts (2)
76-88:single모드에서는 첫 파일만 처리하도록 방어 코드를 두는 걸 권장합니다.현재는
single이어도 선택된 파일 전체를 전처리합니다. 입력 설정이 어긋난 경우 불필요한 전처리/URL 생성이 발생할 수 있어 첫 파일만 제한하는 편이 안전합니다.변경 예시
- const selectedFiles = Array.from(files); + const selectedFiles = + selectionMode === 'single' ? [files[0]].filter((file): file is File => Boolean(file)) : Array.from(files);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/common/ImageUploader/hooks/useImagePreparation.ts` around lines 76 - 88, When the uploader is in single mode, limit preprocessing to the first selected file to avoid unnecessary work: detect the single-mode flag (e.g., a prop or variable representing "single" mode) and, before calling mapWithConcurrencyLimit, slice or reduce selectedFiles to only [selectedFiles[0]] when single is true; keep the rest of the flow (prepareImageFile, createLocalImageUploadItem, IMAGE_PREPARATION_CONCURRENCY, selectedFiles, preparedItems, createdItems, committedImageIds, visiblePreparedCount) unchanged so only the first file is passed into mapWithConcurrencyLimit and subsequent processing.
14-15: 상대경로 import를@/*별칭으로 통일해 주세요.현재 parent 상대경로 import가 들어가 있어 import 규칙과 정렬 규칙을 동시에 벗어납니다. 내부 모듈은
@/*로 통일하고 internal 그룹 내 알파벳 순서도 맞춰주세요.변경 예시
import { startTransition, type ChangeEvent, type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState, } from 'react'; +import type { ImageUploadItem, ImageUploaderSelectionMode } from '@/components/common/ImageUploader/types'; +import { createLocalImageUploadItem, revokeImagePreviewUrl } from '@/components/common/ImageUploader/utils/imageUploadItem'; import { useToastContext } from '@/contexts/useToastContext'; import { prepareImageFile } from '@/utils/ts/image/imagePreprocessor'; import { mapWithConcurrencyLimit } from '@/utils/ts/promise'; -import { createLocalImageUploadItem, revokeImagePreviewUrl } from '../utils/imageUploadItem'; -import type { ImageUploaderSelectionMode, ImageUploadItem } from '../types';As per coding guidelines, "Use path alias
@/*for imports instead of relative paths" and "Maintain ESLint import/order groups: builtin -> external -> internal(@/**) -> parent -> sibling -> index, with alphabetical sorting within groups".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/common/ImageUploader/hooks/useImagePreparation.ts` around lines 14 - 15, The import of createLocalImageUploadItem and revokeImagePreviewUrl uses a parent relative path; change it to the project path-alias form (use `@/`*) and place it in the internal imports group, ensuring the internal imports are alphabetized—update the import for createLocalImageUploadItem and revokeImagePreviewUrl in useImagePreparation.ts to use the `@/`... alias instead of ../utils/imageUploadItem and reorder imports so they follow the ESLint import/order sequence with alphabetical sorting inside the internal group.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/components/common/ImageUploader/hooks/useImagePreparation.ts`:
- Around line 76-88: When the uploader is in single mode, limit preprocessing to
the first selected file to avoid unnecessary work: detect the single-mode flag
(e.g., a prop or variable representing "single" mode) and, before calling
mapWithConcurrencyLimit, slice or reduce selectedFiles to only
[selectedFiles[0]] when single is true; keep the rest of the flow
(prepareImageFile, createLocalImageUploadItem, IMAGE_PREPARATION_CONCURRENCY,
selectedFiles, preparedItems, createdItems, committedImageIds,
visiblePreparedCount) unchanged so only the first file is passed into
mapWithConcurrencyLimit and subsequent processing.
- Around line 14-15: The import of createLocalImageUploadItem and
revokeImagePreviewUrl uses a parent relative path; change it to the project
path-alias form (use `@/`*) and place it in the internal imports group, ensuring
the internal imports are alphabetized—update the import for
createLocalImageUploadItem and revokeImagePreviewUrl in useImagePreparation.ts
to use the `@/`... alias instead of ../utils/imageUploadItem and reorder imports
so they follow the ESLint import/order sequence with alphabetical sorting inside
the internal group.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 7a92bce1-30cd-444d-96cd-6169ce5bc55c
📒 Files selected for processing (1)
src/components/common/ImageUploader/hooks/useImagePreparation.ts
✨ 요약
😎 해결한 이슈
Summary by CodeRabbit
릴리즈 노트