From 76599cf8b8e52db234e0d7d923791b232eb8912e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A1=9C=EB=A1=9C?= Date: Sun, 22 Feb 2026 12:06:58 +0900 Subject: [PATCH 01/16] =?UTF-8?q?refactor:=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=ED=82=A4=EC=99=80=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?prefetch=20=EC=A0=95=EB=A6=AC=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/queryClient.test.ts | 16 ++++ src/api/queryClient.ts | 13 +++- src/components/common/layout/Gnb.tsx | 3 + src/hooks/queries/useProjectEntryPrefetch.ts | 36 +++++++++ src/hooks/usePresentationVideos.ts | 14 +++- src/pages/VideoListPage.tsx | 81 ++++++++++++++++++-- src/pages/VideoRecordPage.tsx | 5 +- 7 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 src/hooks/queries/useProjectEntryPrefetch.ts diff --git a/src/api/queryClient.test.ts b/src/api/queryClient.test.ts index 642810eb..821a1ec9 100644 --- a/src/api/queryClient.test.ts +++ b/src/api/queryClient.test.ts @@ -60,6 +60,22 @@ describe('queryKeys', () => { }); }); + describe('videos', () => { + it('builds list prefix key with projectId', () => { + expect(queryKeys.videos.listPrefix('p1')).toEqual(['videos', 'list', 'p1']); + }); + + it('builds list key with params', () => { + expect( + queryKeys.videos.list('p1', { search: 'abc', filter: 'ready', sort: 'recent' }), + ).toEqual(['videos', 'list', 'p1', { search: 'abc', filter: 'ready', sort: 'recent' }]); + }); + + it('builds list key without params', () => { + expect(queryKeys.videos.list('p1')).toEqual(['videos', 'list', 'p1', {}]); + }); + }); + describe('shares', () => { it('builds content key with sessionId', () => { expect(queryKeys.shares.content('token1', 'sess1')).toEqual([ diff --git a/src/api/queryClient.ts b/src/api/queryClient.ts index 7b4d6a1f..5ee97187 100644 --- a/src/api/queryClient.ts +++ b/src/api/queryClient.ts @@ -69,7 +69,8 @@ export const queryKeys = { all: ['scripts'] as const, detail: (slideId: string) => [...queryKeys.scripts.all, 'detail', slideId] as const, versions: (slideId: string) => [...queryKeys.scripts.all, 'versions', slideId] as const, - project: (projectId: string) => [...queryKeys.scripts.all, 'project', projectId] as const, + projects: () => [...queryKeys.scripts.all, 'project'] as const, + project: (projectId: string) => [...queryKeys.scripts.projects(), projectId] as const, }, presentations: { all: ['presentations'] as const, @@ -87,7 +88,15 @@ export const queryKeys = { videos: { all: ['videos'] as const, lists: () => [...queryKeys.videos.all, 'list'] as const, - list: (projectId: string) => [...queryKeys.videos.lists(), projectId] as const, + listPrefix: (projectId: string) => [...queryKeys.videos.lists(), projectId] as const, + list: ( + projectId: string, + params?: { + search?: string; + filter?: string; + sort?: string; + }, + ) => [...queryKeys.videos.listPrefix(projectId), params ?? {}] as const, details: () => [...queryKeys.videos.all, 'detail'] as const, detail: (videoId: string) => [...queryKeys.videos.details(), videoId] as const, slides: (videoId: number) => [...queryKeys.videos.all, 'slides', videoId] as const, diff --git a/src/components/common/layout/Gnb.tsx b/src/components/common/layout/Gnb.tsx index 2c272241..941ab8cc 100644 --- a/src/components/common/layout/Gnb.tsx +++ b/src/components/common/layout/Gnb.tsx @@ -10,6 +10,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'; import clsx from 'clsx'; import { TABS, getTabFromPathname, getTabPath } from '@/constants/navigation'; +import { useProjectEntryPrefetch } from '@/hooks/queries/useProjectEntryPrefetch'; export function Gnb() { const { projectId } = useParams<{ projectId: string }>(); @@ -18,6 +19,8 @@ export function Gnb() { const activeIndex = TABS.findIndex((tab) => tab.key === activeTab); const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0; + useProjectEntryPrefetch(projectId); + if (!projectId) return null; return ( diff --git a/src/hooks/queries/useProjectEntryPrefetch.ts b/src/hooks/queries/useProjectEntryPrefetch.ts new file mode 100644 index 00000000..f8a28e52 --- /dev/null +++ b/src/hooks/queries/useProjectEntryPrefetch.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { getProjectScripts } from '@/api/endpoints/scripts'; +import { getSlides } from '@/api/endpoints/slides'; +import { queryKeys } from '@/api/queryClient'; + +/** + * 프로젝트 진입 시 슬라이드/대본 데이터를 한 번 미리 적재합니다. + */ +export function useProjectEntryPrefetch(projectId?: string) { + const queryClient = useQueryClient(); + const prefetchedProjectIdsRef = useRef>(new Set()); + + useEffect(() => { + if (!projectId) return; + if (prefetchedProjectIdsRef.current.has(projectId)) return; + + prefetchedProjectIdsRef.current.add(projectId); + + void Promise.all([ + queryClient.prefetchQuery({ + queryKey: queryKeys.slides.list(projectId), + queryFn: () => getSlides(projectId), + }), + queryClient.prefetchQuery({ + queryKey: queryKeys.scripts.project(projectId), + queryFn: () => getProjectScripts(projectId), + }), + ]).catch(() => { + // 실패 시 재시도 가능하도록 가드를 해제합니다. + prefetchedProjectIdsRef.current.delete(projectId); + }); + }, [projectId, queryClient]); +} diff --git a/src/hooks/usePresentationVideos.ts b/src/hooks/usePresentationVideos.ts index 44b3f6d2..107c884d 100644 --- a/src/hooks/usePresentationVideos.ts +++ b/src/hooks/usePresentationVideos.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { videosApi } from '@/api/endpoints/videos'; +import { queryKeys } from '@/api/queryClient'; import type { FilterMode, SortMode } from '@/types/home'; import type { VideoPresentation } from '@/types/video'; @@ -17,13 +18,20 @@ export function usePresentationVideos({ filter, sort, }: UsePresentationVideosParams) { + const normalizedFilter = filter && filter !== 'all' ? filter : undefined; + const normalizedSort = sort || undefined; + return useQuery({ - queryKey: ['videos', projectId, search, filter, sort], + queryKey: queryKeys.videos.list(projectId, { + search, + filter: normalizedFilter, + sort: normalizedSort, + }), queryFn: async () => { const response = await videosApi.getPresentationVideos(projectId, { search, - filter: filter && filter !== 'all' ? filter : undefined, - sort: sort || undefined, + filter: normalizedFilter, + sort: normalizedSort, }); if (response.data.resultType === 'FAILURE') { diff --git a/src/pages/VideoListPage.tsx b/src/pages/VideoListPage.tsx index 2d6736a8..bd6f2df2 100644 --- a/src/pages/VideoListPage.tsx +++ b/src/pages/VideoListPage.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { videosApi } from '@/api/endpoints/videos'; +import { queryKeys } from '@/api/queryClient'; import { CardView, ListView, Spinner } from '@/components/common'; import PresentationCard from '@/components/presentation/PresentationCard'; import PresentationHeader from '@/components/presentation/PresentationHeader'; @@ -11,17 +13,20 @@ import PresentationList from '@/components/presentation/PresentationList'; import { DeleteVideoModal, RecordingEmptySection } from '@/components/video'; import { usePresentationVideos } from '@/hooks/usePresentationVideos'; import type { FilterMode, SortMode, ViewMode } from '@/types/home'; +import type { VideoPresentation } from '@/types/video'; import { showToast } from '@/utils/toast'; const SKELETON_CARD_COUNT = 6; const SKELETON_LIST_COUNT = 4; type DeleteTarget = { id: string; title: string } | null; +type VideoListQueryData = { videos: VideoPresentation[]; total: number }; export default function VideoListPage() { const navigate = useNavigate(); const location = useLocation(); const { projectId } = useParams<{ projectId: string }>(); + const queryClient = useQueryClient(); // UI 상태 const [query, setQuery] = useState(''); @@ -72,8 +77,12 @@ export default function VideoListPage() { showToast.success('영상을 저장했습니다.'); navigate(location.pathname, { replace: true, state: {} }); - void refetch(); - }, [location.state, location.pathname, navigate, refetch]); + if (!projectId) return; + + void queryClient.invalidateQueries({ + queryKey: queryKeys.videos.listPrefix(projectId), + }); + }, [location.state, location.pathname, navigate, projectId, queryClient]); // processing 1시간 초과면 stuck 처리 (파생 데이터로 정리) const videos = useMemo(() => { @@ -181,10 +190,42 @@ export default function VideoListPage() { const handleUpdateVideoTitle = useCallback( async (videoId: string, newTitle: string) => { - await videosApi.updateVideoTitle(videoId, newTitle); - await refetch(); + if (!projectId) return; + + const updatedVideo = await videosApi.updateVideoTitle(videoId, newTitle); + queryClient.setQueriesData( + { + queryKey: queryKeys.videos.listPrefix(projectId), + }, + (oldData) => { + if (!oldData) return oldData; + + let hasUpdated = false; + const nextVideos = oldData.videos.map((video) => { + if (String(video.videoId) !== String(videoId)) return video; + hasUpdated = true; + return { + ...video, + title: updatedVideo.title, + updatedAt: updatedVideo.updatedAt, + }; + }); + + if (!hasUpdated) return oldData; + + return { + ...oldData, + videos: nextVideos, + }; + }, + ); + + void queryClient.invalidateQueries({ + queryKey: queryKeys.videos.listPrefix(projectId), + refetchType: 'inactive', + }); }, - [refetch], + [projectId, queryClient], ); const openDeleteModal = useCallback((id: string, title: string) => { @@ -199,6 +240,7 @@ export default function VideoListPage() { const handleConfirmDelete = useCallback(async () => { if (!videoToDelete) return; + if (!projectId) return; const { id } = videoToDelete; @@ -213,7 +255,32 @@ export default function VideoListPage() { } showToast.success('영상을 삭제했습니다.'); - void refetch(); + queryClient.setQueriesData( + { + queryKey: queryKeys.videos.listPrefix(projectId), + }, + (oldData) => { + if (!oldData) return oldData; + const nextVideos = oldData.videos.filter((video) => String(video.videoId) !== String(id)); + if (nextVideos.length === oldData.videos.length) return oldData; + + return { + ...oldData, + videos: nextVideos, + total: Math.max(0, oldData.total - 1), + }; + }, + ); + setPendingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + + void queryClient.invalidateQueries({ + queryKey: queryKeys.videos.listPrefix(projectId), + refetchType: 'inactive', + }); } catch (err) { showToast.error( '영상을 삭제하지 못했습니다.', @@ -227,7 +294,7 @@ export default function VideoListPage() { }); setVideoToDelete(null); } - }, [videoToDelete, closeDeleteModal, refetch]); + }, [videoToDelete, projectId, closeDeleteModal, queryClient]); const renderSkeleton = () => { if (viewMode === 'card') { diff --git a/src/pages/VideoRecordPage.tsx b/src/pages/VideoRecordPage.tsx index ea268308..01da2075 100644 --- a/src/pages/VideoRecordPage.tsx +++ b/src/pages/VideoRecordPage.tsx @@ -15,6 +15,7 @@ import { } from '@/components/video'; import { getTabPath } from '@/constants/navigation'; import { usePresentation } from '@/hooks/queries/usePresentations'; +import { useProjectEntryPrefetch } from '@/hooks/queries/useProjectEntryPrefetch'; import { useVideoUpload } from '@/hooks/useVideoUpload'; type RecordStep = 'TEST' | 'RECORDING'; @@ -24,6 +25,8 @@ export default function VideoRecordPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); + useProjectEntryPrefetch(projectId); + const { data: presentation } = usePresentation(projectId!); const [step, setStep] = useState('TEST'); @@ -102,7 +105,7 @@ export default function VideoRecordPage() { if (projectId) { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.shares.videos(projectId) }), - queryClient.invalidateQueries({ queryKey: queryKeys.videos.list(projectId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.videos.listPrefix(projectId) }), ]); } From f117e3d9c2b9f8464a8ce509de7358078758f50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A1=9C=EB=A1=9C?= Date: Sun, 22 Feb 2026 12:07:17 +0900 Subject: [PATCH 02/16] =?UTF-8?q?refactor:=20useSlides=20=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20=EC=98=B5=EC=85=98=EC=9D=84=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/useSlides.test.tsx | 76 ++++++++++++++++++++++++++++ src/hooks/queries/useSlides.ts | 37 +++++++++----- src/hooks/useInsightPageModel.ts | 2 +- src/pages/SlidePage.tsx | 9 +++- 4 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 src/hooks/queries/useSlides.test.tsx diff --git a/src/hooks/queries/useSlides.test.tsx b/src/hooks/queries/useSlides.test.tsx new file mode 100644 index 00000000..d8ef59cc --- /dev/null +++ b/src/hooks/queries/useSlides.test.tsx @@ -0,0 +1,76 @@ +import { useQuery } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSlides } from './useSlides'; + +vi.mock('@tanstack/react-query', async () => { + const actual = + await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQuery: vi.fn(), + }; +}); + +describe('useSlides', () => { + const mockedUseQuery = vi.mocked(useQuery); + + beforeEach(() => { + vi.clearAllMocks(); + mockedUseQuery.mockReturnValue({} as never); + }); + + it('기본값은 폴링이 비활성화된다', () => { + renderHook(() => useSlides('project-1')); + + const options = mockedUseQuery.mock.calls[0]?.[0] as { + enabled: boolean; + refetchInterval: number | false; + }; + + expect(options.enabled).toBe(true); + expect(options.refetchInterval).toBe(false); + }); + + it('liveSync가 true면 지정한 주기로 폴링한다', () => { + renderHook(() => useSlides('project-1', { liveSync: true, pollingIntervalMs: 15000 })); + + const options = mockedUseQuery.mock.calls[0]?.[0] as unknown as { + refetchInterval: (query: { state: { error: unknown } }) => number | false; + }; + + const getInterval = options.refetchInterval; + expect(getInterval({ state: { error: null } })).toBe(15000); + }); + + it('liveSync 폴링 중 401 에러면 폴링을 중단한다', () => { + renderHook(() => useSlides('project-1', { liveSync: true })); + + const options = mockedUseQuery.mock.calls[0]?.[0] as unknown as { + refetchInterval: (query: { state: { error: unknown } }) => number | false; + }; + + const getInterval = options.refetchInterval; + expect( + getInterval({ + state: { + error: { + isAxiosError: true, + response: { status: 401 }, + }, + }, + }), + ).toBe(false); + }); + + it('enabled 옵션으로 요청을 비활성화할 수 있다', () => { + renderHook(() => useSlides('project-1', { enabled: false })); + + const options = mockedUseQuery.mock.calls[0]?.[0] as { + enabled: boolean; + }; + + expect(options.enabled).toBe(false); + }); +}); diff --git a/src/hooks/queries/useSlides.ts b/src/hooks/queries/useSlides.ts index 6af1a030..f39c8892 100644 --- a/src/hooks/queries/useSlides.ts +++ b/src/hooks/queries/useSlides.ts @@ -8,27 +8,40 @@ import type { UpdateSlideTitleRequestDto } from '@/api/dto'; import { getSlides, updateSlide } from '@/api/endpoints/slides'; import { queryKeys } from '@/api/queryClient'; +type UseSlidesOptions = { + enabled?: boolean; + liveSync?: boolean; + pollingIntervalMs?: number; +}; + /** * 슬라이드 목록 조회 * * @param projectId - 프로젝트 ID + * @param options - 조회 옵션 + * @param options.enabled - 쿼리 활성화 여부 (기본값: true) + * @param options.liveSync - 폴링 기반 라이브 동기화 여부 (기본값: false) + * @param options.pollingIntervalMs - 라이브 동기화 폴링 간격(ms) (기본값: 15000) */ -export function useSlides(projectId: string) { +export function useSlides( + projectId: string, + { enabled: isEnabled = true, liveSync = false, pollingIntervalMs = 15000 }: UseSlidesOptions = {}, +) { return useQuery({ queryKey: queryKeys.slides.list(projectId), queryFn: () => getSlides(projectId), - enabled: !!projectId, + enabled: !!projectId && isEnabled, retry: false, - // 🔄 서버가 웹소켓 브로드캐스트를 안하므로 임시로 폴링 추가 - // TODO: 서버에서 broadcastNewComment 호출 후 제거 - refetchInterval: (query) => { - const error = query.state.error; - if (isAxiosError(error) && error.response?.status === 401) { - return false; - } - return 3000; - }, // 3초마다 자동 갱신 (401이면 중단) - refetchIntervalInBackground: false, // 탭이 백그라운드일 때는 멈춤 + refetchInterval: liveSync + ? (query) => { + const error = query.state.error; + if (isAxiosError(error) && error.response?.status === 401) { + return false; + } + return pollingIntervalMs; + } + : false, + refetchIntervalInBackground: false, }); } diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts index 2476a8d8..dbcd88f7 100644 --- a/src/hooks/useInsightPageModel.ts +++ b/src/hooks/useInsightPageModel.ts @@ -42,7 +42,7 @@ export function useInsightPageModel(): InsightModel { const projectIdStr = projectId ?? ''; const projectIdNum = projectIdStr ? Number(projectIdStr) : 0; - const slidesQuery = useSlides(projectIdStr); + const slidesQuery = useSlides(projectIdStr, { liveSync: false }); const slideAnalyticsQuery = useSlideAnalytics(projectIdNum); const summaryAnalyticsQuery = usePresentationAnalyticsSummary(projectIdNum); const recentCommentsQuery = useRecentComments(projectIdNum); diff --git a/src/pages/SlidePage.tsx b/src/pages/SlidePage.tsx index 64c20566..86f19e4a 100644 --- a/src/pages/SlidePage.tsx +++ b/src/pages/SlidePage.tsx @@ -10,7 +10,14 @@ export default function SlidePage() { const { projectId, slideId: routeSlideId } = useParams<{ projectId: string; slideId?: string }>(); const navigate = useNavigate(); - const { data: slides, isLoading, isError } = useSlides(projectId ?? ''); + const { + data: slides, + isLoading, + isError, + } = useSlides(projectId ?? '', { + liveSync: true, + pollingIntervalMs: 15000, + }); const currentSlide = slides?.find((s) => s.slideId === routeSlideId) ?? slides?.[0]; const currentIndex = currentSlide From 32ec3fb6814671081dfd435af6451df00ffaa963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A1=9C=EB=A1=9C?= Date: Sun, 22 Feb 2026 12:07:37 +0900 Subject: [PATCH 03/16] =?UTF-8?q?refactor:=20=EB=8C=80=EB=B3=B8=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=83=81=EC=84=B8=20=EC=9A=94=EC=B2=AD=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slide/SlideWorkspace.tsx | 46 +++++++++++----- src/components/video/RecordingSection.tsx | 42 +++++++++++---- src/hooks/queries/useScript.ts | 64 +++++++++++++++++++++-- src/pages/VideoDetailPage.tsx | 43 ++++++++------- 4 files changed, 149 insertions(+), 46 deletions(-) diff --git a/src/components/slide/SlideWorkspace.tsx b/src/components/slide/SlideWorkspace.tsx index 1eb75165..fcfa558a 100644 --- a/src/components/slide/SlideWorkspace.tsx +++ b/src/components/slide/SlideWorkspace.tsx @@ -7,11 +7,11 @@ * - ScriptBox 접힘 상태를 관리하고 SlideViewer에 전달 * - Zustand store로 슬라이드 상태 관리 */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { SLIDE_MAX_WIDTH } from '@/constants/layout'; import { useSlideActions, useSlideId, useSlideScript } from '@/hooks'; -import { useScript } from '@/hooks/queries/useScript'; +import { useProjectScripts, useScript } from '@/hooks/queries/useScript'; import { useSlideCommentsLoader } from '@/hooks/useSlideCommentsLoader'; import type { SlideListItem } from '@/types/slide'; @@ -28,24 +28,42 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps const { initSlide, updateScript, updateSlide } = useSlideActions(); const slideId = useSlideId(); const script = useSlideScript(); - const { data: scriptData } = useScript(slideId); const lastSyncedSlideIdRef = useRef(''); const lastSyncedScriptRef = useRef(null); + const projectId = slide?.projectId ?? ''; + const currentSlideId = slide?.slideId ?? ''; + const { data: projectScripts } = useProjectScripts(projectId, { + enabled: !!projectId, + staleTime: 1000 * 60 * 10, + }); + + const projectScript = useMemo(() => { + if (!currentSlideId) return undefined; + return projectScripts?.scripts.find((item) => item.slideId === currentSlideId)?.scriptText; + }, [projectScripts, currentSlideId]); + + const shouldFetchDetailScript = !projectScript && !slide?.script; + const { data: scriptData } = useScript(currentSlideId, { + enabled: shouldFetchDetailScript, + staleTime: 1000 * 60 * 10, + }); + + const resolvedServerScript = projectScript ?? slide?.script ?? scriptData?.scriptText ?? ''; + useEffect(() => { if (!slide) return; + const nextSlide = + resolvedServerScript !== slide.script ? { ...slide, script: resolvedServerScript } : slide; const isSameSlide = slide.slideId === slideId; if (isSameSlide) { - updateSlide(slide); + updateSlide(nextSlide); return; } - initSlide(slide); - updateScript(''); - }, [slide, slideId, initSlide, updateScript, updateSlide]); - - useSlideCommentsLoader(slide?.slideId); + initSlide(nextSlide); + }, [slide, slideId, initSlide, resolvedServerScript, updateSlide]); useEffect(() => { if (lastSyncedSlideIdRef.current !== slideId) { @@ -55,13 +73,13 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps }, [slideId]); useEffect(() => { - if (!scriptData) return; + if (!slideId) return; - const serverScript = scriptData.scriptText; + const serverScript = resolvedServerScript; const hasSyncedOnce = lastSyncedScriptRef.current !== null; const hasLocalEditAfterSync = hasSyncedOnce && script !== lastSyncedScriptRef.current; - // 로컬 편집이 있는 동안에는 서버 응답으로 덮어쓰지 않습니다. + // 로컬 편집 중에는 서버 값으로 덮어쓰지 않습니다. if (hasLocalEditAfterSync && script !== serverScript) return; if (script !== serverScript) { @@ -69,7 +87,9 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps } lastSyncedScriptRef.current = serverScript; - }, [script, scriptData, updateScript]); + }, [resolvedServerScript, script, slideId, updateScript]); + + useSlideCommentsLoader(slide?.slideId); return (
diff --git a/src/components/video/RecordingSection.tsx b/src/components/video/RecordingSection.tsx index 6d245715..bbfa5e3f 100644 --- a/src/components/video/RecordingSection.tsx +++ b/src/components/video/RecordingSection.tsx @@ -4,10 +4,10 @@ import IconArrowLeft from '@/assets/icons/icon-arrow-left.svg?react'; import IconArrowRight from '@/assets/icons/icon-arrow-right.svg?react'; import { Logo, SlideImage } from '@/components/common'; import { usePresentation } from '@/hooks/queries/usePresentations'; -import { useScript } from '@/hooks/queries/useScript'; +import { useProjectScripts } from '@/hooks/queries/useScript'; import { useSlides } from '@/hooks/queries/useSlides'; +import { useRecorder } from '@/hooks/useRecorder'; -import { useRecorder } from '../../hooks/useRecorder'; import StopButton from './StopButton'; interface SlideData { @@ -40,11 +40,32 @@ export const RecordingSection = ({ const { isRecording, startRecording, stopRecording, getRecordedBlob } = useRecorder(); const { data: presentation } = usePresentation(projectId); - const { data: slidesData } = useSlides(projectId); - const slidesList = useMemo( - () => slidesData?.map((slide) => ({ id: slide.slideId, url: slide.imageUrl })) ?? [], - [slidesData], + const { data: slidesData, isLoading: isSlidesLoading } = useSlides(projectId, { + liveSync: false, + }); + const { data: projectScripts, isLoading: isProjectScriptsLoading } = useProjectScripts( + projectId, + { + enabled: !!projectId, + staleTime: 1000 * 60 * 10, + }, ); + const slidesList = useMemo(() => { + const projectScriptMap = new Map( + (projectScripts?.scripts ?? []).map((scriptItem) => [ + String(scriptItem.slideId), + scriptItem.scriptText ?? '', + ]), + ); + + return ( + slidesData?.map((slide) => ({ + id: slide.slideId, + url: slide.imageUrl, + script: projectScriptMap.get(String(slide.slideId)) ?? slide.script ?? '', + })) ?? [] + ); + }, [projectScripts, slidesData]); const totalPages = slidesList.length > 0 ? slidesList.length : 1; const [currentPage, setCurrentPage] = useState(1); @@ -56,9 +77,10 @@ export const RecordingSection = ({ const [isFinishing, setIsFinishing] = useState(false); const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; - - const currentSlideId = slidesList[currentPage - 1]?.id; - const { data: scriptData } = useScript(currentSlideId ?? ''); + const currentSlideScript = slidesList[currentPage - 1]?.script ?? ''; + const isScriptLoading = isSlidesLoading || isProjectScriptsLoading; + const scriptDisplayText = + currentSlideScript || (isScriptLoading ? '대본 불러오는 중...' : '대본이 없습니다.'); // 녹화 시작 및 첫 로그 생성 함수 const startRecordingWithLog = useCallback( @@ -316,7 +338,7 @@ export const RecordingSection = ({

발표 대본

- {scriptData?.scriptText || '대본이 없습니다.'} + {scriptDisplayText}
diff --git a/src/hooks/queries/useScript.ts b/src/hooks/queries/useScript.ts index 43edeaa9..aab4d591 100644 --- a/src/hooks/queries/useScript.ts +++ b/src/hooks/queries/useScript.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { BulkEditScriptsRequestDto, + GetProjectScriptsResponseDto, RestoreScriptRequestDto, UpdateScriptRequestDto, } from '@/api/dto'; @@ -14,17 +15,27 @@ import { updateScript, } from '@/api/endpoints/scripts'; import { queryKeys } from '@/api/queryClient'; +import type { SlideListItem } from '@/types/slide'; /** * 대본 조회 * * @param slideId - 슬라이드 ID */ -export function useScript(slideId: string) { +export function useScript( + slideId: string, + options?: { + enabled?: boolean; + staleTime?: number; + }, +) { + const isEnabled = options?.enabled ?? true; + return useQuery({ queryKey: queryKeys.scripts.detail(slideId), queryFn: () => getScript(slideId), - enabled: !!slideId, + enabled: !!slideId && isEnabled, + staleTime: options?.staleTime, }); } @@ -44,6 +55,42 @@ export function useUpdateScript() { onSuccess: (savedScript, { slideId }) => { queryClient.setQueryData(queryKeys.scripts.detail(slideId), savedScript); + + queryClient.setQueriesData({ queryKey: queryKeys.slides.lists() }, (old) => { + if (!old) return old; + + let hasUpdated = false; + const next = old.map((slide) => { + if (slide.slideId !== slideId) return slide; + hasUpdated = true; + return { ...slide, script: savedScript.scriptText }; + }); + + return hasUpdated ? next : old; + }); + + queryClient.setQueriesData( + { queryKey: queryKeys.scripts.projects() }, + (old) => { + if (!old) return old; + if (!Array.isArray(old.scripts)) return old; + + let hasUpdated = false; + const nextScripts = old.scripts.map((scriptItem) => { + if (scriptItem.slideId !== slideId) return scriptItem; + hasUpdated = true; + return { ...scriptItem, scriptText: savedScript.scriptText }; + }); + + if (!hasUpdated) return old; + + return { + ...old, + scripts: nextScripts, + }; + }, + ); + void queryClient.invalidateQueries({ queryKey: queryKeys.scripts.versions(slideId) }); }, }); @@ -82,11 +129,20 @@ export function useRestoreScript() { /** * 프로젝트 전체 대본 조회 */ -export function useProjectScripts(projectId: string) { +export function useProjectScripts( + projectId: string, + options?: { + enabled?: boolean; + staleTime?: number; + }, +) { + const isEnabled = options?.enabled ?? true; + return useQuery({ queryKey: queryKeys.scripts.project(projectId), queryFn: () => getProjectScripts(projectId), - enabled: !!projectId, + enabled: !!projectId && isEnabled, + staleTime: options?.staleTime, }); } diff --git a/src/pages/VideoDetailPage.tsx b/src/pages/VideoDetailPage.tsx index df50378a..a99ec778 100644 --- a/src/pages/VideoDetailPage.tsx +++ b/src/pages/VideoDetailPage.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import type { ReadVideoDetailResponseDto, VideoCommentDto } from '@/api/dto/video.dto'; -import { getScript } from '@/api/endpoints/scripts'; import { videosApi } from '@/api/endpoints/videos'; import { CommentInput } from '@/components/comment'; import CommentList from '@/components/comment/CommentList'; @@ -10,6 +9,7 @@ import FeedbackMobileLayout from '@/components/feedback/FeedbackMobileLayout'; import ScriptSection from '@/components/feedback/ScriptSection'; import ReactionBubble from '@/components/feedback/video/ReactionBubble'; import SlideWebcamStage from '@/components/feedback/video/SlideWebcamStage'; +import { useProjectScripts } from '@/hooks/queries/useScript'; import { useSlides } from '@/hooks/queries/useSlides'; import { useIsDesktop } from '@/hooks/useMediaQuery'; import { useVideoComments } from '@/hooks/useVideoComments'; @@ -38,7 +38,11 @@ export default function VideoDetailPage() { const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [scrollToCommentId, setScrollToCommentId] = useState(); - const { data: slidesData } = useSlides(projectId!); + const { data: slidesData } = useSlides(projectId!, { liveSync: false }); + const { data: projectScripts } = useProjectScripts(projectId ?? '', { + enabled: !!projectId, + staleTime: 1000 * 60 * 10, + }); const [projectSlides, setProjectSlides] = useState([]); const [slideChangeTimes, setSlideChangeTimes] = useState([]); const [slideIdOrder, setSlideIdOrder] = useState([]); @@ -304,23 +308,24 @@ export default function VideoDetailPage() { useEffect(() => { if (!slidesData || slideIdOrder.length === 0) return; - const loadScripts = async () => { - const ordered = await Promise.all( - slideIdOrder.map(async (id) => { - const slideBase = slidesData.find((s) => String(s.slideId) === String(id)); - if (!slideBase) return null; - try { - const scriptRes = await getScript(String(id)); - return { ...slideBase, script: scriptRes.scriptText || '' }; - } catch { - return { ...slideBase, script: slideBase.script || '' }; - } - }), - ); - setProjectSlides(ordered.filter((s): s is SlideListItem => s !== null)); - }; - loadScripts(); - }, [slidesData, slideIdOrder]); + const slideMap = new Map(slidesData.map((slide) => [String(slide.slideId), slide])); + const projectScriptMap = new Map( + (projectScripts?.scripts ?? []).map((item) => [String(item.slideId), item.scriptText]), + ); + const orderedSlides = slideIdOrder + .map((id) => { + const slide = slideMap.get(String(id)); + if (!slide) return undefined; + + return { + ...slide, + script: projectScriptMap.get(String(id)) ?? slide.script ?? '', + }; + }) + .filter((slide): slide is SlideListItem => Boolean(slide)); + + setProjectSlides(orderedSlides); + }, [projectScripts, slideIdOrder, slidesData]); if (isLoading) return
Loading...
; From 893968f8c000822ed1d7a325a09a89ad433702ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A1=9C=EB=A1=9C?= Date: Sun, 22 Feb 2026 12:08:02 +0900 Subject: [PATCH 04/16] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A1=9C=20=ED=86=B5=ED=95=A9=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/queries/useSharedComments.ts | 7 +- src/pages/feedback/useFeedbackSlide.ts | 71 +++--- src/pages/feedback/useFeedbackVideo.test.tsx | 218 +++++++++++++++++++ src/pages/feedback/useFeedbackVideo.ts | 68 ++++-- 4 files changed, 315 insertions(+), 49 deletions(-) create mode 100644 src/pages/feedback/useFeedbackVideo.test.tsx diff --git a/src/hooks/queries/useSharedComments.ts b/src/hooks/queries/useSharedComments.ts index c78d81c5..d030ea4e 100644 --- a/src/hooks/queries/useSharedComments.ts +++ b/src/hooks/queries/useSharedComments.ts @@ -6,6 +6,8 @@ import type { ReadSharedCommentsData } from '@/types/share'; type UseSharedCommentsOptions = { initialData?: ReadSharedCommentsData; + enabled?: boolean; + staleTime?: number; }; export function useSharedComments( @@ -13,10 +15,13 @@ export function useSharedComments( sessionId?: string, options: UseSharedCommentsOptions = {}, ) { + const isEnabled = options.enabled ?? true; + return useQuery({ queryKey: queryKeys.shares.comments(shareToken, sessionId), queryFn: () => getSharedComments(shareToken, sessionId), - enabled: !!shareToken, + enabled: !!shareToken && isEnabled, initialData: options.initialData, + staleTime: options.staleTime, }); } diff --git a/src/pages/feedback/useFeedbackSlide.ts b/src/pages/feedback/useFeedbackSlide.ts index 1ee14f9a..6d4d9b22 100644 --- a/src/pages/feedback/useFeedbackSlide.ts +++ b/src/pages/feedback/useFeedbackSlide.ts @@ -1,17 +1,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + import { slideView } from '@/api/endpoints/analytics'; import { createReply } from '@/api/endpoints/comments'; -import { getSharedComments } from '@/api/endpoints/shares'; +import { queryKeys } from '@/api/queryClient'; import { createDefaultReactions } from '@/constants/reaction'; import { useHotkey, useSlideComments } from '@/hooks'; +import { useSharedComments } from '@/hooks/queries/useSharedComments'; import { useSlideCommentsActions } from '@/hooks/useSlideCommentsActions'; import { useSlideNavigation } from '@/hooks/useSlideNavigation'; import { useSlideReactions } from '@/hooks/useSlideReactions'; import { useAuthStore } from '@/stores/authStore'; import { useSlideStore } from '@/stores/slideStore'; import type { Comment } from '@/types/comment'; -import type { SharedPresentationComment, SharedPresentationSlide } from '@/types/share'; +import type { + ReadSharedCommentsData, + SharedPresentationComment, + SharedPresentationSlide, +} from '@/types/share'; import { flatToTree } from '@/utils/comment'; import { normalizeSharedSlides } from '@/utils/sharedContent'; import { showToast } from '@/utils/toast'; @@ -62,6 +69,8 @@ export const useFeedbackSlide = ({ shareToken, onShareExitSnapshotChange, }: UseFeedbackSlideOptions) => { + const queryClient = useQueryClient(); + const sessionId = useAuthStore((state) => state.user?.sessionId); const slides = useMemo(() => normalizeSharedSlides(sharedSlides), [sharedSlides]); const totalSlides = slides.length; @@ -123,21 +132,35 @@ export const useFeedbackSlide = ({ ); }, [slides]); - // 서버에서 최신 댓글 목록을 가져와서 store 업데이트 - const reloadComments = useCallback(async () => { + const { data: sharedCommentsData, isFetching: isCommentsLoading } = useSharedComments( + shareToken ?? '', + sessionId, + { + enabled: !!shareToken, + initialData: { + comments: sharedComments, + }, + staleTime: 0, + }, + ); + + // 서버 댓글 목록을 store에 동기화 + useEffect(() => { + if (!sharedCommentsData) return; + const slideComments = mapSharedSlideComments(sharedCommentsData.comments, sharedSlideMeta); + setComments(slideComments); + }, [setComments, sharedCommentsData, sharedSlideMeta]); + + const invalidateSharedComments = useCallback(async (): Promise => { if (!shareToken) return null; + const sharedCommentsKey = queryKeys.shares.comments(shareToken, sessionId); - try { - const { user } = useAuthStore.getState(); - const sessionId = user?.sessionId; - const data = await getSharedComments(shareToken, sessionId); - const slideComments = mapSharedSlideComments(data.comments, sharedSlideMeta); - setComments(slideComments); - return data.comments; - } catch { - return null; - } - }, [shareToken, setComments, sharedSlideMeta]); + await queryClient.invalidateQueries({ + queryKey: sharedCommentsKey, + }); + + return queryClient.getQueryData(sharedCommentsKey) ?? null; + }, [queryClient, sessionId, shareToken]); const { addComment, deleteComment, updateComment } = useSlideCommentsActions(); @@ -161,7 +184,7 @@ export const useFeedbackSlide = ({ const handleAddComment = async () => { if (!commentDraft.trim()) return; const serverId = await addComment(commentDraft); - await reloadComments(); + await invalidateSharedComments(); if (serverId) { setScrollToCommentId(serverId); } @@ -191,7 +214,7 @@ export const useFeedbackSlide = ({ // 최상위 부모의 serverId로 답글 작성 try { const response = await createReply(rootParentServerId, { content: trimmedContent }); - await reloadComments(); + await invalidateSharedComments(); // 작성한 답글로 스크롤 if (response.replyId) { @@ -204,22 +227,14 @@ export const useFeedbackSlide = ({ const handleDeleteComment = async (commentId: string) => { await deleteComment(commentId); - await reloadComments(); + await invalidateSharedComments(); }; const handleUpdateComment = async (commentId: string, content: string) => { await updateComment(commentId, content); - await reloadComments(); + await invalidateSharedComments(); }; - // 초기 댓글 로딩 - useEffect(() => { - const initialComments = mapSharedSlideComments(sharedComments, sharedSlideMeta); - if (initialComments.length > 0) { - setComments(initialComments); - } - }, [sharedComments, sharedSlideMeta, setComments]); - const handleGoToRef = useCallback( (ref: NonNullable) => { if (ref.kind !== 'slide') return; @@ -285,7 +300,7 @@ export const useFeedbackSlide = ({ scrollToCommentId, reactions, isLoading: false, - isCommentsLoading: false, + isCommentsLoading, commentsHasNextPage: false, commentsIsFetchingNextPage: false, isFirst: navigation.isFirst, diff --git a/src/pages/feedback/useFeedbackVideo.test.tsx b/src/pages/feedback/useFeedbackVideo.test.tsx new file mode 100644 index 00000000..9ce4f0a3 --- /dev/null +++ b/src/pages/feedback/useFeedbackVideo.test.tsx @@ -0,0 +1,218 @@ +import { useParams } from 'react-router-dom'; + +import { useQueryClient } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recordVideoEvent } from '@/api/endpoints/analytics'; +import { videosApi } from '@/api/endpoints/videos'; +import { useSharedComments } from '@/hooks/queries/useSharedComments'; +import { useVideoComments } from '@/hooks/useVideoComments'; +import { useVideoReactions } from '@/hooks/useVideoReactions'; +import type { ReadSharedContentData } from '@/types/share'; + +import { useFeedbackVideo } from './useFeedbackVideo'; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: vi.fn(), + }; +}); + +vi.mock('@tanstack/react-query', async () => { + const actual = + await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +vi.mock('@/api/endpoints/analytics', () => ({ + recordVideoEvent: vi.fn(), +})); + +vi.mock('@/api/endpoints/videos', () => ({ + videosApi: { + getVideoDetail: vi.fn(), + getVideoSlides: vi.fn(), + }, +})); + +vi.mock('@/hooks/queries/useSharedComments', () => ({ + useSharedComments: vi.fn(), +})); + +vi.mock('@/hooks/useVideoComments', () => ({ + useVideoComments: vi.fn(), +})); + +vi.mock('@/hooks/useVideoReactions', () => ({ + useVideoReactions: vi.fn(), +})); + +const mockedUseParams = vi.mocked(useParams); +const mockedUseQueryClient = vi.mocked(useQueryClient); +const mockedRecordVideoEvent = vi.mocked(recordVideoEvent); +const mockedVideosApi = vi.mocked(videosApi); +const mockedUseSharedComments = vi.mocked(useSharedComments); +const mockedUseVideoComments = vi.mocked(useVideoComments); +const mockedUseVideoReactions = vi.mocked(useVideoReactions); + +const baseSharedContent: ReadSharedContentData = { + message: 'ok', + sessionInfo: { + sessionId: 'session-1', + name: 'anonymous', + tokens: { + accessToken: 'a', + refreshToken: 'b', + }, + }, + shareInfo: { + shareToken: 'share-token', + scope: 'slides_script_video', + createdAt: '2025-01-01T00:00:00.000Z', + publisherName: 'tester', + }, + presentationContent: { + title: '공유 영상', + slides: [ + { + slideId: '1', + slideNum: 1, + title: '슬라이드 1', + imageUrl: 'https://example.com/s1.png', + scriptText: 'script 1', + timestampMs: 0, + }, + { + slideId: '2', + slideNum: 2, + title: '슬라이드 2', + imageUrl: 'https://example.com/s2.png', + scriptText: 'script 2', + timestampMs: 5000, + }, + ], + video: { + videoId: '101', + videoUrl: 'https://cdn.ttorang.com/video.m3u8', + thumbnailUrl: null, + }, + comments: [], + }, +}; + +describe('useFeedbackVideo', () => { + const invalidateQueriesMock = vi.fn().mockResolvedValue(undefined); + const getQueryDataMock = vi.fn(); + const addCommentMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + mockedUseParams.mockReturnValue({ shareToken: 'share-token' }); + mockedUseQueryClient.mockReturnValue({ + invalidateQueries: invalidateQueriesMock, + getQueryData: getQueryDataMock, + } as never); + + mockedUseVideoComments.mockReturnValue({ + comments: [], + addComment: addCommentMock, + addReply: vi.fn(), + deleteComment: vi.fn(), + updateComment: vi.fn(), + }); + + mockedUseVideoReactions.mockReturnValue({ + reactions: [], + addReaction: vi.fn(), + }); + + mockedUseSharedComments.mockReturnValue({ + data: { comments: [] }, + } as never); + + mockedVideosApi.getVideoDetail.mockResolvedValue({ + data: { + resultType: 'SUCCESS', + success: { + video: { + title: '서버 영상', + durationSeconds: 120, + hlsMasterUrl: 'https://cdn.ttorang.com/server.m3u8', + }, + }, + }, + } as never); + mockedVideosApi.getVideoSlides.mockResolvedValue({ + data: { + resultType: 'SUCCESS', + success: { + slides: [ + { slideId: '1', timestampMs: 0 }, + { slideId: '2', timestampMs: 4000 }, + ], + }, + }, + } as never); + }); + + it('공유 슬라이드 타임라인이 있으면 video slides API를 추가 호출하지 않는다', async () => { + const { result } = renderHook(() => useFeedbackVideo(baseSharedContent)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockedVideosApi.getVideoSlides).not.toHaveBeenCalled(); + expect(mockedVideosApi.getVideoDetail).not.toHaveBeenCalled(); + }); + + it('타임라인/영상 URL이 없으면 필요한 API를 1회씩 호출한다', async () => { + const contentWithoutTimeline: ReadSharedContentData = { + ...baseSharedContent, + presentationContent: { + ...baseSharedContent.presentationContent, + slides: baseSharedContent.presentationContent.slides.map((slide) => ({ + ...slide, + timestampMs: -1, + })), + video: { + videoId: '101', + videoUrl: null, + thumbnailUrl: null, + }, + }, + }; + + const { result } = renderHook(() => useFeedbackVideo(contentWithoutTimeline)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockedVideosApi.getVideoDetail).toHaveBeenCalledTimes(1); + expect(mockedVideosApi.getVideoSlides).toHaveBeenCalledTimes(1); + }); + + it('댓글 등록 시 공유 댓글 invalidate를 1회 호출한다', async () => { + addCommentMock.mockResolvedValue('comment-1'); + getQueryDataMock.mockReturnValue({ + comments: [{ commentId: 'comment-1' }], + }); + + const { result } = renderHook(() => useFeedbackVideo(baseSharedContent)); + + await act(async () => { + result.current.setCommentDraft('테스트 댓글'); + }); + + await act(async () => { + await result.current.handleAddComment(); + }); + + expect(invalidateQueriesMock).toHaveBeenCalledTimes(1); + expect(mockedRecordVideoEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/feedback/useFeedbackVideo.ts b/src/pages/feedback/useFeedbackVideo.ts index a27f5f70..128eba81 100644 --- a/src/pages/feedback/useFeedbackVideo.ts +++ b/src/pages/feedback/useFeedbackVideo.ts @@ -5,16 +5,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + import { recordVideoEvent } from '@/api/endpoints/analytics'; -import { getSharedComments } from '@/api/endpoints/shares'; import { videosApi } from '@/api/endpoints/videos'; +import { queryKeys } from '@/api/queryClient'; import { createDefaultReactions } from '@/constants/reaction'; +import { useSharedComments } from '@/hooks/queries/useSharedComments'; import { useVideoComments } from '@/hooks/useVideoComments'; import { useVideoReactions } from '@/hooks/useVideoReactions'; import { useAuthStore } from '@/stores/authStore'; import { useVideoFeedbackStore } from '@/stores/videoFeedbackStore'; import type { Comment } from '@/types/comment'; -import type { ReadSharedContentData, SharedPresentationComment } from '@/types/share'; +import type { + ReadSharedCommentsData, + ReadSharedContentData, + SharedPresentationComment, +} from '@/types/share'; import type { SlideDetail } from '@/types/slide'; import type { VideoTimestampFeedback } from '@/types/video'; import { formatVideoTimestamp } from '@/utils/format'; @@ -165,9 +172,11 @@ export function useFeedbackVideo( options: UseFeedbackVideoOptions = {}, ) { const { onShareExitSnapshotChange } = options; + const queryClient = useQueryClient(); // ─── 라우트 파라미터 ─────────────────────────────────── const { shareToken = '' } = useParams<{ shareToken?: string }>(); + const sessionId = useAuthStore((state) => state.user?.sessionId); // ─── Store 셀렉터 ───────────────────────────────────── const video = useVideoFeedbackStore((s) => s.video); @@ -215,16 +224,19 @@ export function useFeedbackVideo( let videoTitle = content.presentationContent.title || '공유 영상'; let duration = 0; let timelineSlides: Array<{ slideId: string; timestampMs: number }> = fallbackTimelineSlides; + const hasSharedTimeline = fallbackTimelineSlides.length > 0; + const needsVideoDetail = !videoUrl || !videoTitle; if (normalizedVideoId) { const [detailResult, timelineResult] = await Promise.allSettled([ - videosApi.getVideoDetail(normalizedVideoId), - videosApi.getVideoSlides(normalizedVideoId), + needsVideoDetail ? videosApi.getVideoDetail(normalizedVideoId) : Promise.resolve(null), + !hasSharedTimeline ? videosApi.getVideoSlides(normalizedVideoId) : Promise.resolve(null), ]); if (cancelled) return; if ( detailResult.status === 'fulfilled' && + detailResult.value && detailResult.value.data.resultType === 'SUCCESS' ) { const serverVideo = detailResult.value.data.success.video; @@ -235,6 +247,7 @@ export function useFeedbackVideo( if ( timelineResult.status === 'fulfilled' && + timelineResult.value && timelineResult.value.data.resultType === 'SUCCESS' ) { timelineSlides = timelineResult.value.data.success.slides.map((slide) => ({ @@ -278,6 +291,19 @@ export function useFeedbackVideo( }; }, [initVideo, sharedContent, shareToken]); + const { data: sharedCommentsData } = useSharedComments(shareToken, sessionId, { + enabled: !!shareToken, + initialData: { + comments: sharedContent.presentationContent.comments, + }, + }); + + useEffect(() => { + if (!shareToken || !sharedCommentsData) return; + const sharedFeedbacks = mapSharedCommentsToFeedbacks(sharedCommentsData.comments); + updateFeedbacks(sharedFeedbacks); + }, [shareToken, sharedCommentsData, updateFeedbacks]); + // ─── 리액션 ─────────────────────────────────────────── const { reactions, addReaction } = useVideoReactions(); @@ -287,21 +313,21 @@ export function useFeedbackVideo( const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [capturedTimestamp, setCapturedTimestamp] = useState(null); - const reloadComments = useCallback(async () => { + const invalidateSharedComments = useCallback(async (): Promise => { if (!shareToken) return null; - try { - const { user } = useAuthStore.getState(); - const data = await getSharedComments(shareToken, user?.sessionId); - const sharedFeedbacks = mapSharedCommentsToFeedbacks(data.comments); - updateFeedbacks(sharedFeedbacks); - return data.comments; - } catch { - return null; - } - }, [shareToken, updateFeedbacks]); + const sharedCommentsKey = queryKeys.shares.comments(shareToken, sessionId); + + await queryClient.invalidateQueries({ + queryKey: sharedCommentsKey, + }); + + return queryClient.getQueryData(sharedCommentsKey) ?? null; + }, [queryClient, sessionId, shareToken]); const { comments, addComment, addReply, deleteComment, updateComment } = useVideoComments({ - onMutationSuccess: () => void reloadComments(), + onMutationSuccess: () => { + void invalidateSharedComments(); + }, }); const handleInputFocus = useCallback(() => { @@ -317,13 +343,15 @@ export function useFeedbackVideo( try { const timestampToUse = capturedTimestamp ?? currentTime; const newCommentServerId = await addComment(commentDraft, timestampToUse); + if (!newCommentServerId) return; + setCommentDraft(''); setCapturedTimestamp(null); - const latestComments = await reloadComments(); + const latestComments = await invalidateSharedComments(); - if (newCommentServerId && latestComments) { - const newComment = latestComments.find( + if (latestComments) { + const newComment = latestComments.comments.find( (c: SharedPresentationComment) => c.commentId === newCommentServerId, ); if (newComment) { @@ -334,7 +362,7 @@ export function useFeedbackVideo( } finally { setIsSubmittingComment(false); } - }, [addComment, commentDraft, currentTime, capturedTimestamp, reloadComments]); + }, [addComment, commentDraft, currentTime, capturedTimestamp, invalidateSharedComments]); const handleCancelComment = useCallback(() => { setCommentDraft(''); From b6fd79dc3547d717af30fb0bc1a96ee2e9cd0e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A1=9C=EB=A1=9C?= Date: Sun, 22 Feb 2026 12:08:20 +0900 Subject: [PATCH 05/16] =?UTF-8?q?refactor:=20=EB=8C=80=EB=B3=B8=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=EC=88=98=EC=A0=95=20scripts=20=EC=9E=AC?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=B0=A9=EC=A7=80=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useScriptBulkEdit.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/hooks/useScriptBulkEdit.ts b/src/hooks/useScriptBulkEdit.ts index f0236693..bd381361 100644 --- a/src/hooks/useScriptBulkEdit.ts +++ b/src/hooks/useScriptBulkEdit.ts @@ -1,12 +1,18 @@ import { type ChangeEvent, useCallback, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + import type { ProjectScriptItemDto } from '@/api/dto'; +import { getProjectScripts } from '@/api/endpoints/scripts'; +import { queryKeys } from '@/api/queryClient'; import { useBulkEditScripts, useProjectScripts } from '@/hooks/queries/useScript'; import { useSlides } from '@/hooks/queries/useSlides'; import type { SlideListItem } from '@/types/slide'; import { showToast } from '@/utils/toast'; +const PROJECT_SCRIPTS_STALE_TIME_MS = 1000 * 60 * 10; + const normalizeTxt = (text: string) => text.replace(/^\uFEFF/, '').replace(/\r\n|\r/g, '\n'); const parseParagraphsFromTxt = (text: string) => @@ -27,10 +33,12 @@ const buildScriptMap = (scripts: ProjectScriptItemDto[] | undefined) => { export function useScriptBulkEdit() { const { projectId } = useParams<{ projectId: string }>(); - const { data: slides } = useSlides(projectId ?? ''); - const { data: projectScripts, refetch: refetchProjectScripts } = useProjectScripts( - projectId ?? '', - ); + const queryClient = useQueryClient(); + const { data: slides } = useSlides(projectId ?? '', { liveSync: false }); + const { data: projectScripts } = useProjectScripts(projectId ?? '', { + enabled: false, + staleTime: PROJECT_SCRIPTS_STALE_TIME_MS, + }); const { mutateAsync: bulkEditScripts, isPending: isSaving } = useBulkEditScripts(); @@ -72,8 +80,18 @@ export function useScriptBulkEdit() { setIsPreparingModal(true); try { - const response = await refetchProjectScripts(); - setDraftFromSource(response.data?.scripts ?? projectScripts?.scripts); + let latestScripts = projectScripts?.scripts; + + if (!latestScripts) { + const response = await queryClient.ensureQueryData({ + queryKey: queryKeys.scripts.project(projectId), + queryFn: () => getProjectScripts(projectId), + staleTime: PROJECT_SCRIPTS_STALE_TIME_MS, + }); + latestScripts = response.scripts; + } + + setDraftFromSource(latestScripts); } catch { setDraftFromSource(projectScripts?.scripts); showToast.error( From 210426123007389e6eb84a77c400f9808d6e7f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A1=9C=EB=A1=9C?= Date: Sun, 22 Feb 2026 12:08:39 +0900 Subject: [PATCH 06/16] =?UTF-8?q?design:=20=EB=8C=80=EB=B3=B8=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20active/=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=95=EB=A6=AC=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/slide/script/ScriptBoxContent.tsx | 6 +++--- src/components/slide/script/ScriptReadingSpeedModal.tsx | 8 +++++++- src/pages/FeedbackVideoPage.tsx | 2 -- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/slide/script/ScriptBoxContent.tsx b/src/components/slide/script/ScriptBoxContent.tsx index 9577e94d..52a735e4 100644 --- a/src/components/slide/script/ScriptBoxContent.tsx +++ b/src/components/slide/script/ScriptBoxContent.tsx @@ -40,15 +40,15 @@ export default function ScriptBoxContent() { onBlur={flushSave} placeholder="슬라이드 대본을 입력하세요..." aria-label="슬라이드 대본" - className="h-full w-full resize-none overflow-y-auto border-none bg-transparent pb-8 pr-28 text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600" + className="h-full w-full resize-none overflow-y-auto border-none bg-transparent pb-8 text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600" /> -
+