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. 레이어 구조 및 역할 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/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 5bbf2ed3..df50378a 100644 --- a/src/pages/VideoDetailPage.tsx +++ b/src/pages/VideoDetailPage.tsx @@ -1,21 +1,24 @@ -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 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'; 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() { @@ -25,6 +28,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); @@ -32,12 +36,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([]); @@ -45,6 +44,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', @@ -225,62 +225,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 +256,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); @@ -305,8 +265,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, @@ -317,10 +284,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; @@ -349,66 +329,104 @@ 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" > -
-
-
+
+
+
+
+
-
-
- +
+

의견

+
+
+
-
+
+ setCommentDraft('')} + disabled={isSubmittingComment} + className="w-full" + /> +
+
- + } + commentTabContent={ + <> +
+ +
+
+ setCommentDraft('')} + disabled={isSubmittingComment} + className="w-full" + /> +
+ + } + commentCount={countTreeComments(comments)} + /> -
+ {/* 단일 SlideWebcamStage - CSS로 위치 조정 */} +
+ {/* 재생바 위 왼쪽 리액션 버블 */} +
+ +
);