Skip to content

Commit 15c43bb

Browse files
authored
Merge pull request #155 from TTORANG/develop
deploy: 2.1.0 배포
2 parents 5ff2990 + 2da0902 commit 15c43bb

14 files changed

Lines changed: 217 additions & 43 deletions

firebase.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"headers": [
3333
{
3434
"key": "Content-Security-Policy",
35-
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://ttorang-server-407623424780.asia-northeast3.run.app https://cdn.ttorang.com https://developers.kakao.com; media-src 'self' https://cdn.ttorang.com; frame-ancestors 'none'"
35+
"value": "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://ttorang-server-407623424780.asia-northeast3.run.app https://cdn.ttorang.com https://developers.kakao.com https://cdn.jsdelivr.net; media-src 'self' https://cdn.ttorang.com; frame-ancestors 'none'"
3636
},
3737
{
3838
"key": "X-Frame-Options",

src/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useEffect } from 'react';
22
import { RouterProvider } from 'react-router-dom';
33

4+
import { useQueryClient } from '@tanstack/react-query';
5+
46
import type { JwtPayloadDto } from '@/api/dto';
7+
import { queryKeys } from '@/api/queryClient';
58
import { DevFab } from '@/components/common/DevFab';
69
import { router } from '@/router';
710
import { useAuthStore } from '@/stores/authStore';
@@ -10,6 +13,8 @@ import { parseJwtPayload } from '@/utils/jwt';
1013

1114
function App() {
1215
useThemeListener();
16+
const queryClient = useQueryClient();
17+
const accessToken = useAuthStore((state) => state.accessToken);
1318

1419
useEffect(() => {
1520
const handleMessage = (event: MessageEvent) => {
@@ -41,6 +46,11 @@ function App() {
4146
return () => window.removeEventListener('message', handleMessage);
4247
}, []);
4348

49+
useEffect(() => {
50+
if (!accessToken) return;
51+
void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() });
52+
}, [accessToken, queryClient]);
53+
4454
return (
4555
<>
4656
<RouterProvider router={router} />

src/api/errorHandler.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ type ErrorHandler = (message: string) => void;
88
*/
99
const errorHandlers: Record<number, ErrorHandler> = {
1010
401: () => {
11-
// 세션 만료 시 로그아웃 처리
11+
const { accessToken } = useAuthStore.getState();
12+
// 로그인 상태일 때만 만료 처리 (비로그인 상태에서는 토스트 금지)
13+
if (!accessToken) return;
1214
useAuthStore.getState().logout();
1315
showToast.error('로그인이 만료되었습니다.', '다시 로그인해주세요.');
1416
},

src/assets/icons/icon-logout.svg

Lines changed: 3 additions & 3 deletions
Loading

src/components/common/FileDropzone.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export default function FileDropzone({
6161
if (inputRef.current) inputRef.current.value = ''; // 같은 파일 다시 선택 가능하게 (선택창 value 초기화)
6262
};
6363

64-
const handleDragEnter = (e: React.DragEvent<HTMLButtonElement>) => {
64+
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
6565
e.preventDefault();
6666
e.stopPropagation();
6767
if (isBlocked) return;
@@ -70,13 +70,13 @@ export default function FileDropzone({
7070
setIsDragging(true);
7171
};
7272

73-
const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
73+
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
7474
e.preventDefault();
7575
e.stopPropagation();
7676
if (isBlocked) return;
7777
};
7878

79-
const handleDragLeave = (e: React.DragEvent<HTMLButtonElement>) => {
79+
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
8080
e.preventDefault();
8181
e.stopPropagation();
8282
if (isBlocked) return;
@@ -85,7 +85,7 @@ export default function FileDropzone({
8585
if (dragCounter.current === 0) setIsDragging(false);
8686
};
8787

88-
const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
88+
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
8989
e.preventDefault();
9090
e.stopPropagation();
9191
// 드롭 시 카운터 초기화해서 다음 드래그 상태가 꼬이지 않도록 함
@@ -115,6 +115,14 @@ export default function FileDropzone({
115115
const showDragOverlay = isDragging && !isBlocked;
116116
const showUploadOverlay = isUploading;
117117

118+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
119+
if (isBlocked) return;
120+
if (e.key === 'Enter' || e.key === ' ') {
121+
e.preventDefault();
122+
openFileDialog();
123+
}
124+
};
125+
118126
return (
119127
<div className="w-full mt-10">
120128
<input
@@ -125,9 +133,12 @@ export default function FileDropzone({
125133
onChange={(e) => handleFile(e.target.files)}
126134
/>
127135

128-
<button
129-
type="button"
136+
<div
137+
role="button"
138+
tabIndex={isBlocked ? -1 : 0}
139+
aria-disabled={isBlocked}
130140
onClick={openFileDialog}
141+
onKeyDown={handleKeyDown}
131142
onDragEnter={handleDragEnter}
132143
onDragOver={handleDragOver}
133144
onDragLeave={handleDragLeave}
@@ -187,7 +198,7 @@ export default function FileDropzone({
187198
</button>
188199
</div>
189200
)}
190-
</button>
201+
</div>
191202
</div>
192203
);
193204
}
Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,124 @@
11
/**
22
* @file LoginButton.tsx
3-
* @description 로그인 버튼 컴포넌트
3+
* @description 로그인/프로필 버튼 컴포넌트
44
*
5-
* 헤더 우측에 표시되는 로그인 링크입니다.
5+
* 비로그인 상태: 로그인 버튼 (클릭 시 로그인 모달)
6+
* 로그인 상태: 사용자 이름 + 프로필 이미지 (클릭 시 로그아웃/회원탈퇴 드롭다운)
67
*/
8+
import { useState } from 'react';
9+
10+
import { apiClient } from '@/api/client';
711
import LoginIcon from '@/assets/icons/icon-login.svg?react';
12+
import LogoutIcon from '@/assets/icons/icon-logout.svg?react';
13+
import { Dropdown } from '@/components/common/Dropdown';
14+
import { Modal } from '@/components/common/Modal';
815
import { useAuthStore } from '@/stores/authStore';
16+
import { showToast } from '@/utils/toast';
917

1018
import { HeaderButton } from './HeaderButton';
1119

1220
export function LoginButton() {
21+
const user = useAuthStore((s) => s.user);
1322
const openLoginModal = useAuthStore((s) => s.openLoginModal);
23+
const logout = useAuthStore((s) => s.logout);
24+
25+
const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false);
26+
const [isWithdrawing, setIsWithdrawing] = useState(false);
27+
28+
if (!user) {
29+
return <HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} />;
30+
}
31+
32+
const handleWithdraw = async () => {
33+
setIsWithdrawing(true);
34+
try {
35+
await apiClient.delete(`/users/${user.id}`);
36+
logout();
37+
setIsWithdrawModalOpen(false);
38+
showToast.success('회원 탈퇴가 완료되었습니다.');
39+
} catch {
40+
showToast.error('회원 탈퇴에 실패했습니다.', '잠시 후 다시 시도해주세요.');
41+
} finally {
42+
setIsWithdrawing(false);
43+
}
44+
};
45+
46+
return (
47+
<>
48+
<Dropdown
49+
position="bottom"
50+
align="end"
51+
ariaLabel="사용자 메뉴"
52+
trigger={
53+
<button
54+
type="button"
55+
className="flex cursor-pointer items-center gap-2 text-body-s-bold text-gray-800 transition-colors hover:text-gray-600"
56+
>
57+
{user.name ?? '사용자'}
58+
{user.profileImage ? (
59+
<img
60+
src={user.profileImage}
61+
alt="프로필"
62+
className="size-6 rounded-full object-cover"
63+
/>
64+
) : (
65+
<div className="size-6 rounded-full bg-gray-200" />
66+
)}
67+
</button>
68+
}
69+
items={[
70+
{
71+
id: 'logout',
72+
label: (
73+
<span className="flex items-center gap-1">
74+
로그아웃
75+
<LogoutIcon className="size-6" />
76+
</span>
77+
),
78+
onClick: logout,
79+
variant: 'danger',
80+
},
81+
{
82+
id: 'withdraw',
83+
label: '회원 탈퇴',
84+
onClick: () => setIsWithdrawModalOpen(true),
85+
variant: 'danger',
86+
},
87+
]}
88+
/>
1489

15-
return <HeaderButton text="로그인" icon={<LoginIcon />} onClick={openLoginModal} />;
90+
<Modal
91+
isOpen={isWithdrawModalOpen}
92+
onClose={() => setIsWithdrawModalOpen(false)}
93+
title="회원 탈퇴"
94+
size="sm"
95+
closeOnBackdropClick={!isWithdrawing}
96+
closeOnEscape={!isWithdrawing}
97+
>
98+
<p className="text-body-m">
99+
탈퇴하면 모든 데이터가 삭제되며 복구할 수 없습니다.
100+
<br />
101+
정말 탈퇴하시겠습니까?
102+
</p>
103+
<div className="mt-7 flex gap-3">
104+
<button
105+
className="flex-1 rounded-md bg-gray-100 py-3 font-bold text-gray-600 transition-colors hover:bg-gray-200 disabled:opacity-50"
106+
type="button"
107+
onClick={() => setIsWithdrawModalOpen(false)}
108+
disabled={isWithdrawing}
109+
>
110+
취소
111+
</button>
112+
<button
113+
className="flex-1 rounded-md bg-error py-3 font-bold text-white transition-colors hover:bg-error/90 disabled:opacity-50"
114+
type="button"
115+
onClick={handleWithdraw}
116+
disabled={isWithdrawing}
117+
>
118+
{isWithdrawing ? '탈퇴 중...' : '탈퇴'}
119+
</button>
120+
</div>
121+
</Modal>
122+
</>
123+
);
16124
}

src/hooks/queries/usePresentations.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,14 @@ export function usePresentations(options?: { enabled?: boolean }) {
2727
/**
2828
* 프로젝트 목록 조회 (필터/검색/정렬 지원)
2929
*/
30-
export function usePresentationsWithFilters(params: GetPresentationsRequestDto) {
30+
export function usePresentationsWithFilters(
31+
params: GetPresentationsRequestDto,
32+
options?: { enabled?: boolean },
33+
) {
3134
return useQuery({
3235
queryKey: queryKeys.presentations.list(params),
3336
queryFn: () => getPresentations(params),
37+
enabled: options?.enabled ?? true,
3438
});
3539
}
3640

@@ -67,6 +71,26 @@ export function useUpdatePresentation() {
6771
? { ...old, title: updatePresentation.title, updatedAt: updatePresentation.updatedAt }
6872
: old,
6973
);
74+
// 목록 캐시는 즉시 업데이트 (화면 전환 시 반영 지연 방지)
75+
queryClient.setQueriesData<PresentationListResponse>(
76+
{ queryKey: queryKeys.presentations.lists() },
77+
(oldData) => {
78+
if (!oldData) return oldData;
79+
const nextPresentations = oldData.presentations.map((item) =>
80+
item.projectId === updatePresentation.projectId
81+
? {
82+
...item,
83+
title: updatePresentation.title,
84+
updatedAt: updatePresentation.updatedAt,
85+
}
86+
: item,
87+
);
88+
return {
89+
...oldData,
90+
presentations: nextPresentations,
91+
};
92+
},
93+
);
7094
// 목록은 최신 데이터 반영을 위해 무효화
7195
void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() });
7296
},

src/hooks/queries/useSlides.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* 슬라이드 관련 TanStack Query 훅
33
*/
44
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
5+
import { isAxiosError } from 'axios';
56

67
import type { UpdateSlideTitleRequestDto } from '@/api/dto';
78
import { getSlides, updateSlide } from '@/api/endpoints/slides';
@@ -17,9 +18,16 @@ export function useSlides(projectId: string) {
1718
queryKey: queryKeys.slides.list(projectId),
1819
queryFn: () => getSlides(projectId),
1920
enabled: !!projectId,
21+
retry: false,
2022
// 🔄 서버가 웹소켓 브로드캐스트를 안하므로 임시로 폴링 추가
2123
// TODO: 서버에서 broadcastNewComment 호출 후 제거
22-
refetchInterval: 3000, // 3초마다 자동 갱신
24+
refetchInterval: (query) => {
25+
const error = query.state.error;
26+
if (isAxiosError(error) && error.response?.status === 401) {
27+
return false;
28+
}
29+
return 3000;
30+
}, // 3초마다 자동 갱신 (401이면 중단)
2331
refetchIntervalInBackground: false, // 탭이 백그라운드일 때는 멈춤
2432
});
2533
}

src/hooks/useAutoSaveScript.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function useAutoSaveScript() {
3636
try {
3737
await mutateAsync({ slideId, data: { script } });
3838
lastSavedRef.current = script;
39-
showToast.success('저장 완료');
39+
showToast.success('저장 완료', '대본이 자동으로 저장되었습니다.');
4040
} catch {
4141
showToast.error('저장 실패', '다시 시도해주세요.');
4242
}

0 commit comments

Comments
 (0)