diff --git a/src/apis/upload/mutations.ts b/src/apis/upload/mutations.ts
deleted file mode 100644
index d2940f0d..00000000
--- a/src/apis/upload/mutations.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { mutationOptions } from '@tanstack/react-query';
-import type { UploadTarget } from './entity';
-import { uploadImage } from '.';
-
-export const uploadMutationKeys = {
- image: (target: UploadTarget) => ['upload', 'image', target] as const,
-};
-
-export const uploadMutations = {
- image: (target: UploadTarget) =>
- mutationOptions({
- mutationKey: uploadMutationKeys.image(target),
- mutationFn: (file: File) => uploadImage(file, target),
- }),
-};
diff --git a/src/components/common/ImageUploader/ImageUploader.tsx b/src/components/common/ImageUploader/ImageUploader.tsx
new file mode 100644
index 00000000..38ab7deb
--- /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 = (
+
+ );
+
+ 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 00000000..0ec84f1e
--- /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 00000000..8aafdabc
--- /dev/null
+++ b/src/components/common/ImageUploader/hooks/useImagePreparation.ts
@@ -0,0 +1,161 @@
+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 = selectionMode === 'single' ? (files[0] ? [files[0]] : []) : Array.from(files);
+ const previousImageCount = images.length;
+ const preparedItems: Array = new Array(selectedFiles.length).fill(null);
+ const createdItems: ImageUploadItem[] = [];
+ const committedImageIds = new Set();
+ 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(() => {
+ committedImageIds.add(preparedItem.id);
+ 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(() => {
+ orderedPreparedItems.forEach((item) => committedImageIds.add(item.id));
+ onChange([...images.slice(0, previousImageCount), ...orderedPreparedItems]);
+
+ if (shouldFocusFirstPreparedImage) {
+ setCurrentImageIndex(previousImageCount);
+ }
+ });
+ }
+ )
+ .catch(() => {
+ showToast('이미지 처리에 실패했습니다. 다시 시도해주세요.', 'error');
+ })
+ .finally(() => {
+ if (!isMountedRef.current) {
+ createdItems.forEach(revokeImagePreviewUrl);
+ return;
+ }
+
+ createdItems.filter((item) => !committedImageIds.has(item.id)).forEach(revokeImagePreviewUrl);
+
+ 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 00000000..f8af5f96
--- /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 00000000..e794f545
--- /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 00000000..d8abea8c
--- /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 00000000..f891f58f
--- /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 00000000..0079ef84
--- /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)));
+}
diff --git a/src/pages/Club/Application/clubFeePage.tsx b/src/pages/Club/Application/clubFeePage.tsx
index a500476e..9d97160b 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 310c9188..411fb79a 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() {