From 09bca7bbdcc5025fa18c1b48a252d391428b7288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Thu, 16 Apr 2026 03:05:52 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=8D=94=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/ImageUploader/ImageUploader.tsx | 233 ++++++++++++++++++ .../ImageUploader/hooks/useImageCarousel.ts | 60 +++++ .../hooks/useImagePreparation.ts | 156 ++++++++++++ .../ImageUploader/hooks/useImageUploader.ts | 49 ++++ src/components/common/ImageUploader/index.ts | 3 + src/components/common/ImageUploader/types.ts | 18 ++ .../ImageUploader/utils/imageUploadItem.ts | 28 +++ .../utils/resolveImageUploadItems.ts | 15 ++ 8 files changed, 562 insertions(+) create mode 100644 src/components/common/ImageUploader/ImageUploader.tsx create mode 100644 src/components/common/ImageUploader/hooks/useImageCarousel.ts create mode 100644 src/components/common/ImageUploader/hooks/useImagePreparation.ts create mode 100644 src/components/common/ImageUploader/hooks/useImageUploader.ts create mode 100644 src/components/common/ImageUploader/index.ts create mode 100644 src/components/common/ImageUploader/types.ts create mode 100644 src/components/common/ImageUploader/utils/imageUploadItem.ts create mode 100644 src/components/common/ImageUploader/utils/resolveImageUploadItems.ts diff --git a/src/components/common/ImageUploader/ImageUploader.tsx b/src/components/common/ImageUploader/ImageUploader.tsx new file mode 100644 index 0000000..38ab7de --- /dev/null +++ b/src/components/common/ImageUploader/ImageUploader.tsx @@ -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 ( + + ); +} + +interface ImageUploaderActionButtonProps { + action: 'change' | 'delete'; + disabled: boolean; + onClick: () => void; +} + +function ImageUploaderActionButton({ action, disabled, onClick }: ImageUploaderActionButtonProps) { + const isDeleteAction = action === 'delete'; + + return ( + + ); +} + +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 = () => ( + + ); + + const renderPreviewImage = (image: ImageUploadItem, index: number) => { + const imageNode = ( + {previewAlt(index)} + ); + + if (!onPreviewClick) return imageNode; + + return ( + + ); + }; + + const renderSquarePreview = () => { + if (!currentImage) return renderEmptyState(); + + return ( + <> +
+ {renderPreviewImage(currentImage, currentIndex)} +
+ + + + ); + }; + + const renderWidePreview = () => { + if (!currentImage) return renderEmptyState(); + + return ( +
+
+ {renderPreviewImage(currentImage, currentIndex)} + + + {value.length > 1 && ( + <> + + + + )} +
+ + {selectionMode === 'multiple' && ( +
+
+ {value.map((image, index) => ( +
+ + +
+ )} +
+ ); + }; + + const renderContent = () => { + if (layout === 'square') return renderSquarePreview(); + return renderWidePreview(); + }; + + return ( +
+ + {renderContent()} +
+ ); +} + +export default ImageUploader; diff --git a/src/components/common/ImageUploader/hooks/useImageCarousel.ts b/src/components/common/ImageUploader/hooks/useImageCarousel.ts new file mode 100644 index 0000000..0ec84f1 --- /dev/null +++ b/src/components/common/ImageUploader/hooks/useImageCarousel.ts @@ -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>; +} + +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, + }; +} diff --git a/src/components/common/ImageUploader/hooks/useImagePreparation.ts b/src/components/common/ImageUploader/hooks/useImagePreparation.ts new file mode 100644 index 0000000..aec186d --- /dev/null +++ b/src/components/common/ImageUploader/hooks/useImagePreparation.ts @@ -0,0 +1,156 @@ +import { + startTransition, + type ChangeEvent, + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +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'; + +const IMAGE_PREPARATION_CONCURRENCY = 2; + +interface UseImagePreparationOptions { + disabled: boolean; + images: ImageUploadItem[]; + onChange: (images: ImageUploadItem[]) => void; + onPreparingChange?: (isPreparing: boolean) => void; + selectionMode: ImageUploaderSelectionMode; + setCurrentImageIndex: Dispatch>; +} + +export function useImagePreparation({ + disabled, + images, + onChange, + onPreparingChange, + selectionMode, + setCurrentImageIndex, +}: UseImagePreparationOptions) { + const { showToast } = useToastContext(); + const fileInputRef = useRef(null); + const previousImagesRef = useRef(images); + const isMountedRef = useRef(true); + const [isPreparing, setIsPreparing] = useState(false); + const isDisabled = disabled || isPreparing; + + useEffect(() => { + const removedImages = previousImagesRef.current.filter( + (previousImage) => !images.some((nextImage) => nextImage.id === previousImage.id) + ); + + removedImages.forEach(revokeImagePreviewUrl); + previousImagesRef.current = images; + }, [images]); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + previousImagesRef.current.forEach(revokeImagePreviewUrl); + }; + }, []); + + const updatePreparing = useCallback( + (nextIsPreparing: boolean) => { + if (!isMountedRef.current) return; + + setIsPreparing(nextIsPreparing); + onPreparingChange?.(nextIsPreparing); + }, + [onPreparingChange] + ); + + const handleImageSelect = useCallback( + (e: ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0 || isDisabled) return; + + const selectedFiles = Array.from(files); + const previousImageCount = images.length; + const preparedItems: Array = 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 = ''; + }); + }, + [images, isDisabled, onChange, selectionMode, setCurrentImageIndex, showToast, updatePreparing] + ); + + const openFilePicker = useCallback(() => { + if (isDisabled) return; + + fileInputRef.current?.click(); + }, [isDisabled]); + + return { + fileInputRef, + handleImageSelect, + isDisabled, + isPreparing, + openFilePicker, + }; +} diff --git a/src/components/common/ImageUploader/hooks/useImageUploader.ts b/src/components/common/ImageUploader/hooks/useImageUploader.ts new file mode 100644 index 0000000..f8af5f9 --- /dev/null +++ b/src/components/common/ImageUploader/hooks/useImageUploader.ts @@ -0,0 +1,49 @@ +import { useCallback, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { uploadImage } from '@/apis/upload'; +import type { UploadTarget } from '@/apis/upload/entity'; +import type { ApiError } from '@/utils/ts/error/apiError'; +import { createExistingImageUploadItem } from '../utils/imageUploadItem'; +import { resolveImageUploadItemUrls } from '../utils/resolveImageUploadItems'; +import type { ImageUploadItem } from '../types'; + +interface UseImageUploaderOptions { + initialImageUrls?: string[]; + target: UploadTarget; +} + +function createExistingImageUploadItems(imageUrls: string[]) { + return imageUrls.map((imageUrl) => createExistingImageUploadItem(imageUrl)); +} + +export function useImageUploader({ initialImageUrls = [], target }: UseImageUploaderOptions) { + const [images, setImages] = useState(() => createExistingImageUploadItems(initialImageUrls)); + const { mutateAsync, isPending, error } = useMutation({ + mutationKey: ['image-uploader', 'upload-items', target], + mutationFn: (imagesToUpload: ImageUploadItem[]) => + resolveImageUploadItemUrls(imagesToUpload, (file) => uploadImage(file, target)), + }); + + const resetImages = useCallback((imageUrls: string[] = []) => { + setImages(createExistingImageUploadItems(imageUrls)); + }, []); + + const selectedImage = images[0]; + const selectedImageUrl = selectedImage?.previewUrl ?? ''; + + const uploadImages = useCallback( + (imagesToUpload: ImageUploadItem[] = images) => mutateAsync(imagesToUpload), + [images, mutateAsync] + ); + + return { + images, + isUploadingImages: isPending, + resetImages, + selectedImage, + selectedImageUrl, + setImages, + uploadError: error as ApiError | null, + uploadImages, + }; +} diff --git a/src/components/common/ImageUploader/index.ts b/src/components/common/ImageUploader/index.ts new file mode 100644 index 0000000..e794f54 --- /dev/null +++ b/src/components/common/ImageUploader/index.ts @@ -0,0 +1,3 @@ +export { default } from './ImageUploader'; +export { useImageUploader } from './hooks/useImageUploader'; +export type { ImageUploadItem } from './types'; diff --git a/src/components/common/ImageUploader/types.ts b/src/components/common/ImageUploader/types.ts new file mode 100644 index 0000000..d8abea8 --- /dev/null +++ b/src/components/common/ImageUploader/types.ts @@ -0,0 +1,18 @@ +export type ImageUploaderSelectionMode = 'single' | 'multiple'; +export type ImageUploaderLayout = 'wide' | 'square'; + +interface ImageUploadItemBase { + id: string; + previewUrl: string; +} + +export interface ExistingImageUploadItem extends ImageUploadItemBase { + kind: 'existing'; +} + +export interface LocalImageUploadItem extends ImageUploadItemBase { + file: File; + kind: 'local'; +} + +export type ImageUploadItem = ExistingImageUploadItem | LocalImageUploadItem; diff --git a/src/components/common/ImageUploader/utils/imageUploadItem.ts b/src/components/common/ImageUploader/utils/imageUploadItem.ts new file mode 100644 index 0000000..f891f58 --- /dev/null +++ b/src/components/common/ImageUploader/utils/imageUploadItem.ts @@ -0,0 +1,28 @@ +import type { ExistingImageUploadItem, ImageUploadItem, LocalImageUploadItem } from '../types'; + +export function createImageUploadItemId() { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; +} + +export function createExistingImageUploadItem(previewUrl: string): ExistingImageUploadItem { + return { + id: createImageUploadItemId(), + kind: 'existing', + previewUrl, + }; +} + +export function createLocalImageUploadItem(file: File): LocalImageUploadItem { + return { + id: createImageUploadItemId(), + file, + kind: 'local', + previewUrl: URL.createObjectURL(file), + }; +} + +export function revokeImagePreviewUrl(image: ImageUploadItem) { + if (image.kind === 'local' && image.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(image.previewUrl); + } +} diff --git a/src/components/common/ImageUploader/utils/resolveImageUploadItems.ts b/src/components/common/ImageUploader/utils/resolveImageUploadItems.ts new file mode 100644 index 0000000..0079ef8 --- /dev/null +++ b/src/components/common/ImageUploader/utils/resolveImageUploadItems.ts @@ -0,0 +1,15 @@ +import type { ImageUploadItem } from '../types'; + +type UploadImageFile = (file: File) => Promise<{ fileUrl: string }>; + +export async function resolveImageUploadItemUrl(image: ImageUploadItem | undefined, uploadImage: UploadImageFile) { + if (!image) return ''; + if (image.kind === 'existing') return image.previewUrl; + + const { fileUrl } = await uploadImage(image.file); + return fileUrl; +} + +export async function resolveImageUploadItemUrls(images: ImageUploadItem[], uploadImage: UploadImageFile) { + return Promise.all(images.map((image) => resolveImageUploadItemUrl(image, uploadImage))); +} From f946d6057612bb7c44c862dfb6b49c520bd121ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Thu, 16 Apr 2026 03:06:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=82=AC=EC=9A=A9=EC=B2=98=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Club/Application/clubFeePage.tsx | 120 ++---- .../Manager/ManagedClubProfile/index.tsx | 183 ++------- .../Manager/ManagedRecruitmentWrite/index.tsx | 350 ++++-------------- 3 files changed, 142 insertions(+), 511 deletions(-) diff --git a/src/pages/Club/Application/clubFeePage.tsx b/src/pages/Club/Application/clubFeePage.tsx index a500476..9d97160 100644 --- a/src/pages/Club/Application/clubFeePage.tsx +++ b/src/pages/Club/Application/clubFeePage.tsx @@ -1,16 +1,13 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate, useParams } from 'react-router-dom'; import { clubQueries } from '@/apis/club/queries'; -import ImageIcon from '@/assets/svg/image.svg'; import WarningCircleIcon from '@/assets/svg/warning-circle.svg'; import Card from '@/components/common/Card'; +import ImageUploader, { useImageUploader } from '@/components/common/ImageUploader'; import Portal from '@/components/common/Portal'; -import { useToastContext } from '@/contexts/useToastContext'; import { useClubApplicationStore } from '@/stores/clubApplicationStore'; -import useUploadImage from '@/utils/hooks/image/useUploadImage'; import useBooleanState from '@/utils/hooks/useBooleanState'; -import { prepareImageFile } from '@/utils/ts/image/imagePreprocessor'; import AccountInfoCard from './components/AccountInfo'; import useApplyToClub from './hooks/useApplyToClub'; @@ -20,56 +17,32 @@ function ClubFeePage() { const { data: clubFee } = useSuspenseQuery(clubQueries.fee(Number(clubId))); const { applyToClub, isPending: isApplyingToClub } = useApplyToClub(Number(clubId)); const { answers, clubId: storedClubId } = useClubApplicationStore(); + 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(null); - const [previewUrl, setPreviewUrl] = useState(null); - const [imageFile, setImageFile] = useState(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) => { - 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 }); }; + useEffect(() => { + if (storedClubId == null || storedClubId !== Number(clubId)) { + navigate(`/clubs/${clubId}/apply`, { replace: true }); + } + }, [storedClubId, clubId, navigate]); + return (
입금 확인 인증
- openImage()} + previewAlt={() => '입금 확인'} /> -
- {previewUrl ? ( -
- - -
- ) : ( - - )} -
@@ -146,16 +88,16 @@ function ClubFeePage() { type="button" className="bg-primary mt-5 w-full rounded-lg py-2.5 text-center text-lg leading-7 font-bold text-white disabled:opacity-50" onClick={handleSubmit} - disabled={!imageFile || isSubmitting} + disabled={!canSubmitImage || isSubmitting} > {isPreparingImage ? '이미지 준비 중...' : isSubmitting ? '제출 중...' : '제출하기'} - {isImageOpen && previewUrl && ( + {isImageOpen && selectedImage && (
입금 확인 e.stopPropagation()} diff --git a/src/pages/Manager/ManagedClubProfile/index.tsx b/src/pages/Manager/ManagedClubProfile/index.tsx index 310c918..411fb79 100644 --- a/src/pages/Manager/ManagedClubProfile/index.tsx +++ b/src/pages/Manager/ManagedClubProfile/index.tsx @@ -1,15 +1,13 @@ -import { type ChangeEvent, type MutableRefObject, useEffect, useRef, useState } from 'react'; +import { type ChangeEvent, useState } from 'react'; import { useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate, useParams } from 'react-router-dom'; import { clubQueries } from '@/apis/club/queries'; -import ImageIcon from '@/assets/svg/image.svg'; import BottomModal from '@/components/common/BottomModal'; +import ImageUploader, { useImageUploader } from '@/components/common/ImageUploader'; import { useToastContext } from '@/contexts/useToastContext'; import { useUpdateManagedClubInfoMutation } from '@/pages/Manager/hooks/useManagedClubMutations'; -import useUploadImage from '@/utils/hooks/image/useUploadImage'; import useBooleanState from '@/utils/hooks/useBooleanState'; import { getApiErrorMessage, getApiErrorMessages } from '@/utils/ts/error/apiErrorMessage'; -import { prepareImageFile } from '@/utils/ts/image/imagePreprocessor'; const DESCRIPTION_MAX_LENGTH = 25; @@ -21,27 +19,20 @@ const fieldInputClassName = `${fieldControlClassName} h-[31px]`; const disabledFieldInputClassName = 'h-[31px] w-full rounded-lg border border-transparent bg-background px-3 text-[13px] leading-[20.8px] font-medium text-text-500 outline-none disabled:cursor-not-allowed disabled:opacity-100 disabled:[-webkit-text-fill-color:#5A6B7F]'; const fieldTextAreaClassName = `${fieldControlClassName} min-h-[512px] resize-none py-2.5`; -const imageActionButtonClassName = - 'absolute flex size-[25px] items-center justify-center rounded-full bg-[#9f9f9f] text-[18px] leading-none text-white shadow-[0_1px_2px_rgba(0,0,0,0.16)]'; const clubNameFieldId = 'managed-club-name'; const categoryFieldId = 'managed-club-category'; const descriptionFieldId = 'managed-club-description'; const locationFieldId = 'managed-club-location'; const introduceFieldId = 'managed-club-introduce'; -function clearLocalPreviewUrl(localPreviewUrlRef: MutableRefObject) { - if (!localPreviewUrlRef.current) return; - - URL.revokeObjectURL(localPreviewUrlRef.current); - localPreviewUrlRef.current = null; -} - function ManagedClubInfo() { - const { clubId } = useParams<{ clubId: string }>(); const navigate = useNavigate(); - const numericClubId = Number(clubId); const { showToast } = useToastContext(); + const { clubId } = useParams<{ clubId: string }>(); + + const numericClubId = Number(clubId); const { data: clubDetail } = useSuspenseQuery(clubQueries.detail(numericClubId)); + const { mutateAsync: updateClubInfo, isPending, error } = useUpdateManagedClubInfoMutation(numericClubId); const initialDescription = clubDetail.description ?? ''; const initialLocation = clubDetail.location ?? ''; @@ -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(null); - const [imagePreview, setImagePreview] = useState(initialImageUrl); + const { + images, + selectedImage: currentImage, + selectedImageUrl: currentImageUrl, + setImages, + isUploadingImages: isUploadingImage, + uploadError, + uploadImages, + } = useImageUploader({ initialImageUrls: initialImageUrl ? [initialImageUrl] : [], target: 'CLUB' }); const [isPreparingImage, setIsPreparingImage] = useState(false); - const [isUploading, setIsUploading] = useState(false); - - const fileInputRef = useRef(null); - const imageDeletedRef = useRef(false); - const localPreviewUrlRef = useRef(null); - const { mutateAsync: uploadImage, error: uploadError } = useUploadImage('CLUB'); - const { mutateAsync: updateClubInfo, isPending, error } = useUpdateManagedClubInfoMutation(numericClubId); const { value: isSubmitModalOpen, setTrue: openSubmitModal, setFalse: closeSubmitModal } = useBooleanState(false); - useEffect(() => { - clearLocalPreviewUrl(localPreviewUrlRef); - setDescription(initialDescription); - setLocation(initialLocation); - setIntroduce(initialIntroduce); - setImageFile(null); - setImagePreview(initialImageUrl); - }, [initialDescription, initialImageUrl, initialIntroduce, initialLocation]); - - useEffect(() => { - return () => { - clearLocalPreviewUrl(localPreviewUrlRef); - }; - }, []); - const hasChanges = description !== initialDescription || location !== initialLocation || introduce !== initialIntroduce || - imagePreview !== initialImageUrl; + currentImageUrl !== initialImageUrl; const handleDescriptionChange = (e: ChangeEvent) => { const value = e.target.value; @@ -93,74 +69,19 @@ function ManagedClubInfo() { } }; - const handleImageSelect = async (e: ChangeEvent) => { - const file = e.target.files?.[0]; - - if (!file || isPreparingImage) return; - - imageDeletedRef.current = false; - setIsPreparingImage(true); - - try { - const preparedFile = await prepareImageFile(file); - - if (imageDeletedRef.current) { - e.target.value = ''; - return; - } - - clearLocalPreviewUrl(localPreviewUrlRef); - - const previewUrl = URL.createObjectURL(preparedFile); - localPreviewUrlRef.current = previewUrl; - - setImageFile(preparedFile); - setImagePreview(previewUrl); - e.target.value = ''; - } catch { - e.target.value = ''; - showToast('이미지 처리에 실패했습니다'); - } finally { - setIsPreparingImage(false); - } - }; - - const handleImageClick = () => { - if (isPreparingImage) return; - fileInputRef.current?.click(); - }; - - const handleDeleteImage = () => { - imageDeletedRef.current = true; - clearLocalPreviewUrl(localPreviewUrlRef); - setImageFile(null); - setImagePreview(''); - }; - const handleSubmit = async () => { closeSubmitModal(); - setIsUploading(true); - - try { - let finalImageUrl = imagePreview; + const [finalImageUrl = ''] = await uploadImages(currentImage ? [currentImage] : []); - if (imageFile) { - const result = await uploadImage(imageFile); - finalImageUrl = result.fileUrl; - } + await updateClubInfo({ + description, + imageUrl: finalImageUrl, + location, + introduce, + }); - await updateClubInfo({ - description, - imageUrl: finalImageUrl, - location, - introduce, - }); - - showToast('클럽 정보가 수정되었습니다'); - navigate(-1); - } finally { - setIsUploading(false); - } + showToast('클럽 정보가 수정되었습니다'); + navigate(-1); }; const readOnlyFields = [ @@ -172,43 +93,15 @@ function ManagedClubInfo() {
- -
- {!imagePreview ? ( - - ) : ( - <> -
- 동아리 이미지 미리보기 -
- - - - )} -
+ '동아리 이미지 미리보기'} + />
@@ -290,12 +183,12 @@ function ManagedClubInfo() { - ) : ( -
-
- {`업로드 - - - - {images.length > 1 && ( - <> - - - - )} -
- -
-
- {images.map((_, index) => ( -
- - -
-
- )}
@@ -569,11 +363,13 @@ function ManagedRecruitmentWrite() {