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
25 changes: 18 additions & 7 deletions src/pages/Club/Application/clubFeePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Portal from '@/components/common/Portal';
import { useClubApplicationStore } from '@/stores/clubApplicationStore';
import useBooleanState from '@/utils/hooks/useBooleanState';
import useUploadImage from '@/utils/hooks/useUploadImage';
import { prepareImageFile } from '@/utils/ts/imagePreprocessor';
import AccountInfoCard from './components/AccountInfo';
import useApplyToClub from './hooks/useApplyToClub';
import { useGetClubFee } from './hooks/useGetClubFee';
Expand All @@ -28,8 +29,9 @@ function ClubFeePage() {
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 || isUploadingImage;
const isSubmitting = isApplyingToClub || isPreparingImage || isUploadingImage;

Comment on lines +32 to 35
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 | 🟠 Major

전처리 중에도 파일 선택 버튼이 다시 열립니다.

초기 업로드에서는 previewUrl이 아직 없어서 큰 추가 버튼이 계속 렌더링되는데, 이 버튼은 여전히 fileInputRef.current?.click()를 바로 호출합니다. 그래서 전처리 중 두 번째 파일 선택창을 열 수 있고, 새 선택은 handleImageSelect의 early return으로 조용히 버려집니다. 이 CTA도 isPreparingImage 동안 비활성화해 주세요.

Also applies to: 43-58

useEffect(() => {
return () => {
Expand All @@ -38,17 +40,26 @@ function ClubFeePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file || isPreparingImage) return;

if (previewUrl) URL.revokeObjectURL(previewUrl);
setImageFile(file);
setPreviewUrl(URL.createObjectURL(file));
setIsPreparingImage(true);

try {
const preparedFile = await prepareImageFile(file);

if (previewUrl) URL.revokeObjectURL(previewUrl);
setImageFile(preparedFile);
setPreviewUrl(URL.createObjectURL(preparedFile));
} finally {
setIsPreparingImage(false);
}
};

const handleSubmit = async () => {
if (!imageFile) return;

const { fileUrl } = await uploadImage(imageFile);
await applyToClub({ answers, feePaymentImageUrl: fileUrl });
};
Expand Down Expand Up @@ -123,7 +134,7 @@ function ClubFeePage() {
onClick={handleSubmit}
disabled={!imageFile || isSubmitting}
>
{isSubmitting ? '제출 중...' : '제출하기'}
{isPreparingImage ? '이미지 준비 중...' : isSubmitting ? '제출 중...' : '제출하기'}
</button>

{isImageOpen && previewUrl && (
Expand Down
55 changes: 43 additions & 12 deletions src/pages/Manager/ManagedClubProfile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useGetClubDetail } from '@/pages/Club/ClubDetail/hooks/useGetClubDetail
import { useUpdateClubInfo } from '@/pages/Manager/hooks/useManagedClubs';
import useBooleanState from '@/utils/hooks/useBooleanState';
import useUploadImage from '@/utils/hooks/useUploadImage';
import { prepareImageFile } from '@/utils/ts/imagePreprocessor';

const DESCRIPTION_MAX_LENGTH = 25;

Expand Down Expand Up @@ -48,9 +49,11 @@ function ManagedClubInfo() {
const [introduce, setIntroduce] = useState(initialIntroduce);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState(initialImageUrl);
const [isPreparingImage, setIsPreparingImage] = useState(false);
const [isUploading, setIsUploading] = useState(false);

const fileInputRef = useRef<HTMLInputElement>(null);
const imageDeletedRef = useRef(false);
const localPreviewUrlRef = useRef<string | null>(null);

const { mutateAsync: uploadImage, error: uploadError } = useUploadImage('CLUB');
Expand Down Expand Up @@ -86,26 +89,42 @@ function ManagedClubInfo() {
}
};

const handleImageSelect = (e: ChangeEvent<HTMLInputElement>) => {
const handleImageSelect = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];

if (!file) return;
if (!file || isPreparingImage) return;

clearLocalPreviewUrl(localPreviewUrlRef);
imageDeletedRef.current = false;
setIsPreparingImage(true);

const previewUrl = URL.createObjectURL(file);
localPreviewUrlRef.current = previewUrl;
try {
const preparedFile = await prepareImageFile(file);

setImageFile(file);
setImagePreview(previewUrl);
e.target.value = '';
if (imageDeletedRef.current) {
e.target.value = '';
return;
}

clearLocalPreviewUrl(localPreviewUrlRef);

const previewUrl = URL.createObjectURL(preparedFile);
localPreviewUrlRef.current = previewUrl;

setImageFile(preparedFile);
setImagePreview(previewUrl);
e.target.value = '';
} finally {
setIsPreparingImage(false);
}
};

const handleImageClick = () => {
if (isPreparingImage) return;
fileInputRef.current?.click();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

const handleDeleteImage = () => {
imageDeletedRef.current = true;
clearLocalPreviewUrl(localPreviewUrlRef);
setImageFile(null);
setImagePreview('');
Expand Down Expand Up @@ -262,10 +281,16 @@ function ManagedClubInfo() {
<button
type="button"
onClick={openSubmitModal}
disabled={isPending || isUploading || !hasChanges}
disabled={isPending || isPreparingImage || isUploading || !hasChanges}
className="text-h2 bg-primary-500 disabled:bg-text-300 w-full rounded-2xl py-[9.5px] text-center text-white transition-colors disabled:cursor-not-allowed"
>
{isUploading ? '이미지 업로드 중...' : isPending ? '수정 중...' : '수정하기'}
{isPreparingImage
? '이미지 준비 중...'
: isUploading
? '이미지 업로드 중...'
: isPending
? '수정 중...'
: '수정하기'}
</button>
</div>
</div>
Expand All @@ -276,11 +301,17 @@ function ManagedClubInfo() {
<div>
<button
type="button"
disabled={isPending || isUploading}
disabled={isPending || isPreparingImage || isUploading}
onClick={handleSubmit}
className="bg-primary-500 text-h3 w-full rounded-lg py-3.5 text-center text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{isUploading ? '수정 중...' : isPending ? '수정 중...' : '수정하기'}
{isPreparingImage
? '이미지 준비 중...'
: isUploading
? '수정 중...'
: isPending
? '수정 중...'
: '수정하기'}
</button>
<button
type="button"
Expand Down
105 changes: 82 additions & 23 deletions src/pages/Manager/ManagedRecruitmentWrite/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { startTransition, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import AddPhotoAlternateIcon from '@/assets/svg/add-photo-alternate.svg';
Expand All @@ -15,6 +15,8 @@ import { useGetClubSettings, usePatchClubSettings } from '@/pages/Manager/hooks/
import useBooleanState from '@/utils/hooks/useBooleanState';
import useUploadImage from '@/utils/hooks/useUploadImage';
import { formatDateDot } from '@/utils/ts/date';
import { prepareImageFile } from '@/utils/ts/imagePreprocessor';
import { mapWithConcurrencyLimit } from '@/utils/ts/promise';
import {
combineDateTime,
DEFAULT_END_TIME,
Expand All @@ -37,6 +39,7 @@ const dateFieldContainerStyle = 'rounded-[20px] border-[0.7px] border-[#c6cfd8]
const compactButtonStyle =
'group flex h-[34px] min-w-0 w-full items-center justify-between rounded-[4px] border-[0.7px] border-[#c6cfd8] bg-white px-1.5 text-left shadow-[0_0_3px_rgba(0,0,0,0.15)]';
const compactButtonTextStyle = 'text-[11px] leading-[1.6] font-medium text-[#344352]';
const IMAGE_PREPARATION_CONCURRENCY = 2;

function ManagedRecruitmentWrite() {
const { clubId } = useParams<{ clubId: string }>();
Expand All @@ -52,12 +55,12 @@ function ManagedRecruitmentWrite() {
const [isAlwaysRecruiting, setIsAlwaysRecruiting] = useState(false);
const [images, setImages] = useState<ImageItem[]>([]);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [isPreparingImages, setIsPreparingImages] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [hasHandledExisting, setHasHandledExisting] = useState(false);
const [hasInitializedRecruitmentEnabled, setHasInitializedRecruitmentEnabled] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

const { mutateAsync: uploadImage, error: uploadError } = useUploadImage('CLUB');
const { data: existingRecruitment } = useGetManagedRecruitments(clubIdNumber);
const { data: clubSettings } = useGetClubSettings(clubIdNumber);
Expand Down Expand Up @@ -156,25 +159,63 @@ function ManagedRecruitmentWrite() {

const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;

const newItems = Array.from(files).map((file) => ({
file,
previewUrl: URL.createObjectURL(file),
}));
setImages((prev) => {
const newImages = [...prev, ...newItems];
setCurrentImageIndex(newImages.length - 1);
return newImages;
});
e.target.value = '';
if (!files || files.length === 0 || isPreparingImages) return;

const selectedFiles = Array.from(files);
const previousImageCount = images.length;
let visiblePreparedCount = 0;
const preparedItems: Array<ImageItem | null> = new Array(selectedFiles.length).fill(null);
setIsPreparingImages(true);

mapWithConcurrencyLimit(
selectedFiles,
IMAGE_PREPARATION_CONCURRENCY,
(file) => prepareImageFile(file),
(preparedFile, index) => {
preparedItems[index] = {
file: preparedFile,
previewUrl: URL.createObjectURL(preparedFile),
};

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 ImageItem[];

startTransition(() => {
setImages((prev) => [...prev.slice(0, previousImageCount), ...orderedPreparedItems]);

if (shouldFocusFirstPreparedImage) {
setCurrentImageIndex(previousImageCount);
}
});
}
)
.catch(() => {})
.finally(() => {
setIsPreparingImages(false);
e.target.value = '';
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

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

setCurrentImageIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};

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

setCurrentImageIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
};

Expand All @@ -196,6 +237,7 @@ function ManagedRecruitmentWrite() {
};

const handleImageClick = () => {
if (isPreparingImages) return;
fileInputRef.current?.click();
};

Expand All @@ -208,11 +250,10 @@ function ManagedRecruitmentWrite() {

setIsUploading(true);
try {
// 새 이미지만 업로드 (file이 있는 것만)
const newImages = images.filter((img) => img.file);
const existingImages = images.filter((img) => img.isExisting);

const uploadResults = await Promise.all(newImages.map((img) => uploadImage(img.file!)));
const uploadResults = await Promise.all(newImages.map((image) => uploadImage(image.file!)));
const uploadedImageData = uploadResults.map((res) => ({ url: res.fileUrl }));
const existingImageData = existingImages.map((img) => ({ url: img.previewUrl }));
const imageData = [...existingImageData, ...uploadedImageData];
Expand All @@ -228,7 +269,12 @@ function ManagedRecruitmentWrite() {
return;
}

patchSettings({ isRecruitmentEnabled: nextRecruitmentEnabled }, { onSuccess: navigateAfterSave });
patchSettings(
{ isRecruitmentEnabled: nextRecruitmentEnabled },
{
onSuccess: navigateAfterSave,
}
);
};

if (isAlwaysRecruiting) {
Expand Down Expand Up @@ -419,15 +465,19 @@ function ManagedRecruitmentWrite() {
<button
type="button"
onClick={handleImageClick}
disabled={isPreparingImages}
className="border-text-200 mt-3 flex h-[226px] w-full flex-col items-center justify-center gap-2 rounded-[20px] border-[0.7px] bg-white text-[#5a6b7f]"
>
<AddPhotoAlternateIcon aria-hidden="true" className="h-[60px] w-[60px]" />
<p className="text-center text-[16px] leading-[1.6] font-semibold">이미지를 추가해주세요</p>
<p className="text-center text-[16px] leading-[1.6] font-semibold">
{isPreparingImages ? '이미지를 준비하고 있어요' : '이미지를 추가해주세요'}
</p>
</button>
) : (
<div className="mt-3 flex flex-col gap-3">
<div className="border-text-200 relative h-[226px] overflow-hidden rounded-[20px] border-[0.7px] bg-white">
<img
key={images[currentImageIndex].previewUrl}
src={images[currentImageIndex].previewUrl}
alt={`업로드 이미지 ${currentImageIndex + 1}`}
className="h-full w-full object-cover"
Expand All @@ -436,8 +486,9 @@ function ManagedRecruitmentWrite() {
<button
type="button"
onClick={handleDeleteImage}
disabled={isPreparingImages}
aria-label="현재 이미지 삭제"
className="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/45 text-sm font-semibold text-white"
className="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-full bg-black/45 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
>
×
</button>
Expand All @@ -447,16 +498,18 @@ function ManagedRecruitmentWrite() {
<button
type="button"
onClick={handlePrevImage}
disabled={isPreparingImages}
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)]"
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={handleNextImage}
disabled={isPreparingImages}
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)]"
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>
Expand Down Expand Up @@ -509,9 +562,15 @@ function ManagedRecruitmentWrite() {
<button
type="submit"
className="bg-primary-500 disabled:bg-text-200 h-12 w-full rounded-2xl text-[18px] leading-[1.6] font-semibold text-white disabled:cursor-not-allowed"
disabled={isPending || isUploading || !content.trim() || hasDateError}
disabled={isPending || isPreparingImages || isUploading || !content.trim() || hasDateError}
>
{isUploading ? '이미지 업로드 중…' : isPending ? '수정 중…' : '모집공고 수정'}
{isPreparingImages
? '이미지 준비 중…'
: isUploading
? '이미지 업로드 중…'
: isPending
? '수정 중…'
: '모집공고 수정'}
</button>
</div>
</div>
Expand Down
Loading
Loading