Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/api/queryClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
13 changes: 11 additions & 2 deletions src/api/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/components/common/layout/Gnb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand All @@ -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 (
Expand Down
46 changes: 33 additions & 13 deletions src/components/slide/SlideWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<string>('');
const lastSyncedScriptRef = useRef<string | null>(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) {
Expand All @@ -55,21 +73,23 @@ 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) {
updateScript(serverScript);
}

lastSyncedScriptRef.current = serverScript;
}, [script, scriptData, updateScript]);
}, [resolvedServerScript, script, slideId, updateScript]);

useSlideCommentsLoader(slide?.slideId);

return (
<div className="relative h-full min-h-0 flex flex-col pb-[clamp(12rem,30vh,20rem)] md:pb-0">
Expand Down
6 changes: 3 additions & 3 deletions src/components/slide/script/ScriptBoxContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>

<div className="absolute bottom-3 right-4">
<div className="pointer-events-none absolute bottom-3 right-4 z-10">
<button
type="button"
onClick={() => setIsSpeedModalOpen(true)}
aria-label={`읽기 속도 설정 열기 (현재 예상 시간 ${estimatedDuration})`}
className="inline-flex min-h-9 items-center gap-2 rounded-md bg-white px-3 py-1.5 text-gray-700 transition-colors hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main"
className="pointer-events-auto inline-flex min-h-9 items-center gap-2 rounded-full border border-gray-200 bg-white/95 px-3 py-1.5 text-gray-700 shadow-xs backdrop-blur-sm transition-colors hover:bg-gray-100 active:bg-gray-200 focus-visible:outline-2 focus-visible:outline-main"
>
<span aria-live="polite" aria-atomic="true" className="text-sm font-semibold leading-4">
{estimatedDuration}
Expand Down
8 changes: 7 additions & 1 deletion src/components/slide/script/ScriptReadingSpeedModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ export default function ScriptReadingSpeedModal({ isOpen, onClose }: ScriptReadi
step={1}
value={selectedSpeed}
onChange={(event) => setSelectedSpeed(Number(event.target.value))}
className="h-2 w-full cursor-pointer accent-main"
className={clsx(
'block h-2 w-full cursor-pointer appearance-none rounded-full border border-gray-200 bg-gray-100',
'[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent',
'[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:border-0 [&::-moz-range-track]:bg-transparent',
'[&::-webkit-slider-thumb]:-mt-1.25 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border [&::-webkit-slider-thumb]:border-main [&::-webkit-slider-thumb]:bg-main',
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border [&::-moz-range-thumb]:border-main [&::-moz-range-thumb]:bg-main',
)}
/>
<div className="flex items-center justify-between text-caption text-gray-600">
<span>매우 느리게</span>
Expand Down
42 changes: 32 additions & 10 deletions src/components/video/RecordingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<number>(1);
Expand All @@ -56,9 +77,10 @@ export const RecordingSection = ({
const [isFinishing, setIsFinishing] = useState<boolean>(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(
Expand Down Expand Up @@ -316,7 +338,7 @@ export const RecordingSection = ({
<h3 className="text-body-s-bold text-gray-800">발표 대본</h3>
</div>
<div className="scrollbar-hide flex-1 overflow-y-auto text-body-m leading-normal text-black whitespace-pre-wrap">
{scriptData?.scriptText || '대본이 없습니다.'}
{scriptDisplayText}
</div>
</div>

Expand Down
36 changes: 36 additions & 0 deletions src/hooks/queries/useProjectEntryPrefetch.ts
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(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]);
}
Loading