diff --git a/firebase.json b/firebase.json index 24fd599a..df8ad14c 100644 --- a/firebase.json +++ b/firebase.json @@ -32,7 +32,7 @@ "headers": [ { "key": "Content-Security-Policy", - "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'" + "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'" }, { "key": "X-Frame-Options", diff --git a/src/App.tsx b/src/App.tsx index b4c506b0..8a0428d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ import { useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + import type { JwtPayloadDto } from '@/api/dto'; +import { queryKeys } from '@/api/queryClient'; import { DevFab } from '@/components/common/DevFab'; import { router } from '@/router'; import { useAuthStore } from '@/stores/authStore'; @@ -10,6 +13,8 @@ import { parseJwtPayload } from '@/utils/jwt'; function App() { useThemeListener(); + const queryClient = useQueryClient(); + const accessToken = useAuthStore((state) => state.accessToken); useEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -41,6 +46,11 @@ function App() { return () => window.removeEventListener('message', handleMessage); }, []); + useEffect(() => { + if (!accessToken) return; + void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() }); + }, [accessToken, queryClient]); + return ( <> diff --git a/src/api/errorHandler.ts b/src/api/errorHandler.ts index 00867d28..3333adac 100644 --- a/src/api/errorHandler.ts +++ b/src/api/errorHandler.ts @@ -8,7 +8,9 @@ type ErrorHandler = (message: string) => void; */ const errorHandlers: Record = { 401: () => { - // 세션 만료 시 로그아웃 처리 + const { accessToken } = useAuthStore.getState(); + // 로그인 상태일 때만 만료 처리 (비로그인 상태에서는 토스트 금지) + if (!accessToken) return; useAuthStore.getState().logout(); showToast.error('로그인이 만료되었습니다.', '다시 로그인해주세요.'); }, diff --git a/src/assets/icons/icon-logout.svg b/src/assets/icons/icon-logout.svg index 7697df56..2f08f411 100644 --- a/src/assets/icons/icon-logout.svg +++ b/src/assets/icons/icon-logout.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/src/components/common/FileDropzone.tsx b/src/components/common/FileDropzone.tsx index 7e660e4d..db8c1bf7 100644 --- a/src/components/common/FileDropzone.tsx +++ b/src/components/common/FileDropzone.tsx @@ -61,7 +61,7 @@ export default function FileDropzone({ if (inputRef.current) inputRef.current.value = ''; // 같은 파일 다시 선택 가능하게 (선택창 value 초기화) }; - const handleDragEnter = (e: React.DragEvent) => { + const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (isBlocked) return; @@ -70,13 +70,13 @@ export default function FileDropzone({ setIsDragging(true); }; - const handleDragOver = (e: React.DragEvent) => { + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (isBlocked) return; }; - const handleDragLeave = (e: React.DragEvent) => { + const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (isBlocked) return; @@ -85,7 +85,7 @@ export default function FileDropzone({ if (dragCounter.current === 0) setIsDragging(false); }; - const handleDrop = (e: React.DragEvent) => { + const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); // 드롭 시 카운터 초기화해서 다음 드래그 상태가 꼬이지 않도록 함 @@ -115,6 +115,14 @@ export default function FileDropzone({ const showDragOverlay = isDragging && !isBlocked; const showUploadOverlay = isUploading; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isBlocked) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openFileDialog(); + } + }; + return (
handleFile(e.target.files)} /> -
)} - + ); } diff --git a/src/components/common/layout/LoginButton.tsx b/src/components/common/layout/LoginButton.tsx index 00802f1a..3a25b540 100644 --- a/src/components/common/layout/LoginButton.tsx +++ b/src/components/common/layout/LoginButton.tsx @@ -1,16 +1,124 @@ /** * @file LoginButton.tsx - * @description 로그인 버튼 컴포넌트 + * @description 로그인/프로필 버튼 컴포넌트 * - * 헤더 우측에 표시되는 로그인 링크입니다. + * 비로그인 상태: 로그인 버튼 (클릭 시 로그인 모달) + * 로그인 상태: 사용자 이름 + 프로필 이미지 (클릭 시 로그아웃/회원탈퇴 드롭다운) */ +import { useState } from 'react'; + +import { apiClient } from '@/api/client'; import LoginIcon from '@/assets/icons/icon-login.svg?react'; +import LogoutIcon from '@/assets/icons/icon-logout.svg?react'; +import { Dropdown } from '@/components/common/Dropdown'; +import { Modal } from '@/components/common/Modal'; import { useAuthStore } from '@/stores/authStore'; +import { showToast } from '@/utils/toast'; import { HeaderButton } from './HeaderButton'; export function LoginButton() { + const user = useAuthStore((s) => s.user); const openLoginModal = useAuthStore((s) => s.openLoginModal); + const logout = useAuthStore((s) => s.logout); + + const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); + const [isWithdrawing, setIsWithdrawing] = useState(false); + + if (!user) { + return } onClick={openLoginModal} />; + } + + const handleWithdraw = async () => { + setIsWithdrawing(true); + try { + await apiClient.delete(`/users/${user.id}`); + logout(); + setIsWithdrawModalOpen(false); + showToast.success('회원 탈퇴가 완료되었습니다.'); + } catch { + showToast.error('회원 탈퇴에 실패했습니다.', '잠시 후 다시 시도해주세요.'); + } finally { + setIsWithdrawing(false); + } + }; + + return ( + <> + + {user.name ?? '사용자'} + {user.profileImage ? ( + 프로필 + ) : ( +
+ )} + + } + items={[ + { + id: 'logout', + label: ( + + 로그아웃 + + + ), + onClick: logout, + variant: 'danger', + }, + { + id: 'withdraw', + label: '회원 탈퇴', + onClick: () => setIsWithdrawModalOpen(true), + variant: 'danger', + }, + ]} + /> - return } onClick={openLoginModal} />; + setIsWithdrawModalOpen(false)} + title="회원 탈퇴" + size="sm" + closeOnBackdropClick={!isWithdrawing} + closeOnEscape={!isWithdrawing} + > +

+ 탈퇴하면 모든 데이터가 삭제되며 복구할 수 없습니다. +
+ 정말 탈퇴하시겠습니까? +

+
+ + +
+
+ + ); } diff --git a/src/hooks/queries/usePresentations.ts b/src/hooks/queries/usePresentations.ts index f5fe9b2e..8f0a9551 100644 --- a/src/hooks/queries/usePresentations.ts +++ b/src/hooks/queries/usePresentations.ts @@ -27,10 +27,14 @@ export function usePresentations(options?: { enabled?: boolean }) { /** * 프로젝트 목록 조회 (필터/검색/정렬 지원) */ -export function usePresentationsWithFilters(params: GetPresentationsRequestDto) { +export function usePresentationsWithFilters( + params: GetPresentationsRequestDto, + options?: { enabled?: boolean }, +) { return useQuery({ queryKey: queryKeys.presentations.list(params), queryFn: () => getPresentations(params), + enabled: options?.enabled ?? true, }); } @@ -67,6 +71,26 @@ export function useUpdatePresentation() { ? { ...old, title: updatePresentation.title, updatedAt: updatePresentation.updatedAt } : old, ); + // 목록 캐시는 즉시 업데이트 (화면 전환 시 반영 지연 방지) + queryClient.setQueriesData( + { queryKey: queryKeys.presentations.lists() }, + (oldData) => { + if (!oldData) return oldData; + const nextPresentations = oldData.presentations.map((item) => + item.projectId === updatePresentation.projectId + ? { + ...item, + title: updatePresentation.title, + updatedAt: updatePresentation.updatedAt, + } + : item, + ); + return { + ...oldData, + presentations: nextPresentations, + }; + }, + ); // 목록은 최신 데이터 반영을 위해 무효화 void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() }); }, diff --git a/src/hooks/queries/useSlides.ts b/src/hooks/queries/useSlides.ts index f97ed90c..6af1a030 100644 --- a/src/hooks/queries/useSlides.ts +++ b/src/hooks/queries/useSlides.ts @@ -2,6 +2,7 @@ * 슬라이드 관련 TanStack Query 훅 */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; import type { UpdateSlideTitleRequestDto } from '@/api/dto'; import { getSlides, updateSlide } from '@/api/endpoints/slides'; @@ -17,9 +18,16 @@ export function useSlides(projectId: string) { queryKey: queryKeys.slides.list(projectId), queryFn: () => getSlides(projectId), enabled: !!projectId, + retry: false, // 🔄 서버가 웹소켓 브로드캐스트를 안하므로 임시로 폴링 추가 // TODO: 서버에서 broadcastNewComment 호출 후 제거 - refetchInterval: 3000, // 3초마다 자동 갱신 + refetchInterval: (query) => { + const error = query.state.error; + if (isAxiosError(error) && error.response?.status === 401) { + return false; + } + return 3000; + }, // 3초마다 자동 갱신 (401이면 중단) refetchIntervalInBackground: false, // 탭이 백그라운드일 때는 멈춤 }); } diff --git a/src/hooks/useAutoSaveScript.ts b/src/hooks/useAutoSaveScript.ts index 19df2dfe..db1dc38f 100644 --- a/src/hooks/useAutoSaveScript.ts +++ b/src/hooks/useAutoSaveScript.ts @@ -36,7 +36,7 @@ export function useAutoSaveScript() { try { await mutateAsync({ slideId, data: { script } }); lastSavedRef.current = script; - showToast.success('저장 완료'); + showToast.success('저장 완료', '대본이 자동으로 저장되었습니다.'); } catch { showToast.error('저장 실패', '다시 시도해주세요.'); } diff --git a/src/hooks/useFeedbackWebSocket.ts b/src/hooks/useFeedbackWebSocket.ts index f0e60118..f9796b8a 100644 --- a/src/hooks/useFeedbackWebSocket.ts +++ b/src/hooks/useFeedbackWebSocket.ts @@ -4,11 +4,12 @@ * * 실시간 댓글/리액션 이벤트를 수신하여 Zustand Store를 직접 업데이트합니다. */ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/api/queryClient'; +import { useAuthStore } from '@/stores/authStore.ts'; import { useVideoFeedbackStore } from '@/stores/videoFeedbackStore'; import type { CommentDeletedPayload, @@ -31,6 +32,7 @@ interface UseFeedbackWebSocketOptions { export function useFeedbackWebSocket({ projectId, enabled = true }: UseFeedbackWebSocketOptions) { const queryClient = useQueryClient(); + const didNotifyConnectRef = useRef(false); // ========== 이벤트 핸들러 ========== @@ -38,8 +40,11 @@ export function useFeedbackWebSocket({ projectId, enabled = true }: UseFeedbackW (data: NewCommentPayload) => { // console.log('[Feedback WebSocket] New comment:', data); - // 새 댓글 알림 - showToast.info('새 댓글', '누군가 댓글을 작성했습니다.'); + // 본인 댓글이면 토스트 생략 + const currentUserId = useAuthStore.getState().user?.id; + if (currentUserId && data.userId === currentUserId) { + return; + } // WebSocket 페이로드에서 직접 Store 업데이트 const currentVideo = useVideoFeedbackStore.getState().video; @@ -67,10 +72,7 @@ export function useFeedbackWebSocket({ projectId, enabled = true }: UseFeedbackW const handleCommentDeleted = useCallback( (data: CommentDeletedPayload) => { - // console.log('[Feedback WebSocket] Comment deleted:', data); - - // 댓글 삭제 알림 - showToast.info('댓글 삭제됨', '댓글이 삭제되었습니다.'); + console.log('[Feedback WebSocket] Comment deleted:', data); // WebSocket 페이로드에서 직접 Store 업데이트 if (data.commentId) { @@ -89,10 +91,12 @@ export function useFeedbackWebSocket({ projectId, enabled = true }: UseFeedbackW const handleNewReaction = useCallback( (data: NewReactionPayload) => { - // console.log('[Feedback WebSocket] New reaction:', data); + console.log('[Feedback WebSocket] New reaction:', data); - // 리액션 추가 애니메이션 표시 - showToast.success('👍', '누군가 반응했습니다!'); + const currentUserId = useAuthStore.getState().user?.id; + if (currentUserId && data.userId === currentUserId) { + return; + } // TanStack Query 캐시 무효화 - 비디오별 리액션 목록 갱신 if (data.videoId) { @@ -135,16 +139,14 @@ export function useFeedbackWebSocket({ projectId, enabled = true }: UseFeedbackW ); const handleConnect = useCallback(() => { - // console.log(`[Feedback WebSocket] Connected to project: ${projectId}`); - showToast.success('실시간 연결됨', '피드백이 실시간으로 동기화됩니다.'); - - // 연결 성공 시 테스트 이벤트 전송 (디버깅용) - // console.log('🧪 [Test] Sending test message to server...'); + if (didNotifyConnectRef.current) return; + didNotifyConnectRef.current = true; + showToast.success('실시간 연결 완료'); }, []); const handleDisconnect = useCallback(() => { // console.log('[Feedback WebSocket] Disconnected'); - showToast.warning('연결 끊김', '재연결 중입니다...'); + // 연결 끊김 알림은 토스트 대신 조용히 처리 }, []); // ========== 웹소켓 연결 ========== diff --git a/src/hooks/useSlideCommentsActions.ts b/src/hooks/useSlideCommentsActions.ts index cc2369b2..8619a0a2 100644 --- a/src/hooks/useSlideCommentsActions.ts +++ b/src/hooks/useSlideCommentsActions.ts @@ -93,7 +93,13 @@ export function useSlideCommentsActions() { const comments = useMemo(() => { if (!flatComments) return EMPTY_COMMENTS; - return flatToTree(flatComments); + const sorted = [...flatComments].sort((a, b) => { + const at = Date.parse(a.createdAt); + const bt = Date.parse(b.createdAt); + if (Number.isNaN(at) || Number.isNaN(bt)) return 0; + return at - bt; // 시간순(오래된 -> 최신) + }); + return flatToTree(sorted); }, [flatComments]); const addComment = (content: string, currentSlideIndex: number) => { @@ -157,6 +163,7 @@ export function useSlideCommentsActions() { // 서버에 저장되지 않은 댓글은 로컬에서만 삭제 if (!targetServerId) { deleteCommentStore(commentId); + showToast.success('댓글이 삭제되었습니다.'); return; } @@ -170,6 +177,7 @@ export function useSlideCommentsActions() { queryClient.invalidateQueries({ queryKey: queryKeys.comments.list(targetSlideId), }); + showToast.success('댓글이 삭제되었습니다.'); }, onError: () => { setComments(previousComments); diff --git a/src/hooks/useVideoComments.ts b/src/hooks/useVideoComments.ts index 2ba6a4d9..72f4ff06 100644 --- a/src/hooks/useVideoComments.ts +++ b/src/hooks/useVideoComments.ts @@ -147,10 +147,6 @@ export function useVideoComments() { deleteCommentStore(commentId); try { - if (targetComment.serverId) { - throw new Error('Invalid comment server ID'); - } - await deleteVideoComment(targetComment.serverId); showToast.success('댓글이 삭제되었습니다.'); } catch { diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 3ba424c3..48cc1af0 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -9,6 +9,7 @@ import { usePresentations, usePresentationsWithFilters } from '@/hooks/queries/u import { useDebounce } from '@/hooks/useDebounce'; import { useHomeFilter, useHomeQuery, useHomeSort } from '@/hooks/useHomeSelectors'; import { useUploadFile } from '@/hooks/useUploadFile'; +import { useAuthStore } from '@/stores/authStore'; import { showToast } from '@/utils/toast'; const ACCEPTED_FILES_TYPES = '.pptx,.pdf'; @@ -19,6 +20,8 @@ export default function HomePage() { //const navigate = useNavigate(); const queryClient = useQueryClient(); const { uploadFile, cancelUpload, isUploading, progress, error } = useUploadFile(); + const accessToken = useAuthStore((state) => state.accessToken); + const isLoggedIn = Boolean(accessToken); const onFileSelected = async (file: File) => { const response = await uploadFile({ file, title: file.name }); @@ -66,11 +69,13 @@ export default function HomePage() { [debouncedQuery, maxDuration], ); const { data: baseData, isLoading: isBaseLoading } = usePresentations({ - enabled: needsBaseTotal, + enabled: isLoggedIn && needsBaseTotal, + }); + const { data: filteredData, isLoading: isFilteredLoading } = usePresentationsWithFilters(params, { + enabled: isLoggedIn, }); - const { data: filteredData, isLoading: isFilteredLoading } = usePresentationsWithFilters(params); - const isLoading = (needsBaseTotal ? isBaseLoading : false) || isFilteredLoading; + const isLoading = isLoggedIn && (isBaseLoading || isFilteredLoading); const presentations = filteredData?.presentations ?? []; const totalCount = needsBaseTotal ? (baseData?.total ?? 0) : (filteredData?.total ?? 0); const filteredCount = filteredData?.total ?? 0; diff --git a/src/types/api.ts b/src/types/api.ts index cd69d8e2..91a329c9 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -41,7 +41,7 @@ export interface PaginatedData { /** * 변환 상태 */ -export type ConversionStatus = 'processing' | 'completed' | 'failed'; +export type ConversionStatus = 'queued' | 'processing' | 'completed' | 'partial_done' | 'failed'; /** * 변환 진행 상황