diff --git a/public/svg/ic_left_arrow.svg b/public/svg/ic_left_arrow.svg new file mode 100644 index 0000000..d24fd2c --- /dev/null +++ b/public/svg/ic_left_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/pages/home/components/scrollText/ScrollText.tsx b/src/pages/home/components/scrollText/ScrollText.tsx index 8e2b83f..4a2334b 100644 --- a/src/pages/home/components/scrollText/ScrollText.tsx +++ b/src/pages/home/components/scrollText/ScrollText.tsx @@ -3,7 +3,7 @@ import * as styles from '@pages/home/components/scrollText/scrollText.css'; const ScrollText = () => { const { scrollY } = useScroll(); - const BREAKPOINT = 2480; + const BREAKPOINT = 2400; const positionY = useTransform(scrollY, (y) => y < BREAKPOINT ? 'fixed' : 'absolute', diff --git a/src/pages/home/components/sectionBottom/SectionBottom.tsx b/src/pages/home/components/sectionBottom/SectionBottom.tsx index a4dd097..4222ac6 100644 --- a/src/pages/home/components/sectionBottom/SectionBottom.tsx +++ b/src/pages/home/components/sectionBottom/SectionBottom.tsx @@ -20,9 +20,9 @@ const SectionBottom = () => { } }, []); - const FADE_IN_START = offsetTop - window.innerHeight / 2 + 200; - const FADE_IN_GAP = 200; - const FADE_OUT_GAP = 200; + const FADE_IN_START = offsetTop - window.innerHeight / 2; + const FADE_IN_GAP = 150; + const FADE_OUT_GAP = 150; const opacityIn = useTransform( scrollY, @@ -33,7 +33,7 @@ const SectionBottom = () => { const translateYIn = useTransform( scrollY, [FADE_IN_START, FADE_IN_START + FADE_IN_GAP], - [50, 0], + [-100, -150], ); const opacityOut = useTransform( diff --git a/src/pages/home/components/sectionBottom/sectionBottom.css.ts b/src/pages/home/components/sectionBottom/sectionBottom.css.ts index 2d078e4..dd927f9 100644 --- a/src/pages/home/components/sectionBottom/sectionBottom.css.ts +++ b/src/pages/home/components/sectionBottom/sectionBottom.css.ts @@ -4,7 +4,7 @@ import { style } from '@vanilla-extract/css'; const sectionBottomWrapper = style({ position: 'relative', width: '100%', - height: '88.8rem', + height: '93rem', background: 'linear-gradient(180deg, #82acff 0%, #D7ECFF 85%)', overflow: 'hidden', }); diff --git a/src/pages/home/components/sectionTop/SectionTop.tsx b/src/pages/home/components/sectionTop/SectionTop.tsx index ed46322..cfd1d42 100644 --- a/src/pages/home/components/sectionTop/SectionTop.tsx +++ b/src/pages/home/components/sectionTop/SectionTop.tsx @@ -65,7 +65,6 @@ const SectionTop = () => { return (
- {/* 전체 wrapper: translateY만 적용 */} { @@ -107,7 +108,9 @@ const SectionTop = () => { diff --git a/src/pages/home/components/sectionTop/sectionTop.css.ts b/src/pages/home/components/sectionTop/sectionTop.css.ts index 21b3b47..6ca67cd 100644 --- a/src/pages/home/components/sectionTop/sectionTop.css.ts +++ b/src/pages/home/components/sectionTop/sectionTop.css.ts @@ -7,6 +7,6 @@ export const sectionTopWrapper = style({ alignItems: 'center', width: '100%', - height: '249.8rem', + height: '256rem', background: 'linear-gradient(180deg, #2150EC 0%,#82acff 100%)', }); diff --git a/src/pages/home/home.css.ts b/src/pages/home/home.css.ts index 84443e6..240ad61 100644 --- a/src/pages/home/home.css.ts +++ b/src/pages/home/home.css.ts @@ -2,16 +2,24 @@ import { themeVars } from '@styles/theme.css'; import { style } from '@vanilla-extract/css'; export const floatingSolveBtn = style({ + // 1. 버튼을 담을 '컨테이너' display: 'flex', - position: 'fixed', justifyContent: 'flex-end', - + position: 'fixed', bottom: '2rem', + zIndex: themeVars.zIndex.one, + + // 2. 컨테이너를 페이지 '메인 컨텐츠'와 똑같이 + width: '100%', + maxWidth: '1180px', + + // 3. 컨테이너를 화면 중앙에 배치 left: '50%', transform: 'translateX(-50%)', + + // 4. 컨테이너의 오른쪽 안쪽에 2rem 여백을 줌 paddingRight: '2rem', - width: '100%', - maxWidth: '768px', - zIndex: themeVars.zIndex.one, + // padding이 너비에 영향을 주지 않도록 설정 + boxSizing: 'border-box', }); diff --git a/src/pages/loginCallback/LoginCallback.tsx b/src/pages/loginCallback/LoginCallback.tsx index 2e45f78..d324221 100644 --- a/src/pages/loginCallback/LoginCallback.tsx +++ b/src/pages/loginCallback/LoginCallback.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; import { routePath } from '@routes/routePath'; @@ -10,8 +10,14 @@ import { API_URL } from '@/shared/constants/apiURL'; const LoginCallback = () => { const navigate = useNavigate(); + const hasRunRef = useRef(false); useEffect(() => { + if (hasRunRef.current) { + return; + } + hasRunRef.current = true; + const params = new URLSearchParams(window.location.search); const code = params.get('code'); const returnedState = params.get('state'); diff --git a/src/pages/my/my.css.ts b/src/pages/my/my.css.ts index 72cc702..dbe7b1d 100644 --- a/src/pages/my/my.css.ts +++ b/src/pages/my/my.css.ts @@ -146,6 +146,8 @@ export const chipList = style({ export const chipListWrapper = style({ position: 'relative', + width: '100%', + maxWidth: '60rem', }); export const chipGradientOverlay = style({ diff --git a/src/pages/solve/Solve.tsx b/src/pages/solve/Solve.tsx index d873ecf..c2e0b08 100644 --- a/src/pages/solve/Solve.tsx +++ b/src/pages/solve/Solve.tsx @@ -13,6 +13,19 @@ import { solutionStepsRef, } from './ChatLogic'; +const preloadImages = (urls: string[]): Promise => { + const promises = urls.map((url) => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = url; + img.onload = () => resolve(); + img.onerror = () => reject(new Error(`Failed to load image: ${url}`)); + }); + }); + + return Promise.all(promises).then(() => undefined); +}; + // 타입 type StepItem = Record<`step ${number}`, string>; type AnswerItem = { answer: string }; @@ -26,6 +39,8 @@ const Solve = () => { const [downloadUrls, setDownloadUrls] = useState([]); const [s3Key, setS3Key] = useState(''); const [isPending, setIsPending] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const [uploadingSlots, setUploadingSlots] = useState([]); const bottomRef = useRef(null); const { mutateAsync: requestSolutionMutate } = usePostAiChat(); @@ -37,10 +52,11 @@ const Solve = () => { requestAnimationFrame(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), ); - }, [chatList, isPending]); + }, [chatList, isPending, isUploading]); const addChat = (chat: Chat) => setChatList((prev) => [...prev, chat]); const addServerMessage = (text: string) => addChat({ from: 'server', text }); + const handleImageSelect = (url: string) => addChat({ from: 'me', imageUrl: url }); @@ -53,7 +69,7 @@ const Solve = () => { // 토글 클릭 핸들러 const handleTextSelect = (text: string) => { - if (isPending) { + if (isPending || isUploading) { return; } addChat({ from: 'me', text }); @@ -62,7 +78,12 @@ const Solve = () => { text !== '해결했어요!' && (!imageUploaded || !s3Key || !downloadUrls.length) ) { - return addServerMessage('문제 이미지를 먼저 업로드 해주세요!'); + return setTimeout(() => { + addChat({ + from: 'server', + text: '문제 이미지를 먼저 업로드 해주세요!', + }); + }, 300); } switch (text) { @@ -149,7 +170,12 @@ const Solve = () => { }; const handleSolved = () => { - addChat({ from: 'server', text: '문제 해결을 축하합니다!' }); + setTimeout(() => { + addChat({ + from: 'server', + text: '문제 해결을 축하합니다!', + }); + }, 300); setTimeout(() => { addChat({ from: 'server', @@ -177,6 +203,13 @@ const Solve = () => { text: solutionStepsRef.current.map((s) => s.text).join('\n\n'), }); + setTimeout(() => { + addChat({ + from: 'server', + text: '새로운 문제를 질문하려면 카메라를 눌러주세요.', + }); + }, 1000); + // 토글 그대로 유지 setToggleItems([ '단계별 풀이를 알려줘', @@ -194,6 +227,9 @@ const Solve = () => { // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { + // 1. 파일 선택 즉시 모달 닫기 + setIsOpen(false); + const files = e.target.files; if (!files || files.length < expectedCount) { addServerMessage( @@ -201,42 +237,64 @@ const Solve = () => { ? '문제 이미지 1장을 선택해주세요.' : '문제 이미지 1장, 풀이 이미지 1장을 선택해주세요.', ); + e.target.value = ''; return; } + // 2. 'me' 로딩 UI 시작 및 초기화 + setUploadingSlots(Array.from({ length: expectedCount }, (_, i) => i)); + setIsUploading(true); + const uploadedUrls: string[] = []; + try { const { uploadUrls, downloadUrls: presignedUrls, s3Key: presignedKey, } = await getPresignedUrl(expectedCount); - const sortedFiles = Array.from(files).sort( - (a, b) => a.lastModified - b.lastModified, - ); + // lastModified 정렬 로직 제거 + const filesArray = Array.from(files); + + // S3 업로드 루프: filesArray의 순서를 그대로 사용 + // filesArray의 순서가 곧 사용자가 선택한 순서 for (let i = 0; i < expectedCount; i++) { const response = await uploadToPresignedUrl( uploadUrls[i], - sortedFiles[i]!, + filesArray[i]!, ); if (!response.ok) { throw new Error('S3 업로드 실패'); } - handleImageSelect(presignedUrls[i]); + uploadedUrls.push(presignedUrls[i]); } + // 3. S3 업로드 완료 후, 로딩을 끄지 않고 프리로딩 시작 + await preloadImages(uploadedUrls); + + // 4. 프리로딩 완료 후, 로딩 제거하고 동시에 이미지 추가 + setUploadingSlots([]); + setIsUploading(false); + + // uploadedUrls는 사용자가 선택한 순서대로 + uploadedUrls.forEach((url) => { + handleImageSelect(url); + }); + setS3Key(presignedKey); setDownloadUrls(presignedUrls); setImageUploaded(true); } catch { + // 5. 실패 시 addServerMessage( '이미지 업로드 중 오류가 발생했습니다. 다시 시도해 주세요.', ); + // 실패해도 로딩 상태는 초기화 + setUploadingSlots([]); + setIsUploading(false); } finally { - // input 초기화 (같은 파일 다시 선택 가능하게) + // 6. 모든 작업이 끝나면 input 초기화 e.target.value = ''; - // 모달 닫기 - setIsOpen(false); } }; @@ -282,6 +340,19 @@ const Solve = () => {
)} + {isUploading && + uploadingSlots.map((_, index) => ( +
+
+
+ + + +
+
+
+ ))} +
@@ -289,7 +360,7 @@ const Solve = () => { items={toggleItems} onTextSelect={handleTextSelect} onCameraClick={() => setIsOpen(true)} - disabled={isPending} + disabled={isPending || isUploading} /> { const location = useLocation(); - const noHeaderPaths = [routePath.LOGIN, routePath.SIGNUP]; + const noHeaderPaths = [ + routePath.LOGIN, + routePath.SIGNUP, + routePath.LOGIN_CALLBACK, + ]; const showHeader = !noHeaderPaths.some((path) => location.pathname.startsWith(path), ); diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 735df3f..f812216 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -4,22 +4,27 @@ import Error from '@pages/error/Error'; import GlobalLayout from './Layout'; import { routePath } from './routePath'; import { protectedRoutes, publicRoutes } from './globalRoutes'; +import GlobalErrorBoundary from './globalErrorBoundary'; import ProtectedRoute from './protectedRoute'; export const router = createBrowserRouter([ { path: routePath.HOME, - element: , + element: ( + + + + ), children: [ ...publicRoutes, { element: , children: protectedRoutes, }, - { - path: '*', - element: , - }, ], }, + { + path: '*', + element: , + }, ]); diff --git a/src/shared/apis/interceptor.ts b/src/shared/apis/interceptor.ts index 0e63540..3538cff 100644 --- a/src/shared/apis/interceptor.ts +++ b/src/shared/apis/interceptor.ts @@ -30,6 +30,12 @@ export const onErrorResponse = async (error: AxiosError) => { return Promise.reject(error); } + // 서버가 꺼졌거나 네트워크 문제 + if (!error.response) { + window.location.href = '/error'; + return Promise.reject(error); + } + if (error.response?.status === 401 && originRequest.url !== API_URL.REISSUE) { // refresh 중이면 큐에 대기 if (isRefreshing) { @@ -71,5 +77,6 @@ export const onErrorResponse = async (error: AxiosError) => { } } + // 3. 그 외 HTTP 에러 코드 (400, 403, 500 등) 처리 return Promise.reject(error); }; diff --git a/src/shared/components/footer/footer.css.ts b/src/shared/components/footer/footer.css.ts index 068c270..a8c36f8 100644 --- a/src/shared/components/footer/footer.css.ts +++ b/src/shared/components/footer/footer.css.ts @@ -8,7 +8,7 @@ export const footerWrapper = style({ gap: '0.8rem', width: '100%', - height: '14.4rem', + height: '18rem', padding: '3.6rem 2.4rem 6rem 2.4rem', backgroundColor: themeVars.color.gray600, diff --git a/src/shared/components/header/Header.tsx b/src/shared/components/header/Header.tsx index 8b96f6d..dac5b25 100644 --- a/src/shared/components/header/Header.tsx +++ b/src/shared/components/header/Header.tsx @@ -1,4 +1,9 @@ -import { IcHomeTextLogo, IcMypage, IcTextLogo } from '@components/icons'; +import { + IcHomeTextLogo, + IcMypage, + IcTextLogo, + IcLeftArrow, +} from '@components/icons'; import { useLocation, useNavigate } from 'react-router-dom'; import { routePath } from '@routes/routePath'; import { themeVars } from '@styles/theme.css'; @@ -14,26 +19,60 @@ const Header = ({ isHome = false }: HeaderProps) => { const { pathname } = useLocation(); const goHome = () => navigate(routePath.HOME); + const goBack = () => { + if (window.history?.length && window.history.length > 1) { + navigate(-1); + } else { + navigate(routePath.HOME, { replace: true }); + } + }; const goMyPage = () => navigate(routePath.MY); + const isMyPage = pathname === routePath.MY; + const isHomePage = pathname === routePath.HOME; + + const showBackButton = + !isHomePage && !isMyPage && pathname !== routePath.SOLVE; + + const iconColor = isHome ? '#C9DFFF' : themeVars.color.point; return (
- )} - - {!isMyPage && ( - - )} +
+ +
+ {!isMyPage && ( + + )} +
); }; diff --git a/src/shared/components/header/header.css.ts b/src/shared/components/header/header.css.ts index 54c3f70..c50bb9a 100644 --- a/src/shared/components/header/header.css.ts +++ b/src/shared/components/header/header.css.ts @@ -4,8 +4,6 @@ import { style } from '@vanilla-extract/css'; export const headerWrapper = style({ display: 'flex', padding: '2rem 2.4rem', - // padding: '6rem 2.4rem 1.2rem', - alignItems: 'center', justifyContent: 'space-between', position: 'fixed', @@ -15,3 +13,14 @@ export const headerWrapper = style({ WebkitBackdropFilter: 'blur(5px)', zIndex: themeVars.zIndex.five, }); + +export const leftGroup = style({ + display: 'flex', + alignItems: 'center', + gap: '1.6rem', +}); + +export const rightGroup = style({ + display: 'flex', + alignItems: 'center', +}); diff --git a/src/shared/components/icons/IcLeftArrow.tsx b/src/shared/components/icons/IcLeftArrow.tsx new file mode 100644 index 0000000..49708ee --- /dev/null +++ b/src/shared/components/icons/IcLeftArrow.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; +const SvgIcLeftArrow = (props: SVGProps) => ( + + + +); +export default SvgIcLeftArrow; diff --git a/src/shared/components/icons/index.ts b/src/shared/components/icons/index.ts index 76f3aa9..04ef295 100644 --- a/src/shared/components/icons/index.ts +++ b/src/shared/components/icons/index.ts @@ -13,6 +13,7 @@ export { default as IcFloatingSolve } from './IcFloatingSolve'; export { default as IcGrayCheck } from './IcGrayCheck'; export { default as IcHomeTextLogo } from './IcHomeTextLogo'; export { default as IcKakao } from './IcKakao'; +export { default as IcLeftArrow } from './IcLeftArrow'; export { default as IcMainAdd } from './IcMainAdd'; export { default as IcMainChat1 } from './IcMainChat1'; export { default as IcMainChat2 } from './IcMainChat2'; diff --git a/src/shared/styles/global.css.ts b/src/shared/styles/global.css.ts index 17bf200..01199db 100644 --- a/src/shared/styles/global.css.ts +++ b/src/shared/styles/global.css.ts @@ -6,7 +6,7 @@ import { themeVars } from './theme.css'; globalStyle(':root', { vars: { '--min-width': '375px', - '--max-width': '820px', + '--max-width': '1180px', }, });