From c224f4696702e938647f37b6157f541242dec0ab Mon Sep 17 00:00:00 2001 From: hansoojeongsj Date: Wed, 29 Oct 2025 21:47:58 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A4=91=EC=95=99=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20solve=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/my/my.css.ts | 11 ++++++++-- src/pages/solve/Solve.tsx | 45 +++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/pages/my/my.css.ts b/src/pages/my/my.css.ts index dbe7b1d..61dc840 100644 --- a/src/pages/my/my.css.ts +++ b/src/pages/my/my.css.ts @@ -7,12 +7,17 @@ export const container = style({ height: '100%', background: 'linear-gradient(355deg, #FFF 48.23%, #BFD9FE 97.05%), #FFF', backgroundRepeat: 'no-repeat', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', }); export const title = style({ display: 'flex', flexDirection: 'row', paddingLeft: '3.6rem', + width: '100%', + maxWidth: '66.8rem', }); export const name = style({ @@ -37,7 +42,8 @@ export const hello = style({ export const noteDiv = style({ padding: '1.2rem 2.6rem 2.6rem', borderRadius: '12px', - margin: 'auto 0', + width: '100%', + maxWidth: '66.8rem', }); export const noteTitle = style({ @@ -183,7 +189,8 @@ export const buttonContainer = style({ flexDirection: 'column', gap: '1.6rem', padding: '3.6rem 3.6rem 10rem', - maxWidth: '55rem', + maxWidth: '66.8rem', + width: '100%', }); export const button = style({ diff --git a/src/pages/solve/Solve.tsx b/src/pages/solve/Solve.tsx index 84379bc..6fa3c76 100644 --- a/src/pages/solve/Solve.tsx +++ b/src/pages/solve/Solve.tsx @@ -232,8 +232,7 @@ const Solve = () => { const files = e.target.files; - // 2. 파일 개수 검증 로직 수정 - // 필요한 개수와 다르다면 에러 메시지 표시 + // 2. 파일 개수 검증 로직 if (!files || files.length !== expectedCount) { addServerMessage( expectedCount === 1 @@ -247,7 +246,11 @@ const Solve = () => { // 3. 'me' 로딩 UI 시작 및 초기화 setUploadingSlots(Array.from({ length: expectedCount }, (_, i) => i)); setIsUploading(true); - const uploadedUrls: string[] = []; + + // 정렬 로직을 제거하고, files 배열의 순서를 그대로 사용 + const filesArray = Array.from(files); + + let finalUploadedUrls: string[] = []; try { const { @@ -256,34 +259,44 @@ const Solve = () => { s3Key: presignedKey, } = await getPresignedUrl(expectedCount); - const filesArray = Array.from(files); + // Promise 배열을 filesArray의 순서대로 정의 + const uploadPromises = []; - // S3 업로드 루프: filesArray의 순서를 그대로 사용 - // filesArray의 순서가 곧 사용자가 선택한 순서 + // S3 업로드 루프: filesArray의 원래 순서(사용자 선택 순서로 추정)대로 진행 for (let i = 0; i < expectedCount; i++) { - const response = await uploadToPresignedUrl( - uploadUrls[i], - filesArray[i]!, + const fileToUpload = filesArray[i]!; // 원래 순서의 파일 + const uploadUrl = uploadUrls[i]; + const downloadUrl = presignedUrls[i]; // 해당 순서의 다운로드 URL + + // Promise는 HTTP 요청만 담고, 결과를 해당 순서의 downloadUrl로 resolve + // Promise.all은 입력된 배열 순서대로 결과를 반환하므로, filesArray의 순서가 유지 + uploadPromises.push( + uploadToPresignedUrl(uploadUrl, fileToUpload).then((response) => { + if (!response.ok) { + throw new Error('S3 업로드 실패'); + } + return downloadUrl; // filesArray[i]에 해당하는 URL 반환 + }), ); - if (!response.ok) { - throw new Error('S3 업로드 실패'); - } - uploadedUrls.push(presignedUrls[i]); } + // Promise.all을 await하여 filesArray의 순서대로 결과 배열을 얻음 + finalUploadedUrls = await Promise.all(uploadPromises); + // 4. S3 업로드 완료 후, 로딩을 끄지 않고 프리로딩 시작 - await preloadImages(uploadedUrls); + await preloadImages(finalUploadedUrls); // 5. 프리로딩 완료 후, 로딩 제거하고 동시에 이미지 추가 setUploadingSlots([]); setIsUploading(false); - uploadedUrls.forEach((url) => { + // finalUploadedUrls (filesArray 순서)대로 채팅에 추가 + finalUploadedUrls.forEach((url) => { handleImageSelect(url); }); setS3Key(presignedKey); - setDownloadUrls(presignedUrls); + setDownloadUrls(finalUploadedUrls); setImageUploaded(true); } catch { // 6. 실패 시 From 08924cea0a67c6b2578ef2d2ff5b19b98c4da397 Mon Sep 17 00:00:00 2001 From: hansoojeongsj Date: Wed, 29 Oct 2025 22:11:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20solve=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=88=9C=EC=B0=A8=20=EC=B2=98=EB=A6=AC=20=EA=B0=95=EC=A0=9C?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/solve/Solve.tsx | 45 +++++++++++++++------------------------ 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/src/pages/solve/Solve.tsx b/src/pages/solve/Solve.tsx index 6fa3c76..6e95c90 100644 --- a/src/pages/solve/Solve.tsx +++ b/src/pages/solve/Solve.tsx @@ -243,14 +243,11 @@ const Solve = () => { return; } - // 3. 'me' 로딩 UI 시작 및 초기화 + // 3. 'me' 로딩 UI 시작 및 초기화 (expectedCount == 1 또는 2) setUploadingSlots(Array.from({ length: expectedCount }, (_, i) => i)); setIsUploading(true); - - // 정렬 로직을 제거하고, files 배열의 순서를 그대로 사용 const filesArray = Array.from(files); - - let finalUploadedUrls: string[] = []; + const finalUploadedUrls: string[] = []; try { const { @@ -259,38 +256,30 @@ const Solve = () => { s3Key: presignedKey, } = await getPresignedUrl(expectedCount); - // Promise 배열을 filesArray의 순서대로 정의 - const uploadPromises = []; - - // S3 업로드 루프: filesArray의 원래 순서(사용자 선택 순서로 추정)대로 진행 - for (let i = 0; i < expectedCount; i++) { - const fileToUpload = filesArray[i]!; // 원래 순서의 파일 - const uploadUrl = uploadUrls[i]; - const downloadUrl = presignedUrls[i]; // 해당 순서의 다운로드 URL - - // Promise는 HTTP 요청만 담고, 결과를 해당 순서의 downloadUrl로 resolve - // Promise.all은 입력된 배열 순서대로 결과를 반환하므로, filesArray의 순서가 유지 - uploadPromises.push( - uploadToPresignedUrl(uploadUrl, fileToUpload).then((response) => { - if (!response.ok) { - throw new Error('S3 업로드 실패'); - } - return downloadUrl; // filesArray[i]에 해당하는 URL 반환 - }), - ); + // [최종 순서 보장 로직] expectedCount가 2일 경우, 파일을 순차적으로 처리 + if (expectedCount === 2) { + // 1) 첫 번째 파일(filesArray[0]) 처리 (문제 이미지로 추정) + await uploadToPresignedUrl(uploadUrls[0], filesArray[0]!); + finalUploadedUrls.push(presignedUrls[0]); + + // 2) 두 번째 파일(filesArray[1]) 처리 (풀이 이미지로 추정) + await uploadToPresignedUrl(uploadUrls[1], filesArray[1]!); + finalUploadedUrls.push(presignedUrls[1]); + } else { + // expectedCount === 1 + await uploadToPresignedUrl(uploadUrls[0], filesArray[0]!); + finalUploadedUrls.push(presignedUrls[0]); } - // Promise.all을 await하여 filesArray의 순서대로 결과 배열을 얻음 - finalUploadedUrls = await Promise.all(uploadPromises); - // 4. S3 업로드 완료 후, 로딩을 끄지 않고 프리로딩 시작 + // finalUploadedUrls는 if/else 블록에서 순차적으로 채워짐 await preloadImages(finalUploadedUrls); // 5. 프리로딩 완료 후, 로딩 제거하고 동시에 이미지 추가 setUploadingSlots([]); setIsUploading(false); - // finalUploadedUrls (filesArray 순서)대로 채팅에 추가 + // finalUploadedUrls (강제 순차 처리 순서)대로 채팅에 추가 finalUploadedUrls.forEach((url) => { handleImageSelect(url); }); From 7ce34e07f34a3501d1beb84c166ee928425f890f Mon Sep 17 00:00:00 2001 From: hansoojeongsj Date: Wed, 29 Oct 2025 22:16:25 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EB=8B=A4=EC=8B=9C=20solve=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/solve/Solve.tsx | 49 ++++++++++++++------------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/src/pages/solve/Solve.tsx b/src/pages/solve/Solve.tsx index 6e95c90..76b0262 100644 --- a/src/pages/solve/Solve.tsx +++ b/src/pages/solve/Solve.tsx @@ -225,15 +225,14 @@ const Solve = () => { } }; + // 파일 선택 핸들러 // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { - // 1. 파일 선택 즉시 모달 닫기 setIsOpen(false); + const fileList = e.target.files; - const files = e.target.files; - - // 2. 파일 개수 검증 로직 - if (!files || files.length !== expectedCount) { + // 파일 개수 검증 + if (!fileList || fileList.length !== expectedCount) { addServerMessage( expectedCount === 1 ? '정확한 풀이를 위해 문제 이미지 1장을 선택해주세요.' @@ -243,11 +242,11 @@ const Solve = () => { return; } - // 3. 'me' 로딩 UI 시작 및 초기화 (expectedCount == 1 또는 2) + // ✅ FileList를 Array로 변환하되, 브라우저가 제공한 순서 그대로 보존 + const filesArray = Array.from(fileList); // 이 순서가 "사용자가 클릭한 순서"입니다. + setUploadingSlots(Array.from({ length: expectedCount }, (_, i) => i)); setIsUploading(true); - const filesArray = Array.from(files); - const finalUploadedUrls: string[] = []; try { const { @@ -256,47 +255,33 @@ const Solve = () => { s3Key: presignedKey, } = await getPresignedUrl(expectedCount); - // [최종 순서 보장 로직] expectedCount가 2일 경우, 파일을 순차적으로 처리 - if (expectedCount === 2) { - // 1) 첫 번째 파일(filesArray[0]) 처리 (문제 이미지로 추정) - await uploadToPresignedUrl(uploadUrls[0], filesArray[0]!); - finalUploadedUrls.push(presignedUrls[0]); - - // 2) 두 번째 파일(filesArray[1]) 처리 (풀이 이미지로 추정) - await uploadToPresignedUrl(uploadUrls[1], filesArray[1]!); - finalUploadedUrls.push(presignedUrls[1]); - } else { - // expectedCount === 1 - await uploadToPresignedUrl(uploadUrls[0], filesArray[0]!); - finalUploadedUrls.push(presignedUrls[0]); + const finalUploadedUrls: string[] = []; + + // ✅ 순서대로 업로드 (문제 → 풀이 이미지) + for (let i = 0; i < expectedCount; i++) { + await uploadToPresignedUrl(uploadUrls[i], filesArray[i]!); + finalUploadedUrls.push(presignedUrls[i]); } - // 4. S3 업로드 완료 후, 로딩을 끄지 않고 프리로딩 시작 - // finalUploadedUrls는 if/else 블록에서 순차적으로 채워짐 + // 이미지 프리로딩 await preloadImages(finalUploadedUrls); - // 5. 프리로딩 완료 후, 로딩 제거하고 동시에 이미지 추가 setUploadingSlots([]); setIsUploading(false); - // finalUploadedUrls (강제 순차 처리 순서)대로 채팅에 추가 - finalUploadedUrls.forEach((url) => { - handleImageSelect(url); - }); + // 업로드된 순서대로 채팅에 이미지 표시 + finalUploadedUrls.forEach((url) => handleImageSelect(url)); setS3Key(presignedKey); setDownloadUrls(finalUploadedUrls); setImageUploaded(true); } catch { - // 6. 실패 시 addServerMessage( - '이미지 업로드 중 오류가 발생했습니다. 다시 시도해 주세요.', + '이미지 업로드 중 오류가 발생했습니다. 다시 시도해주세요.', ); - // 실패해도 로딩 상태는 초기화 setUploadingSlots([]); setIsUploading(false); } finally { - // 7. 모든 작업이 끝나면 input 초기화 e.target.value = ''; } }; From b16e383820a8b16dbedfe9c07e8cc8a3bd7060b9 Mon Sep 17 00:00:00 2001 From: hansoojeongsj Date: Wed, 29 Oct 2025 22:56:14 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=20=EC=B2=98?= =?UTF-8?q?=EC=9D=8C=EC=9C=BC=EB=A1=9C=20=EB=B3=B5=EA=B7=80=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/solve/Solve.tsx | 47 +++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/src/pages/solve/Solve.tsx b/src/pages/solve/Solve.tsx index 76b0262..84379bc 100644 --- a/src/pages/solve/Solve.tsx +++ b/src/pages/solve/Solve.tsx @@ -225,14 +225,16 @@ const Solve = () => { } }; - // 파일 선택 핸들러 // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { + // 1. 파일 선택 즉시 모달 닫기 setIsOpen(false); - const fileList = e.target.files; - // 파일 개수 검증 - if (!fileList || fileList.length !== expectedCount) { + const files = e.target.files; + + // 2. 파일 개수 검증 로직 수정 + // 필요한 개수와 다르다면 에러 메시지 표시 + if (!files || files.length !== expectedCount) { addServerMessage( expectedCount === 1 ? '정확한 풀이를 위해 문제 이미지 1장을 선택해주세요.' @@ -242,11 +244,10 @@ const Solve = () => { return; } - // ✅ FileList를 Array로 변환하되, 브라우저가 제공한 순서 그대로 보존 - const filesArray = Array.from(fileList); // 이 순서가 "사용자가 클릭한 순서"입니다. - + // 3. 'me' 로딩 UI 시작 및 초기화 setUploadingSlots(Array.from({ length: expectedCount }, (_, i) => i)); setIsUploading(true); + const uploadedUrls: string[] = []; try { const { @@ -255,33 +256,45 @@ const Solve = () => { s3Key: presignedKey, } = await getPresignedUrl(expectedCount); - const finalUploadedUrls: string[] = []; + const filesArray = Array.from(files); - // ✅ 순서대로 업로드 (문제 → 풀이 이미지) + // S3 업로드 루프: filesArray의 순서를 그대로 사용 + // filesArray의 순서가 곧 사용자가 선택한 순서 for (let i = 0; i < expectedCount; i++) { - await uploadToPresignedUrl(uploadUrls[i], filesArray[i]!); - finalUploadedUrls.push(presignedUrls[i]); + const response = await uploadToPresignedUrl( + uploadUrls[i], + filesArray[i]!, + ); + if (!response.ok) { + throw new Error('S3 업로드 실패'); + } + uploadedUrls.push(presignedUrls[i]); } - // 이미지 프리로딩 - await preloadImages(finalUploadedUrls); + // 4. S3 업로드 완료 후, 로딩을 끄지 않고 프리로딩 시작 + await preloadImages(uploadedUrls); + // 5. 프리로딩 완료 후, 로딩 제거하고 동시에 이미지 추가 setUploadingSlots([]); setIsUploading(false); - // 업로드된 순서대로 채팅에 이미지 표시 - finalUploadedUrls.forEach((url) => handleImageSelect(url)); + uploadedUrls.forEach((url) => { + handleImageSelect(url); + }); setS3Key(presignedKey); - setDownloadUrls(finalUploadedUrls); + setDownloadUrls(presignedUrls); setImageUploaded(true); } catch { + // 6. 실패 시 addServerMessage( - '이미지 업로드 중 오류가 발생했습니다. 다시 시도해주세요.', + '이미지 업로드 중 오류가 발생했습니다. 다시 시도해 주세요.', ); + // 실패해도 로딩 상태는 초기화 setUploadingSlots([]); setIsUploading(false); } finally { + // 7. 모든 작업이 끝나면 input 초기화 e.target.value = ''; } }; From 8e242302d3a2dab3f5c303417da432a3c90c10a6 Mon Sep 17 00:00:00 2001 From: hansoojeongsj Date: Wed, 29 Oct 2025 23:42:27 +0900 Subject: [PATCH 5/7] feat: solve... (#64) --- src/pages/solve/Solve.tsx | 165 +++++++++++++++++++++++++---------- src/pages/solve/solve.css.ts | 1 - 2 files changed, 118 insertions(+), 48 deletions(-) diff --git a/src/pages/solve/Solve.tsx b/src/pages/solve/Solve.tsx index 84379bc..16d45e0 100644 --- a/src/pages/solve/Solve.tsx +++ b/src/pages/solve/Solve.tsx @@ -36,6 +36,14 @@ const Solve = () => { const [chatList, setChatList] = useState([]); const [isOpen, setIsOpen] = useState(false); const [imageUploaded, setImageUploaded] = useState(false); + + // 순차 업로드를 위한 상태 복구 + const [presignedUploadUrls, setPresignedUploadUrls] = useState([]); + const [presignedDownloadUrls, setPresignedDownloadUrls] = useState( + [], + ); + const [currentUploadStep, setCurrentUploadStep] = useState<0 | 1 | 2>(0); // 0:초기, 1:문제완료/풀이대기, 2:최종완료 + const [downloadUrls, setDownloadUrls] = useState([]); const [s3Key, setS3Key] = useState(''); const [isPending, setIsPending] = useState(false); @@ -227,85 +235,130 @@ const Solve = () => { // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { - // 1. 파일 선택 즉시 모달 닫기 + // 1. 파일 선택 직후, 모달 상태를 닫음 (브라우저 동작 실패 대비 강제) setIsOpen(false); const files = e.target.files; - // 2. 파일 개수 검증 로직 수정 - // 필요한 개수와 다르다면 에러 메시지 표시 - if (!files || files.length !== expectedCount) { - addServerMessage( - expectedCount === 1 - ? '정확한 풀이를 위해 문제 이미지 1장을 선택해주세요.' - : '정확한 풀이를 위해 문제 이미지 1장, 풀이 이미지 1장을 선택해주세요.', - ); + // 2. 파일 개수 검증 (순차 모드이므로 항상 1개만 받아야 함) + if (!files || files.length !== 1) { + const stepName = + expectedCount === 1 || currentUploadStep === 0 + ? '문제 이미지' + : '풀이 이미지'; + addServerMessage(`${stepName} 1장만 선택해주세요.`); e.target.value = ''; return; } - // 3. 'me' 로딩 UI 시작 및 초기화 - setUploadingSlots(Array.from({ length: expectedCount }, (_, i) => i)); + const file = files[0]; + let downloadUrlToUse: string; + let uploadUrlToUse: string; + let receivedS3Key: string | undefined; + let finalDownloadUrls: string[] = []; + + // 3. 'me' 로딩 UI 시작 + setUploadingSlots([0]); setIsUploading(true); - const uploadedUrls: string[] = []; try { - const { - uploadUrls, - downloadUrls: presignedUrls, - s3Key: presignedKey, - } = await getPresignedUrl(expectedCount); - - const filesArray = Array.from(files); - - // S3 업로드 루프: filesArray의 순서를 그대로 사용 - // filesArray의 순서가 곧 사용자가 선택한 순서 - for (let i = 0; i < expectedCount; i++) { - const response = await uploadToPresignedUrl( - uploadUrls[i], - filesArray[i]!, - ); - if (!response.ok) { - throw new Error('S3 업로드 실패'); + if (expectedCount === 2) { + // 2장 모드 (미리 받아둔 URL 사용) + const stepIndex = currentUploadStep === 0 ? 0 : 1; + + if ( + presignedUploadUrls.length < 2 || + presignedDownloadUrls.length < 2 + ) { + throw new Error( + 'Presigned URLs not initialized correctly for 2 images.', + ); } - uploadedUrls.push(presignedUrls[i]); + + uploadUrlToUse = presignedUploadUrls[stepIndex]; + downloadUrlToUse = presignedDownloadUrls[stepIndex]; + } else { + // 1장 모드: Presigned URL을 지금 요청 (기존 로직 유지) + const res = await getPresignedUrl(1); + uploadUrlToUse = res.uploadUrls[0]; + downloadUrlToUse = res.downloadUrls[0]; + receivedS3Key = res.s3Key; } - // 4. S3 업로드 완료 후, 로딩을 끄지 않고 프리로딩 시작 - await preloadImages(uploadedUrls); + // S3 업로드 + await uploadToPresignedUrl(uploadUrlToUse, file); + + // 4. S3 업로드 완료 후, 프리로딩 시작 + await preloadImages([downloadUrlToUse]); - // 5. 프리로딩 완료 후, 로딩 제거하고 동시에 이미지 추가 + // 5. 프리로딩 완료 후, 로딩 제거하고 이미지 추가 setUploadingSlots([]); setIsUploading(false); - - uploadedUrls.forEach((url) => { - handleImageSelect(url); - }); - - setS3Key(presignedKey); - setDownloadUrls(presignedUrls); - setImageUploaded(true); + handleImageSelect(downloadUrlToUse); + + // 후속 처리 로직 (핵심 분기) + if (expectedCount === 2) { + // --- 2장 모드 처리 --- + if (currentUploadStep === 0) { + // 1단계(문제) 완료: 2단계 업로드를 위해 다음 파일 선택 창 자동 호출 + finalDownloadUrls = [downloadUrlToUse]; + setDownloadUrls(finalDownloadUrls); // 문제 이미지 URL만 임시 저장 + setCurrentUploadStep(1); // 2단계(풀이) 대기 상태 + + // 쉼없이 다음 단계 파일 선택 창 자동 호출 + setTimeout(() => { + // setTimeout이 User Activation 오류의 원인이지만, UX를 위해 일단 유지 + if (fileInputRef.current) { + fileInputRef.current.multiple = false; + fileInputRef.current.click(); + } + }, 300); + } else if (currentUploadStep === 1) { + // 2단계(풀이) 완료: 최종 URL 결합 및 완료 + finalDownloadUrls = [...downloadUrls, downloadUrlToUse]; // 문제 + 풀이 URL 결합 + setDownloadUrls(finalDownloadUrls); // 최종 URL 저장 + setImageUploaded(true); + setCurrentUploadStep(2); + } + } else { + // --- 1장 모드 처리 (expectedCount === 1) --- + finalDownloadUrls = [downloadUrlToUse]; + setDownloadUrls(finalDownloadUrls); + setS3Key(receivedS3Key!); + setImageUploaded(true); + setCurrentUploadStep(2); + } } catch { - // 6. 실패 시 addServerMessage( - '이미지 업로드 중 오류가 발생했습니다. 다시 시도해 주세요.', + '이미지 업로드 중 오류가 발생했습니다. 다시 시도해주세요.', ); - // 실패해도 로딩 상태는 초기화 setUploadingSlots([]); setIsUploading(false); + // 실패 시 단계 초기화 + if (expectedCount === 1 || currentUploadStep === 0) { + setCurrentUploadStep(0); + } else { + setCurrentUploadStep(1); + } } finally { - // 7. 모든 작업이 끝나면 input 초기화 e.target.value = ''; } }; // 모달에서 옵션 선택 시 input 트리거 - const handleModalSelect = (option: 'one' | 'two') => { + const handleModalSelect = async (option: 'one' | 'two') => { + // async 유지 + + // 무조건 모든 상태 초기화 setChatList([]); solutionStepsRef.current = []; setImageUploaded(false); setS3Key(''); setDownloadUrls([]); + setPresignedUploadUrls([]); + setPresignedDownloadUrls([]); + setCurrentUploadStep(0); // 단계 상태도 초기화 + setToggleItems([ '단계별 풀이를 알려줘', '전체 풀이를 알려줘', @@ -315,9 +368,27 @@ const Solve = () => { const count = option === 'one' ? 1 : 2; setExpectedCount(count); + if (count === 2) { + // 2장 모드: S3 URL 2개와 Key를 미리 요청하여 저장 + try { + const { + uploadUrls, + downloadUrls, + s3Key: receivedS3Key, + } = await getPresignedUrl(2); + setPresignedUploadUrls(uploadUrls); + setPresignedDownloadUrls(downloadUrls); + setS3Key(receivedS3Key); + } catch { + setIsOpen(false); + addServerMessage('URL 발급 중 오류가 발생했습니다. 다시 시도해주세요.'); + return; + } + } + // input 설정 후 클릭 트리거 if (fileInputRef.current) { - fileInputRef.current.multiple = count > 1; + fileInputRef.current.multiple = false; // 항상 1개씩만 선택하게 함 fileInputRef.current.click(); } }; diff --git a/src/pages/solve/solve.css.ts b/src/pages/solve/solve.css.ts index 9e166f0..b1a3eb2 100644 --- a/src/pages/solve/solve.css.ts +++ b/src/pages/solve/solve.css.ts @@ -6,7 +6,6 @@ const wrapper = style({ minHeight: '100dvh', height: '100%', paddingTop: '7.95rem', - // paddingTop: '10.8rem', paddingBottom: '8.95rem', backgroundColor: themeVars.color.gray100, From 857edb631a983bbf50e6187d5da0c331e955a059 Mon Sep 17 00:00:00 2001 From: hansoojeongsj Date: Wed, 29 Oct 2025 23:50:03 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20solve=20=EB=B3=B5=EA=B7=80=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/solve/Solve.tsx | 165 +++++++++++--------------------------- 1 file changed, 47 insertions(+), 118 deletions(-) diff --git a/src/pages/solve/Solve.tsx b/src/pages/solve/Solve.tsx index 16d45e0..84379bc 100644 --- a/src/pages/solve/Solve.tsx +++ b/src/pages/solve/Solve.tsx @@ -36,14 +36,6 @@ const Solve = () => { const [chatList, setChatList] = useState([]); const [isOpen, setIsOpen] = useState(false); const [imageUploaded, setImageUploaded] = useState(false); - - // 순차 업로드를 위한 상태 복구 - const [presignedUploadUrls, setPresignedUploadUrls] = useState([]); - const [presignedDownloadUrls, setPresignedDownloadUrls] = useState( - [], - ); - const [currentUploadStep, setCurrentUploadStep] = useState<0 | 1 | 2>(0); // 0:초기, 1:문제완료/풀이대기, 2:최종완료 - const [downloadUrls, setDownloadUrls] = useState([]); const [s3Key, setS3Key] = useState(''); const [isPending, setIsPending] = useState(false); @@ -235,130 +227,85 @@ const Solve = () => { // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { - // 1. 파일 선택 직후, 모달 상태를 닫음 (브라우저 동작 실패 대비 강제) + // 1. 파일 선택 즉시 모달 닫기 setIsOpen(false); const files = e.target.files; - // 2. 파일 개수 검증 (순차 모드이므로 항상 1개만 받아야 함) - if (!files || files.length !== 1) { - const stepName = - expectedCount === 1 || currentUploadStep === 0 - ? '문제 이미지' - : '풀이 이미지'; - addServerMessage(`${stepName} 1장만 선택해주세요.`); + // 2. 파일 개수 검증 로직 수정 + // 필요한 개수와 다르다면 에러 메시지 표시 + if (!files || files.length !== expectedCount) { + addServerMessage( + expectedCount === 1 + ? '정확한 풀이를 위해 문제 이미지 1장을 선택해주세요.' + : '정확한 풀이를 위해 문제 이미지 1장, 풀이 이미지 1장을 선택해주세요.', + ); e.target.value = ''; return; } - const file = files[0]; - let downloadUrlToUse: string; - let uploadUrlToUse: string; - let receivedS3Key: string | undefined; - let finalDownloadUrls: string[] = []; - - // 3. 'me' 로딩 UI 시작 - setUploadingSlots([0]); + // 3. 'me' 로딩 UI 시작 및 초기화 + setUploadingSlots(Array.from({ length: expectedCount }, (_, i) => i)); setIsUploading(true); + const uploadedUrls: string[] = []; try { - if (expectedCount === 2) { - // 2장 모드 (미리 받아둔 URL 사용) - const stepIndex = currentUploadStep === 0 ? 0 : 1; - - if ( - presignedUploadUrls.length < 2 || - presignedDownloadUrls.length < 2 - ) { - throw new Error( - 'Presigned URLs not initialized correctly for 2 images.', - ); + const { + uploadUrls, + downloadUrls: presignedUrls, + s3Key: presignedKey, + } = await getPresignedUrl(expectedCount); + + const filesArray = Array.from(files); + + // S3 업로드 루프: filesArray의 순서를 그대로 사용 + // filesArray의 순서가 곧 사용자가 선택한 순서 + for (let i = 0; i < expectedCount; i++) { + const response = await uploadToPresignedUrl( + uploadUrls[i], + filesArray[i]!, + ); + if (!response.ok) { + throw new Error('S3 업로드 실패'); } - - uploadUrlToUse = presignedUploadUrls[stepIndex]; - downloadUrlToUse = presignedDownloadUrls[stepIndex]; - } else { - // 1장 모드: Presigned URL을 지금 요청 (기존 로직 유지) - const res = await getPresignedUrl(1); - uploadUrlToUse = res.uploadUrls[0]; - downloadUrlToUse = res.downloadUrls[0]; - receivedS3Key = res.s3Key; + uploadedUrls.push(presignedUrls[i]); } - // S3 업로드 - await uploadToPresignedUrl(uploadUrlToUse, file); - - // 4. S3 업로드 완료 후, 프리로딩 시작 - await preloadImages([downloadUrlToUse]); + // 4. S3 업로드 완료 후, 로딩을 끄지 않고 프리로딩 시작 + await preloadImages(uploadedUrls); - // 5. 프리로딩 완료 후, 로딩 제거하고 이미지 추가 + // 5. 프리로딩 완료 후, 로딩 제거하고 동시에 이미지 추가 setUploadingSlots([]); setIsUploading(false); - handleImageSelect(downloadUrlToUse); - - // 후속 처리 로직 (핵심 분기) - if (expectedCount === 2) { - // --- 2장 모드 처리 --- - if (currentUploadStep === 0) { - // 1단계(문제) 완료: 2단계 업로드를 위해 다음 파일 선택 창 자동 호출 - finalDownloadUrls = [downloadUrlToUse]; - setDownloadUrls(finalDownloadUrls); // 문제 이미지 URL만 임시 저장 - setCurrentUploadStep(1); // 2단계(풀이) 대기 상태 - - // 쉼없이 다음 단계 파일 선택 창 자동 호출 - setTimeout(() => { - // setTimeout이 User Activation 오류의 원인이지만, UX를 위해 일단 유지 - if (fileInputRef.current) { - fileInputRef.current.multiple = false; - fileInputRef.current.click(); - } - }, 300); - } else if (currentUploadStep === 1) { - // 2단계(풀이) 완료: 최종 URL 결합 및 완료 - finalDownloadUrls = [...downloadUrls, downloadUrlToUse]; // 문제 + 풀이 URL 결합 - setDownloadUrls(finalDownloadUrls); // 최종 URL 저장 - setImageUploaded(true); - setCurrentUploadStep(2); - } - } else { - // --- 1장 모드 처리 (expectedCount === 1) --- - finalDownloadUrls = [downloadUrlToUse]; - setDownloadUrls(finalDownloadUrls); - setS3Key(receivedS3Key!); - setImageUploaded(true); - setCurrentUploadStep(2); - } + + uploadedUrls.forEach((url) => { + handleImageSelect(url); + }); + + setS3Key(presignedKey); + setDownloadUrls(presignedUrls); + setImageUploaded(true); } catch { + // 6. 실패 시 addServerMessage( - '이미지 업로드 중 오류가 발생했습니다. 다시 시도해주세요.', + '이미지 업로드 중 오류가 발생했습니다. 다시 시도해 주세요.', ); + // 실패해도 로딩 상태는 초기화 setUploadingSlots([]); setIsUploading(false); - // 실패 시 단계 초기화 - if (expectedCount === 1 || currentUploadStep === 0) { - setCurrentUploadStep(0); - } else { - setCurrentUploadStep(1); - } } finally { + // 7. 모든 작업이 끝나면 input 초기화 e.target.value = ''; } }; // 모달에서 옵션 선택 시 input 트리거 - const handleModalSelect = async (option: 'one' | 'two') => { - // async 유지 - - // 무조건 모든 상태 초기화 + const handleModalSelect = (option: 'one' | 'two') => { setChatList([]); solutionStepsRef.current = []; setImageUploaded(false); setS3Key(''); setDownloadUrls([]); - setPresignedUploadUrls([]); - setPresignedDownloadUrls([]); - setCurrentUploadStep(0); // 단계 상태도 초기화 - setToggleItems([ '단계별 풀이를 알려줘', '전체 풀이를 알려줘', @@ -368,27 +315,9 @@ const Solve = () => { const count = option === 'one' ? 1 : 2; setExpectedCount(count); - if (count === 2) { - // 2장 모드: S3 URL 2개와 Key를 미리 요청하여 저장 - try { - const { - uploadUrls, - downloadUrls, - s3Key: receivedS3Key, - } = await getPresignedUrl(2); - setPresignedUploadUrls(uploadUrls); - setPresignedDownloadUrls(downloadUrls); - setS3Key(receivedS3Key); - } catch { - setIsOpen(false); - addServerMessage('URL 발급 중 오류가 발생했습니다. 다시 시도해주세요.'); - return; - } - } - // input 설정 후 클릭 트리거 if (fileInputRef.current) { - fileInputRef.current.multiple = false; // 항상 1개씩만 선택하게 함 + fileInputRef.current.multiple = count > 1; fileInputRef.current.click(); } }; From 3007689b1d4d9562350d79fd3e03af37d64dc400 Mon Sep 17 00:00:00 2001 From: hansoojeongsj Date: Thu, 30 Oct 2025 00:32:49 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B4=80=EB=A0=A8=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=20=EB=84=93=EC=9D=B4=20=EC=A7=80=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=A4=91=EC=95=99=EC=97=90=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=8B=9C=ED=82=A4=EA=B8=B0=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reviewNoteDetail/reviewNoteDetail.css.ts | 11 +++- src/pages/reviewNotes/ReviewNotes.tsx | 60 ++++++++++--------- src/pages/reviewNotes/reviewNotes.css.ts | 8 +++ 3 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/pages/reviewNoteDetail/reviewNoteDetail.css.ts b/src/pages/reviewNoteDetail/reviewNoteDetail.css.ts index 70e8b6f..ca60c98 100644 --- a/src/pages/reviewNoteDetail/reviewNoteDetail.css.ts +++ b/src/pages/reviewNoteDetail/reviewNoteDetail.css.ts @@ -8,6 +8,8 @@ export const mainContainer = style({ padding: '9.6rem 2.4rem 10rem', gap: '1.6rem', + alignItems: 'center', + backgroundColor: themeVars.color.gray100, backgroundRepeat: 'no-repeat', backgroundSize: 'cover', @@ -17,6 +19,8 @@ export const topContent = style({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', + width: '100%', + maxWidth: '66.8rem', }); export const date = style({ @@ -27,6 +31,10 @@ export const date = style({ export const noteContent = style({ paddingTop: '1.6rem', + width: '100%', + maxWidth: '66.8rem', + margin: '0 auto', + color: themeVars.color.gray600, whiteSpace: 'pre-wrap', ...themeVars.font.bodySmall, @@ -34,5 +42,6 @@ export const noteContent = style({ export const img = style({ width: '100%', - maxWidth: '50rem', + maxWidth: '66.8rem', + margin: '0 auto', }); diff --git a/src/pages/reviewNotes/ReviewNotes.tsx b/src/pages/reviewNotes/ReviewNotes.tsx index b5440a9..a668ac7 100644 --- a/src/pages/reviewNotes/ReviewNotes.tsx +++ b/src/pages/reviewNotes/ReviewNotes.tsx @@ -108,38 +108,40 @@ const ReviewNotes = () => { return (
- {toasts.map((msg, i) => ( - - setToasts((prev) => prev.filter((_, index) => index !== i)) - } - /> - ))} -

오답노트

- -

- 복습하고 싶은 문제를 선택해 풀어보세요! -

- -
- {data.map((card) => ( - toggleSelectCard(card.questionId)} - onCardClick={() => handleClick(card.questionId)} +
+ {toasts.map((msg, i) => ( + + setToasts((prev) => prev.filter((_, index) => index !== i)) + } /> ))} +

오답노트

+ +

+ 복습하고 싶은 문제를 선택해 풀어보세요! +

+ +
+ {data.map((card) => ( + toggleSelectCard(card.questionId)} + onCardClick={() => handleClick(card.questionId)} + /> + ))} +
+ +
- -
); }; diff --git a/src/pages/reviewNotes/reviewNotes.css.ts b/src/pages/reviewNotes/reviewNotes.css.ts index 22d24f9..ff888cf 100644 --- a/src/pages/reviewNotes/reviewNotes.css.ts +++ b/src/pages/reviewNotes/reviewNotes.css.ts @@ -46,3 +46,11 @@ export const cardContainer = style({ rowGap: '24px', columnGap: '12px', }); + +export const content = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: '66.8rem', + margin: '0 auto', +});