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로 위치 조정 */}
+
+ {/* 재생바 위 왼쪽 리액션 버블 */}
+
+
+
);