From 91215854a3a0602ce698e34d050eb86da54d73be Mon Sep 17 00:00:00 2001 From: wonellyho Date: Wed, 18 Feb 2026 17:26:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?design:=20=EB=B9=84=EB=94=94=EC=98=A4?= =?UTF-8?q?=EB=94=94=ED=85=8C=EC=9D=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=EB=B0=98=EC=A0=81=EC=9D=B8ui=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EA=B8=B0(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/comment/Comment.tsx | 2 +- src/pages/VideoDetailPage.tsx | 42 ++++++++++++------------------ 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/components/comment/Comment.tsx b/src/components/comment/Comment.tsx index 13703ca5..b2362ec5 100644 --- a/src/components/comment/Comment.tsx +++ b/src/components/comment/Comment.tsx @@ -131,7 +131,7 @@ function Comment({ comment, isIndented = false, rootCommentId }: CommentProps) { className={clsx( 'flex gap-3 py-3 pr-4 transition-colors', isIndented ? 'pl-15' : 'pl-4', - isEditing ? 'bg-gray-100' : isActive ? 'bg-gray-200' : 'bg-gray-100', + isEditing ? 'bg-gray-100' : isActive ? 'bg-gray-200' : '', )} >
diff --git a/src/pages/VideoDetailPage.tsx b/src/pages/VideoDetailPage.tsx index 5bbf2ed3..d391db86 100644 --- a/src/pages/VideoDetailPage.tsx +++ b/src/pages/VideoDetailPage.tsx @@ -349,30 +349,24 @@ export default function VideoDetailPage() { role="tabpanel" id="tabpanel-videos" aria-labelledby="tab-videos" - className="flex h-full w-full bg-white overflow-hidden" + className="flex h-full w-full bg-gray-100 overflow-hidden px-35 pt-6" > -
-
-
-
-
-
- -
+
+ {/* 비디오 위치 placeholder */} +
+
+
- -
+ {/* 단일 SlideWebcamStage - CSS로 위치 조정 */} +
Date: Wed, 18 Feb 2026 17:40:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20CommentList=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=B4=EC=84=9C=20=EC=82=AD=EC=A0=9C=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EA=B5=AC=ED=98=84(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/VideoDetailPage.tsx | 118 ++++++++++------------------------ 1 file changed, 35 insertions(+), 83 deletions(-) diff --git a/src/pages/VideoDetailPage.tsx b/src/pages/VideoDetailPage.tsx index d391db86..77fd0b3b 100644 --- a/src/pages/VideoDetailPage.tsx +++ b/src/pages/VideoDetailPage.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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 Comment from '@/components/comment/Comment'; -import { CommentProvider } from '@/components/comment/CommentContext'; +import CommentList from '@/components/comment/CommentList'; import ScriptSection from '@/components/feedback/ScriptSection'; import SlideWebcamStage from '@/components/feedback/video/SlideWebcamStage'; import { useSlides } from '@/hooks/queries/useSlides'; @@ -32,12 +31,7 @@ export default function VideoDetailPage() { const [currentTime, setCurrentTime] = useState(0); const [commentDraft, setCommentDraft] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); - // 💡 scrollToCommentId 상태 삭제 (Unused variable 경고 해결) - - const [replyingToId, setReplyingToId] = useState(null); - const [replyDraft, setReplyDraft] = useState(''); - const [editingId, setEditingId] = useState(null); - const [editDraft, setEditDraft] = useState(''); + const [scrollToCommentId, setScrollToCommentId] = useState(); const { data: slidesData } = useSlides(projectId!); const [projectSlides, setProjectSlides] = useState([]); @@ -225,62 +219,27 @@ export default function VideoDetailPage() { requestSeekAction(safeSeekSeconds); }, [isLoading, requestSeekAction, requestedSeekSeconds, videoData?.video.durationSeconds]); - const contextValue = useMemo( - () => ({ - replyingToId, - replyDraft, - setReplyDraft, - toggleReply: (id: string) => { - setReplyingToId((prev) => (prev === id ? null : id)); - setReplyDraft(''); - }, - submitReply: async (targetId: string) => { - if (replyDraft.trim()) { - await addReply(targetId, replyDraft); - setReplyDraft(''); - setReplyingToId(null); - } - }, - cancelReply: () => { - setReplyingToId(null); - setReplyDraft(''); - }, - editingId, - editDraft, - setEditDraft, - startEdit: (id: string, content: string) => { - setEditingId(id); - setEditDraft(content); - }, - cancelEdit: () => { - setEditingId(null); - setEditDraft(''); - }, - submitEdit: async (id: string) => { - if (editDraft.trim()) { - await updateComment(id, editDraft); - setEditingId(null); - setEditDraft(''); - } - }, - deleteComment, - skipReplyFetch: true, - goToRef: (ref: { kind: 'slide'; index: number } | { kind: 'video'; seconds: number }) => { - if (ref.kind === 'video') { - requestSeekAction(ref.seconds); - } - }, - }), - [ - replyingToId, - replyDraft, - editingId, - editDraft, - addReply, - updateComment, - deleteComment, - requestSeekAction, - ], + const handleGoToRef = useCallback( + (ref: NonNullable) => { + if (ref.kind === 'video') { + requestSeekAction(ref.seconds); + } + }, + [requestSeekAction], + ); + + const handleAddReply = useCallback( + (targetId: string, content: string) => { + addReply(targetId, content); + }, + [addReply], + ); + + const handleUpdateComment = useCallback( + (commentId: string, content: string) => { + updateComment(commentId, content); + }, + [updateComment], ); const handleAddMainComment = async () => { @@ -291,12 +250,7 @@ export default function VideoDetailPage() { if (successId) { setCommentDraft(''); await loadData(false); - // 💡 렌더링 후 해당 댓글로 스크롤 (상태 변수 대신 직접 DOM 접근) - setTimeout(() => { - document - .getElementById(`comment-${successId}`) - ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 300); + setScrollToCommentId(successId); } } finally { setIsSubmittingComment(false); @@ -369,17 +323,15 @@ export default function VideoDetailPage() {

의견

- -
- {comments.map((comment) => ( - - ))} -
-
+
{/* 단일 SlideWebcamStage - CSS로 위치 조정 */} -
+
Date: Wed, 18 Feb 2026 17:53:07 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=81=EC=9A=A9(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/VideoDetailPage.tsx | 151 +++++++++++++++++++++++++--------- 1 file changed, 110 insertions(+), 41 deletions(-) diff --git a/src/pages/VideoDetailPage.tsx b/src/pages/VideoDetailPage.tsx index 77fd0b3b..be9385cd 100644 --- a/src/pages/VideoDetailPage.tsx +++ b/src/pages/VideoDetailPage.tsx @@ -6,15 +6,18 @@ import { getScript } from '@/api/endpoints/scripts'; import { videosApi } from '@/api/endpoints/videos'; import { CommentInput } from '@/components/comment'; import CommentList from '@/components/comment/CommentList'; +import FeedbackMobileLayout from '@/components/feedback/FeedbackMobileLayout'; import ScriptSection from '@/components/feedback/ScriptSection'; import SlideWebcamStage from '@/components/feedback/video/SlideWebcamStage'; import { useSlides } from '@/hooks/queries/useSlides'; +import { useIsDesktop } from '@/hooks/useMediaQuery'; import { useVideoComments } from '@/hooks/useVideoComments'; import { useAuthStore } from '@/stores/authStore'; import { useVideoFeedbackStore } from '@/stores/videoFeedbackStore'; import type { SlideListItem } from '@/types'; import type { Comment as CommentType } from '@/types/comment'; import type { VideoTimestampFeedback } from '@/types/video'; +import { countTreeComments } from '@/utils/comment'; import { clamp, parseSeekSecondsParam } from '@/utils/video'; export default function VideoDetailPage() { @@ -24,6 +27,7 @@ export default function VideoDetailPage() { const requestedSeekSeconds = parseSeekSecondsParam(seekParam); const currentUser = useAuthStore((state) => state.user); + const isDesktop = useIsDesktop(); const { initVideo, requestSeek: requestSeekAction } = useVideoFeedbackStore(); const [videoData, setVideoData] = useState(null); @@ -39,6 +43,7 @@ export default function VideoDetailPage() { const [slideIdOrder, setSlideIdOrder] = useState([]); const desktopPlaceholderRef = useRef(null); + const mobilePlaceholderRef = useRef(null); const initialSeekRequestedRef = useRef(false); const [videoStyle, setVideoStyle] = useState({ position: 'fixed', @@ -259,8 +264,15 @@ export default function VideoDetailPage() { useEffect(() => { const updatePosition = () => { - const rect = desktopPlaceholderRef.current?.getBoundingClientRect(); - if (!rect || rect.width === 0) return; + const primaryRef = isDesktop ? desktopPlaceholderRef : mobilePlaceholderRef; + const fallbackRef = isDesktop ? mobilePlaceholderRef : desktopPlaceholderRef; + + let rect = primaryRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0 || rect.height === 0) { + rect = fallbackRef.current?.getBoundingClientRect(); + } + if (!rect || rect.width === 0 || rect.height === 0) return; + setVideoStyle({ position: 'fixed', top: rect.top, @@ -271,10 +283,23 @@ export default function VideoDetailPage() { opacity: 1, }); }; + + const timers = [0, 50, 100, 200].map((delay) => setTimeout(updatePosition, delay)); + + const observer = new ResizeObserver(updatePosition); + if (desktopPlaceholderRef.current) observer.observe(desktopPlaceholderRef.current); + if (mobilePlaceholderRef.current) observer.observe(mobilePlaceholderRef.current); + window.addEventListener('resize', updatePosition); - updatePosition(); - return () => window.removeEventListener('resize', updatePosition); - }, [isLoading]); + window.addEventListener('scroll', updatePosition, true); + + return () => { + timers.forEach(clearTimeout); + observer.disconnect(); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [isDesktop, isLoading]); useEffect(() => { if (!slidesData || slideIdOrder.length === 0) return; @@ -303,47 +328,89 @@ export default function VideoDetailPage() { role="tabpanel" id="tabpanel-videos" aria-labelledby="tab-videos" - className="flex h-full w-full bg-gray-100 overflow-hidden px-35 pt-6" + className="flex h-full w-full bg-gray-100 overflow-hidden" > -
- {/* 비디오 위치 placeholder */} -
-
+ {/* 데스크톱 뷰 */} +
+
+
+
+
+
- + +
- + } + commentTabContent={ + <> +
+ +
+
+ setCommentDraft('')} + disabled={isSubmittingComment} + className="w-full" + /> +
+ + } + commentCount={countTreeComments(comments)} + /> {/* 단일 SlideWebcamStage - CSS로 위치 조정 */}
@@ -352,6 +419,8 @@ export default function VideoDetailPage() { slideChangeTimes={slideChangeTimes} webcamVideoUrl={videoData?.video.hlsMasterUrl || ''} onTimeUpdate={setCurrentTime} + disablePip={!isDesktop} + showLayoutToggle={!isDesktop} />
From 2065f6f1bb8fc03abe84ff2adbb12477bebd0827 Mon Sep 17 00:00:00 2001 From: wonellyho Date: Wed, 18 Feb 2026 18:25:42 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AA=A8=EC=A7=80?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=5F=EB=A6=AC=EC=95=A1?= =?UTF-8?q?=EC=85=98=EB=B2=84=EB=B8=94=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/video/ReactionBubble.tsx | 115 ++++++++++++++++++ src/pages/VideoDetailPage.tsx | 7 +- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/components/feedback/video/ReactionBubble.tsx diff --git a/src/components/feedback/video/ReactionBubble.tsx b/src/components/feedback/video/ReactionBubble.tsx new file mode 100644 index 00000000..4d9916ea --- /dev/null +++ b/src/components/feedback/video/ReactionBubble.tsx @@ -0,0 +1,115 @@ +/** + * @file ReactionBubble.tsx + * @description 영상 재생바 위에 현재 구간 리액션을 요약하여 보여주는 버블 컴포넌트 + * + * - 현재 재생시간 ±windowMs 범위 내 리액션을 표시 + * - 상위 3개까지 노출, 4개 이상이면 상위 3개 + "..." 로 축약 + * - "..." 클릭 시 팝오버로 전체 5종 표시 + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { REACTION_CONFIG, REACTION_TYPES } from '@/constants/reaction'; +import { useVideoReactionWindow } from '@/hooks/queries/useVideoReactionQueries'; +import type { ReactionType } from '@/types/script'; + +interface ReactionBubbleProps { + videoId: string | undefined; + currentTimeMs: number; + windowMs?: number; +} + +export default function ReactionBubble({ + videoId, + currentTimeMs, + windowMs = 5000, +}: ReactionBubbleProps) { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const popoverRef = useRef(null); + + // 500ms 단위로 쿼리 키를 스냅하여 과도한 리패치 방지 + const snappedMs = Math.round(currentTimeMs / 500) * 500; + + const { data: reactions } = useVideoReactionWindow(videoId, snappedMs, windowMs); + + useEffect(() => { + if (!isPopoverOpen) return; + const handleClick = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setIsPopoverOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isPopoverOpen]); + + const handleTogglePopover = useCallback(() => { + setIsPopoverOpen((prev) => !prev); + }, []); + + // 리액션 데이터를 정규화 + const reactionMap = new Map(); + if (reactions) { + for (const r of reactions) { + reactionMap.set(r.emojiType, (reactionMap.get(r.emojiType) ?? 0) + r.count); + } + } + + // count > 0인 항목만 추출, count 내림차순 + const activeReactions = REACTION_TYPES.map((type) => ({ + type, + emoji: REACTION_CONFIG[type].emoji, + count: reactionMap.get(type) ?? 0, + })) + .filter((r) => r.count > 0) + .sort((a, b) => b.count - a.count); + + if (activeReactions.length === 0) return null; + + const displayItems = activeReactions.slice(0, 3); + const hasMore = activeReactions.length >= 4; + + // 전체 5종 목록 (팝오버용) + const allReactions = REACTION_TYPES.map((type) => ({ + type, + emoji: REACTION_CONFIG[type].emoji, + label: REACTION_CONFIG[type].label, + count: reactionMap.get(type) ?? 0, + })); + + return ( +
+ + + {isPopoverOpen && ( +
+

+ 전체 이모지 반응 보기 +

+
+ {allReactions.map((item) => ( +
+ + {item.emoji} + {item.label} + + {item.count} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/pages/VideoDetailPage.tsx b/src/pages/VideoDetailPage.tsx index be9385cd..df50378a 100644 --- a/src/pages/VideoDetailPage.tsx +++ b/src/pages/VideoDetailPage.tsx @@ -8,6 +8,7 @@ import { CommentInput } from '@/components/comment'; import CommentList from '@/components/comment/CommentList'; 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 { useSlides } from '@/hooks/queries/useSlides'; import { useIsDesktop } from '@/hooks/useMediaQuery'; @@ -413,7 +414,7 @@ export default function VideoDetailPage() { /> {/* 단일 SlideWebcamStage - CSS로 위치 조정 */} -
+
+ {/* 재생바 위 왼쪽 리액션 버블 */} +
+ +
); From 59ed8a795d9c8978e5328c226553e9b47787dcad Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 18 Feb 2026 19:01:21 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=AC=B8=EC=84=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-connect.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-connect.md b/docs/api-connect.md index fea04289..0ad90b9a 100644 --- a/docs/api-connect.md +++ b/docs/api-connect.md @@ -1,7 +1,7 @@ ## 1. 레이어 구조 및 역할