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 (
-
);
};
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',
},
});