From f6cbd5dfaa9859d06f785bf7aa3148207e5eb997 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 10:33:03 +0900 Subject: [PATCH 01/19] =?UTF-8?q?fix:=20DOM=20=EC=A4=91=EC=B2=A9=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=ED=95=B4=EA=B2=B0=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/FileDropzone.tsx | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) 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)} /> -
)} - + ); } From 16ee9a8ef34b3ae7d23b19f97b04a9610966237f Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 13:27:26 +0900 Subject: [PATCH 02/19] =?UTF-8?q?fix:=20401=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=9C=EB=A0=A5=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/errorHandler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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('로그인이 만료되었습니다.', '다시 로그인해주세요.'); }, From a53d1aa331b05e35abdd3637fedcd150c4e708bf Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 13:30:38 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=84=20=ED=99=94=EB=A9=B4=20=EA=B0=B1=EC=8B=A0=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 ( <> From 7ad9eac1ddc36aa4c9b5debff98321fbeca9992b Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 13:39:51 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8=20=ED=9B=84=EC=97=90=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20=EB=A0=8C=EB=8D=94=20(#1?= =?UTF-8?q?50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/usePresentations.ts | 6 +++++- src/pages/HomePage.tsx | 11 ++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/hooks/queries/usePresentations.ts b/src/hooks/queries/usePresentations.ts index f5fe9b2e..c2b60338 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, }); } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 3ba424c3..bc2d80c6 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 && ((needsBaseTotal ? isBaseLoading : false) || isFilteredLoading); const presentations = filteredData?.presentations ?? []; const totalCount = needsBaseTotal ? (baseData?.total ?? 0) : (filteredData?.total ?? 0); const filteredCount = filteredData?.total ?? 0; From 8afdd63c68afb269b0f8c765cc33a2055cc2e670 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 13:45:45 +0900 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20CSP=20=EB=84=90=EB=84=90=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EC=84=A4=EC=A0=95=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firebase.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.json b/firebase.json index 24fd599a..19ae5786 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' 'unsafe-inline' 'unsafe-eval' 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", From 5dc243db113af9937f845344208153041bd749fd Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 13:53:28 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=9B=84=20=EC=BA=90=EC=8B=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/usePresentations.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/hooks/queries/usePresentations.ts b/src/hooks/queries/usePresentations.ts index c2b60338..8f0a9551 100644 --- a/src/hooks/queries/usePresentations.ts +++ b/src/hooks/queries/usePresentations.ts @@ -71,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() }); }, From 866926bebe1dbc7c4d499be27080e3f7fd770219 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 13:55:04 +0900 Subject: [PATCH 07/19] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/HomePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index bc2d80c6..48cc1af0 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -75,7 +75,7 @@ export default function HomePage() { enabled: isLoggedIn, }); - const isLoading = isLoggedIn && ((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; From 760db521c9d66abdd68fa2efa9b0c26711fce00b Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 14:33:15 +0900 Subject: [PATCH 08/19] =?UTF-8?q?fix:=20=EB=B3=80=ED=99=98=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'; /** * 변환 진행 상황 From f149eec8b826218ab834adb2688baef47cc6ec0d Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 14:33:34 +0900 Subject: [PATCH 09/19] =?UTF-8?q?design:=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=86=A4=20=EC=82=AC=EC=9A=A9=EC=84=B1=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/PresentationCard.tsx | 44 +++++++++++++++---- .../presentation/PresentationList.tsx | 43 +++++++++++++++--- src/pages/HomePage.tsx | 29 ++++++++++++ src/pages/SlidePage.tsx | 22 +++++++++- 4 files changed, 121 insertions(+), 17 deletions(-) diff --git a/src/components/presentation/PresentationCard.tsx b/src/components/presentation/PresentationCard.tsx index 03668316..d5bd5fa8 100644 --- a/src/components/presentation/PresentationCard.tsx +++ b/src/components/presentation/PresentationCard.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import clsx from 'clsx'; @@ -14,7 +15,7 @@ import type { Presentation } from '@/types/presentation'; import type { VideoPresentation } from '@/types/video'; import { formatRelativeTime } from '@/utils/format'; -import { Dropdown } from '../common'; +import { Dropdown, Skeleton } from '../common'; import type { DropdownItem } from '../common/Dropdown'; import { HighlightText } from '../common/HighlightText'; import DeletePresentationModal from './DeletePresentationModal'; @@ -87,6 +88,7 @@ function PresentationCard(props: Props) { } = props; const navigate = useNavigate(); + const [isThumbLoaded, setIsThumbLoaded] = useState(false); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = usePresentationDeletion(projectId); @@ -132,13 +134,39 @@ function PresentationCard(props: Props) { onClick={handleCardClick} className="rounded-2xl border-none bg-white transition-shadow cursor-pointer hover:shadow-lg" > -
- {thumbnailUrl && ( - {`${displayTitle}`} +
+ {thumbnailUrl ? ( + <> + {!isThumbLoaded && ( +
+ +
+ )} + {`${displayTitle}`} setIsThumbLoaded(true)} + onError={() => setIsThumbLoaded(false)} + /> + + ) : ( +
+ +
)}
diff --git a/src/components/presentation/PresentationList.tsx b/src/components/presentation/PresentationList.tsx index 20d43ca7..e5787270 100644 --- a/src/components/presentation/PresentationList.tsx +++ b/src/components/presentation/PresentationList.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import clsx from 'clsx'; @@ -15,6 +16,7 @@ import type { Presentation } from '@/types/presentation'; import type { VideoPresentation } from '@/types/video'; import { formatRelativeTime } from '@/utils/format'; +import { Skeleton } from '../common'; import { Dropdown, type DropdownItem } from '../common/Dropdown'; import DeletePresentationModal from './DeletePresentationModal'; import RenamePresentationModal from './RenamePresentationModal'; @@ -88,6 +90,7 @@ function PresentationList(props: Props) { } = props; const navigate = useNavigate(); + const [isThumbLoaded, setIsThumbLoaded] = useState(false); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = usePresentationDeletion(projectId); @@ -135,13 +138,39 @@ function PresentationList(props: Props) { className="flex w-full items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 transition-shadow cursor-pointer hover:shadow-lg" > {/* 썸네일 */} -
- {thumbnailUrl && ( - {`${displayTitle}`} +
+ {thumbnailUrl ? ( + <> + {!isThumbLoaded && ( +
+ +
+ )} + {`${displayTitle}`} setIsThumbLoaded(true)} + onError={() => setIsThumbLoaded(false)} + /> + + ) : ( +
+ +
)}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 48cc1af0..9f3ecd73 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/api'; +import { getConversionStatus } from '@/api/endpoints/presentations'; import IntroSection from '@/components/home/IntroSection'; import PresentationsSection from '@/components/home/PresentationsSection'; import { usePresentations, usePresentationsWithFilters } from '@/hooks/queries/usePresentations'; @@ -22,6 +23,33 @@ export default function HomePage() { const { uploadFile, cancelUpload, isUploading, progress, error } = useUploadFile(); const accessToken = useAuthStore((state) => state.accessToken); const isLoggedIn = Boolean(accessToken); + const pollThumbnail = async (projectId: string) => { + const MAX_ATTEMPTS = 30; + const DELAY_MS = 2000; + + for (let i = 0; i < MAX_ATTEMPTS; i += 1) { + try { + const status = await getConversionStatus(projectId); + const thumbDone = + status?.progress?.thumbnail === 'completed' || + status?.status === 'completed' || + status?.status === 'partial_done'; + + if (thumbDone) { + void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() }); + return; + } + + if (status?.status === 'failed') { + return; + } + } catch { + // 네트워크 일시 오류는 다음 폴링에서 재시도 + } + + await new Promise((resolve) => setTimeout(resolve, DELAY_MS)); + } + }; const onFileSelected = async (file: File) => { const response = await uploadFile({ file, title: file.name }); @@ -29,6 +57,7 @@ export default function HomePage() { if (response?.resultType === 'SUCCESS') { showToast.success('업로드 완료!'); void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() }); + void pollThumbnail(response.success.projectId); //const projectId = response.success.projectId; //navigate(`/presentations/${projectId}`); } diff --git a/src/pages/SlidePage.tsx b/src/pages/SlidePage.tsx index 8676f8da..995d6809 100644 --- a/src/pages/SlidePage.tsx +++ b/src/pages/SlidePage.tsx @@ -1,6 +1,10 @@ import { useEffect } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; + +import { getConversionStatus } from '@/api/endpoints/presentations'; +import { queryKeys } from '@/api/queryClient'; import { SlideList, SlideWorkspace } from '@/components/slide'; import { setLastSlideId } from '@/constants/navigation'; import { useSlides } from '@/hooks/queries/useSlides'; @@ -11,10 +15,20 @@ export default function SlidePage() { const [searchParams, setSearchParams] = useSearchParams(); const { data: slides, isLoading, isError } = useSlides(projectId ?? ''); + const { data: conversionStatus } = useQuery({ + queryKey: queryKeys.presentations.detail(`${projectId ?? ''}:status`), + queryFn: () => getConversionStatus(projectId ?? ''), + enabled: !!projectId, + refetchInterval: 2000, + }); const slideIdParam = searchParams.get('slideId'); const currentSlide = slides?.find((s) => s.slideId === slideIdParam) ?? slides?.[0]; + const isConverting = + conversionStatus?.status === 'queued' || conversionStatus?.status === 'processing'; + const showSkeleton = isLoading || (!slides?.length && isConverting); + /** * 슬라이드 로드 에러 처리 */ @@ -52,10 +66,14 @@ export default function SlidePage() { return (
- +
- +
From 6a2db395b952846e47929d0a52e2e235b5891496 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 16:06:12 +0900 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20401=20=EC=8B=9C=EC=97=90=EB=8F=84?= =?UTF-8?q?=20=EC=9E=AC=EC=8B=9C=EB=8F=84=ED=95=98=EB=8A=94=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/useSlides.ts | 10 +++++++++- src/pages/SlidePage.tsx | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) 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/pages/SlidePage.tsx b/src/pages/SlidePage.tsx index 995d6809..da9fd79b 100644 --- a/src/pages/SlidePage.tsx +++ b/src/pages/SlidePage.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; import { getConversionStatus } from '@/api/endpoints/presentations'; import { queryKeys } from '@/api/queryClient'; @@ -19,7 +20,14 @@ export default function SlidePage() { queryKey: queryKeys.presentations.detail(`${projectId ?? ''}:status`), queryFn: () => getConversionStatus(projectId ?? ''), enabled: !!projectId, - refetchInterval: 2000, + retry: false, + refetchInterval: (query) => { + const error = query.state.error; + if (isAxiosError(error) && error.response?.status === 401) { + return false; + } + return 2000; + }, }); const slideIdParam = searchParams.get('slideId'); From d07bffc05f8a90797dadd415d22f6be92ff4e71c Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 16:20:06 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80/=EB=A6=AC?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=ED=86=A0=EA=B8=80=20=EC=8B=9C=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFeedbackWebSocket.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/hooks/useFeedbackWebSocket.ts b/src/hooks/useFeedbackWebSocket.ts index f0e60118..5ff14af3 100644 --- a/src/hooks/useFeedbackWebSocket.ts +++ b/src/hooks/useFeedbackWebSocket.ts @@ -9,6 +9,7 @@ import { useCallback } 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, @@ -16,7 +17,6 @@ import type { NewReactionPayload, ReactionCountUpdatedPayload, } from '@/types/websocket'; -import { showToast } from '@/utils/toast'; import { useWebSocket } from './useWebSocket'; @@ -38,8 +38,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 +70,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 +89,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) { @@ -136,15 +138,13 @@ 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...'); }, []); const handleDisconnect = useCallback(() => { // console.log('[Feedback WebSocket] Disconnected'); - showToast.warning('연결 끊김', '재연결 중입니다...'); + // 연결 끊김 알림은 토스트 대신 조용히 처리 }, []); // ========== 웹소켓 연결 ========== From 7b8a5f757880d6fdd7de6da15ed8fb5712acb8a2 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 16:23:47 +0900 Subject: [PATCH 12/19] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=EC=88=9C=20=EC=A0=95=EB=A0=AC=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useSlideCommentsActions.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSlideCommentsActions.ts b/src/hooks/useSlideCommentsActions.ts index cc2369b2..12db7e97 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) => { From 451f9d73f3839b57661ea0fca954f411f2801daa Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 16:40:14 +0900 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#1?= =?UTF-8?q?47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFeedbackWebSocket.ts | 10 ++++++---- src/hooks/useSlideCommentsActions.ts | 2 ++ src/hooks/useVideoComments.ts | 4 ---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/hooks/useFeedbackWebSocket.ts b/src/hooks/useFeedbackWebSocket.ts index 5ff14af3..f9796b8a 100644 --- a/src/hooks/useFeedbackWebSocket.ts +++ b/src/hooks/useFeedbackWebSocket.ts @@ -4,7 +4,7 @@ * * 실시간 댓글/리액션 이벤트를 수신하여 Zustand Store를 직접 업데이트합니다. */ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; @@ -17,6 +17,7 @@ import type { NewReactionPayload, ReactionCountUpdatedPayload, } from '@/types/websocket'; +import { showToast } from '@/utils/toast'; import { useWebSocket } from './useWebSocket'; @@ -31,6 +32,7 @@ interface UseFeedbackWebSocketOptions { export function useFeedbackWebSocket({ projectId, enabled = true }: UseFeedbackWebSocketOptions) { const queryClient = useQueryClient(); + const didNotifyConnectRef = useRef(false); // ========== 이벤트 핸들러 ========== @@ -137,9 +139,9 @@ export function useFeedbackWebSocket({ projectId, enabled = true }: UseFeedbackW ); const handleConnect = useCallback(() => { - // console.log(`[Feedback WebSocket] Connected to project: ${projectId}`); - // 연결 성공 시 테스트 이벤트 전송 (디버깅용) - // console.log('🧪 [Test] Sending test message to server...'); + if (didNotifyConnectRef.current) return; + didNotifyConnectRef.current = true; + showToast.success('실시간 연결 완료'); }, []); const handleDisconnect = useCallback(() => { diff --git a/src/hooks/useSlideCommentsActions.ts b/src/hooks/useSlideCommentsActions.ts index 12db7e97..8619a0a2 100644 --- a/src/hooks/useSlideCommentsActions.ts +++ b/src/hooks/useSlideCommentsActions.ts @@ -163,6 +163,7 @@ export function useSlideCommentsActions() { // 서버에 저장되지 않은 댓글은 로컬에서만 삭제 if (!targetServerId) { deleteCommentStore(commentId); + showToast.success('댓글이 삭제되었습니다.'); return; } @@ -176,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 { From d38fb3b0b03fd1c365e4a62e2c7e848eaff7be4a Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 17:00:38 +0900 Subject: [PATCH 14/19] =?UTF-8?q?design:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8C=85=20=EC=88=98=EC=A0=95=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useAutoSaveScript.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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('저장 실패', '다시 시도해주세요.'); } From 0622ca0b91065152b9f41685e3d7f801ad79d60b Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 19:08:18 +0900 Subject: [PATCH 15/19] =?UTF-8?q?chore:=20revert=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4=20=EC=82=AC=EC=9A=A9=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/PresentationCard.tsx | 44 ++++--------------- .../presentation/PresentationList.tsx | 43 +++--------------- src/pages/HomePage.tsx | 29 ------------ src/pages/SlidePage.tsx | 35 ++++----------- 4 files changed, 23 insertions(+), 128 deletions(-) diff --git a/src/components/presentation/PresentationCard.tsx b/src/components/presentation/PresentationCard.tsx index d5bd5fa8..03668316 100644 --- a/src/components/presentation/PresentationCard.tsx +++ b/src/components/presentation/PresentationCard.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import clsx from 'clsx'; @@ -15,7 +14,7 @@ import type { Presentation } from '@/types/presentation'; import type { VideoPresentation } from '@/types/video'; import { formatRelativeTime } from '@/utils/format'; -import { Dropdown, Skeleton } from '../common'; +import { Dropdown } from '../common'; import type { DropdownItem } from '../common/Dropdown'; import { HighlightText } from '../common/HighlightText'; import DeletePresentationModal from './DeletePresentationModal'; @@ -88,7 +87,6 @@ function PresentationCard(props: Props) { } = props; const navigate = useNavigate(); - const [isThumbLoaded, setIsThumbLoaded] = useState(false); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = usePresentationDeletion(projectId); @@ -134,39 +132,13 @@ function PresentationCard(props: Props) { onClick={handleCardClick} className="rounded-2xl border-none bg-white transition-shadow cursor-pointer hover:shadow-lg" > -
- {thumbnailUrl ? ( - <> - {!isThumbLoaded && ( -
- -
- )} - {`${displayTitle}`} setIsThumbLoaded(true)} - onError={() => setIsThumbLoaded(false)} - /> - - ) : ( -
- -
+
+ {thumbnailUrl && ( + {`${displayTitle}`} )}
diff --git a/src/components/presentation/PresentationList.tsx b/src/components/presentation/PresentationList.tsx index e5787270..20d43ca7 100644 --- a/src/components/presentation/PresentationList.tsx +++ b/src/components/presentation/PresentationList.tsx @@ -1,4 +1,3 @@ -import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import clsx from 'clsx'; @@ -16,7 +15,6 @@ import type { Presentation } from '@/types/presentation'; import type { VideoPresentation } from '@/types/video'; import { formatRelativeTime } from '@/utils/format'; -import { Skeleton } from '../common'; import { Dropdown, type DropdownItem } from '../common/Dropdown'; import DeletePresentationModal from './DeletePresentationModal'; import RenamePresentationModal from './RenamePresentationModal'; @@ -90,7 +88,6 @@ function PresentationList(props: Props) { } = props; const navigate = useNavigate(); - const [isThumbLoaded, setIsThumbLoaded] = useState(false); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = usePresentationDeletion(projectId); @@ -138,39 +135,13 @@ function PresentationList(props: Props) { className="flex w-full items-center justify-between bg-white px-5 py-4 rounded-2xl border border-gray-200 transition-shadow cursor-pointer hover:shadow-lg" > {/* 썸네일 */} -
- {thumbnailUrl ? ( - <> - {!isThumbLoaded && ( -
- -
- )} - {`${displayTitle}`} setIsThumbLoaded(true)} - onError={() => setIsThumbLoaded(false)} - /> - - ) : ( -
- -
+
+ {thumbnailUrl && ( + {`${displayTitle}`} )}
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 9f3ecd73..48cc1af0 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -3,7 +3,6 @@ import { useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/api'; -import { getConversionStatus } from '@/api/endpoints/presentations'; import IntroSection from '@/components/home/IntroSection'; import PresentationsSection from '@/components/home/PresentationsSection'; import { usePresentations, usePresentationsWithFilters } from '@/hooks/queries/usePresentations'; @@ -23,33 +22,6 @@ export default function HomePage() { const { uploadFile, cancelUpload, isUploading, progress, error } = useUploadFile(); const accessToken = useAuthStore((state) => state.accessToken); const isLoggedIn = Boolean(accessToken); - const pollThumbnail = async (projectId: string) => { - const MAX_ATTEMPTS = 30; - const DELAY_MS = 2000; - - for (let i = 0; i < MAX_ATTEMPTS; i += 1) { - try { - const status = await getConversionStatus(projectId); - const thumbDone = - status?.progress?.thumbnail === 'completed' || - status?.status === 'completed' || - status?.status === 'partial_done'; - - if (thumbDone) { - void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() }); - return; - } - - if (status?.status === 'failed') { - return; - } - } catch { - // 네트워크 일시 오류는 다음 폴링에서 재시도 - } - - await new Promise((resolve) => setTimeout(resolve, DELAY_MS)); - } - }; const onFileSelected = async (file: File) => { const response = await uploadFile({ file, title: file.name }); @@ -57,7 +29,6 @@ export default function HomePage() { if (response?.resultType === 'SUCCESS') { showToast.success('업로드 완료!'); void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() }); - void pollThumbnail(response.success.projectId); //const projectId = response.success.projectId; //navigate(`/presentations/${projectId}`); } diff --git a/src/pages/SlidePage.tsx b/src/pages/SlidePage.tsx index da9fd79b..eca84f54 100644 --- a/src/pages/SlidePage.tsx +++ b/src/pages/SlidePage.tsx @@ -1,14 +1,10 @@ import { useEffect } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { isAxiosError } from 'axios'; - -import { getConversionStatus } from '@/api/endpoints/presentations'; -import { queryKeys } from '@/api/queryClient'; import { SlideList, SlideWorkspace } from '@/components/slide'; import { setLastSlideId } from '@/constants/navigation'; import { useSlides } from '@/hooks/queries/useSlides'; +import { useSlideWebSocket } from '@/hooks/useSlideWebSocket'; import { showToast } from '@/utils/toast'; export default function SlidePage() { @@ -16,26 +12,15 @@ export default function SlidePage() { const [searchParams, setSearchParams] = useSearchParams(); const { data: slides, isLoading, isError } = useSlides(projectId ?? ''); - const { data: conversionStatus } = useQuery({ - queryKey: queryKeys.presentations.detail(`${projectId ?? ''}:status`), - queryFn: () => getConversionStatus(projectId ?? ''), - enabled: !!projectId, - retry: false, - refetchInterval: (query) => { - const error = query.state.error; - if (isAxiosError(error) && error.response?.status === 401) { - return false; - } - return 2000; - }, - }); const slideIdParam = searchParams.get('slideId'); const currentSlide = slides?.find((s) => s.slideId === slideIdParam) ?? slides?.[0]; - const isConverting = - conversionStatus?.status === 'queued' || conversionStatus?.status === 'processing'; - const showSkeleton = isLoading || (!slides?.length && isConverting); + useSlideWebSocket({ + projectId: projectId ?? '', + slideId: currentSlide?.slideId, + enabled: !!projectId, + }); /** * 슬라이드 로드 에러 처리 @@ -74,14 +59,10 @@ export default function SlidePage() { return (
- +
- +
From 22cf5029b86ef325dab318e23f399b591aba3ce5 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 19:12:37 +0900 Subject: [PATCH 16/19] =?UTF-8?q?chore:=20=EC=8A=AC=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=9B=B9=EC=86=8C=EC=BC=93=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SlidePage.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/pages/SlidePage.tsx b/src/pages/SlidePage.tsx index eca84f54..8676f8da 100644 --- a/src/pages/SlidePage.tsx +++ b/src/pages/SlidePage.tsx @@ -4,7 +4,6 @@ import { useParams, useSearchParams } from 'react-router-dom'; import { SlideList, SlideWorkspace } from '@/components/slide'; import { setLastSlideId } from '@/constants/navigation'; import { useSlides } from '@/hooks/queries/useSlides'; -import { useSlideWebSocket } from '@/hooks/useSlideWebSocket'; import { showToast } from '@/utils/toast'; export default function SlidePage() { @@ -16,12 +15,6 @@ export default function SlidePage() { const slideIdParam = searchParams.get('slideId'); const currentSlide = slides?.find((s) => s.slideId === slideIdParam) ?? slides?.[0]; - useSlideWebSocket({ - projectId: projectId ?? '', - slideId: currentSlide?.slideId, - enabled: !!projectId, - }); - /** * 슬라이드 로드 에러 처리 */ From d55dc0341a212aa94b409a19a1fd2223c4c7449f Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 19:15:48 +0900 Subject: [PATCH 17/19] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/icon-logout.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 @@ - - - + + + From 34d8a75ab3a3b1a32afbb3a3f6f103baaf58e494 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 19:16:10 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20UI=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/layout/LoginButton.tsx | 114 ++++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) 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} + > +

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

+
+ + +
+
+ + ); } From ace9a334818623caabe44e768947c136ab467078 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 10 Feb 2026 19:24:59 +0900 Subject: [PATCH 19/19] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firebase.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase.json b/firebase.json index 19ae5786..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' 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'" + "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",