diff --git a/CLAUDE.md b/CLAUDE.md index 271dd21d..00a83f3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ npm run prettier:fix # Auto-fix formatting ### State Management (Zustand) -The app uses **5 domain-specific stores**, each with distinct responsibilities: +The app uses **6 domain-specific stores**, each with distinct responsibilities: - **`authStore`** - Authentication state (user, tokens, login modal) - Uses `persist` middleware for session retention @@ -49,6 +49,7 @@ The app uses **5 domain-specific stores**, each with distinct responsibilities: - **`homeStore`** - Home page UI state (search, view mode, sort) - **`shareStore`** - Share modal workflow state +- **`videoFeedbackStore`** - Video feedback page state **Key Pattern**: Use selector hooks to prevent unnecessary re-renders. Instead of `useSlideStore((s) => s.field)`, use `useSlideTitle()`. @@ -158,7 +159,7 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `design`, `c Format: `type/description-issue` (e.g., `feat/login-12`) -Note: Use `-` (hyphen) instead of `#` for issue numbers to maintain GitHub branch name compatibility. +Note: While `CONVENTION.md` shows `#` for issue numbers (`feat/login#12`), using `-` (hyphen) is recommended for GitHub compatibility since `#` can cause issues in some shell environments. ## Development Patterns diff --git a/src/components/feedback/FeedbackMobileLayout.tsx b/src/components/feedback/FeedbackMobileLayout.tsx new file mode 100644 index 00000000..0f936aae --- /dev/null +++ b/src/components/feedback/FeedbackMobileLayout.tsx @@ -0,0 +1,120 @@ +/** + * @file FeedbackMobileLayout.tsx + * @description 피드백 페이지 공통 모바일 레이아웃 + * + * Slide/Video 피드백 페이지에서 공통으로 사용하는 모바일 레이아웃입니다. + * 슬롯 기반 설계로 각 페이지에서 필요한 콘텐츠를 주입합니다. + */ +import { type KeyboardEvent, type ReactNode, useCallback, useState } from 'react'; + +interface FeedbackMobileLayoutProps { + /** 미디어 영역 (슬라이드 이미지 or 비디오) */ + mediaSlot: ReactNode; + /** 네비게이션 영역 - optional (slide만 사용) */ + navigationSlot?: ReactNode; + /** 리액션 영역 */ + reactionSlot: ReactNode; + /** 대본 탭 콘텐츠 */ + scriptTabContent: ReactNode; + /** 댓글 탭 콘텐츠 */ + commentTabContent: ReactNode; + /** 탭에 표시할 댓글 수 */ + commentCount: number; +} + +const TAB_IDS = { + script: 'feedback-mobile-tab-script', + comment: 'feedback-mobile-tab-comment', +} as const; + +const PANEL_IDS = { + script: 'feedback-mobile-panel-script', + comment: 'feedback-mobile-panel-comment', +} as const; + +export default function FeedbackMobileLayout({ + mediaSlot, + navigationSlot, + reactionSlot, + scriptTabContent, + commentTabContent, + commentCount, +}: FeedbackMobileLayoutProps) { + const [activeTab, setActiveTab] = useState<'script' | 'comment'>('script'); + + const handleTabKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + setActiveTab((prev) => { + if (prev === 'script') return event.key === 'ArrowRight' ? 'comment' : 'script'; + return event.key === 'ArrowLeft' ? 'script' : 'comment'; + }); + }, []); + + const getTabClassName = (isActive: boolean) => + `flex-1 py-3 text-body-m-bold transition-colors border-b-2 ${ + isActive ? 'text-main-variant1 border-main-variant1' : 'text-black border-gray-200' + }`; + + return ( +
+ {/* 미디어 영역 */} +
{mediaSlot}
+ + {/* 콘텐츠 영역 */} +
+
+ {navigationSlot ?
{navigationSlot}
:
} +
{reactionSlot}
+
+ + {/* 탭 메뉴 */} +
+ + +
+ + {/* 탭 콘텐츠 */} +
+ {activeTab === 'script' ? ( +
+ {scriptTabContent} +
+ ) : ( +
+ {commentTabContent} +
+ )} +
+
+
+ ); +} diff --git a/src/components/feedback/ReactionButtons.tsx b/src/components/feedback/ReactionButtons.tsx index f79f7cf5..5748f1dd 100644 --- a/src/components/feedback/ReactionButtons.tsx +++ b/src/components/feedback/ReactionButtons.tsx @@ -36,19 +36,23 @@ export default function ReactionButtons({ const total = reactions.length; const containerClass = isGrid ? `grid grid-cols-2 gap-2 justify-items-center ${className ?? ''}` - : `flex gap-2 ${showLabel ? 'flex-wrap' : 'flex-nowrap'} ${className ?? ''}`; + : `flex gap-1.5 ${showLabel ? 'flex-wrap' : 'flex-nowrap'} ${className ?? ''}`; return (
{reactions.map((reaction, index) => { const config = REACTION_CONFIG[reaction.type]; const isLastOdd = isGrid && total % 2 === 1 && index === total - 1; + const baseBtn = + 'flex items-center justify-between px-2 py-2 rounded-full border transition text-body-m focus-visible:outline-2 focus-visible:outline-main'; + + const widthClass = showLabel ? 'w-42.25' : 'w-auto flex-1'; return (
- + {reaction.count > 0 ? formatReactionCount(reaction.count) : ''} diff --git a/src/components/feedback/ScriptSection.tsx b/src/components/feedback/ScriptSection.tsx index 4c103efd..aac8ad9d 100644 --- a/src/components/feedback/ScriptSection.tsx +++ b/src/components/feedback/ScriptSection.tsx @@ -60,10 +60,10 @@ export default function ScriptSection({ block: 'center', }); - // 스크롤 완료 후 플래그 해제 (300ms 후) + // 스크롤 완료 후 플래그 해제 (smooth scroll은 거리에 따라 500-1000ms 소요) const timer = setTimeout(() => { isScrollingRef.current = false; - }, 300); + }, 800); return () => clearTimeout(timer); }, [currentSlideIndex, autoScroll]); @@ -152,7 +152,7 @@ export default function ScriptSection({ }); setTimeout(() => { isScrollingRef.current = false; - }, 300); + }, 800); setAutoScroll(true); } }} diff --git a/src/components/feedback/slide/SlideInfoPanel.tsx b/src/components/feedback/slide/SlideInfoPanel.tsx index b3703427..939269e1 100644 --- a/src/components/feedback/slide/SlideInfoPanel.tsx +++ b/src/components/feedback/slide/SlideInfoPanel.tsx @@ -30,7 +30,7 @@ export default function SlideInfoPanel({
- +
-
- {/* 슬라이드 + 웹캠 + 재생바 (오버레이) */} - - - {/* 대본 섹션 */} - -
- - -
- ); -} diff --git a/src/components/feedback/video/FeedbackVideoMobile.tsx b/src/components/feedback/video/FeedbackVideoMobile.tsx deleted file mode 100644 index 2afc8792..00000000 --- a/src/components/feedback/video/FeedbackVideoMobile.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/** - * @file FeedbackVideoMobile.tsx - * @description 비디오 피드백 페이지 - 모바일 뷰 - */ -import { type KeyboardEvent, useCallback, useState } from 'react'; - -import clsx from 'clsx'; - -import { CommentInput } from '@/components/comment'; -import CommentList from '@/components/comment/CommentList'; -import ReactionButtons from '@/components/feedback/ReactionButtons'; -import ScriptSection from '@/components/feedback/ScriptSection'; -import SlideWebcamStage from '@/components/feedback/video/SlideWebcamStage'; -import type { FeedbackVideoContext } from '@/hooks/useFeedbackVideo'; - -interface FeedbackVideoMobileProps { - ctx: FeedbackVideoContext; -} - -const TAB_IDS = { - script: 'feedback-video-tab-script', - comment: 'feedback-video-tab-comment', -} as const; - -const PANEL_IDS = { - script: 'feedback-video-panel-script', - comment: 'feedback-video-panel-comment', -} as const; - -export default function FeedbackVideoMobile({ ctx }: FeedbackVideoMobileProps) { - const { - currentTime, - projectSlides, - slideChangeTimes, - comments, - reactions, - commentDraft, - timestampPrefix, - webcamVideoUrl, - updateCurrentTime, - requestSeek, - setCommentDraft, - handleAddComment, - handleGoToTimeRef, - addReply, - deleteComment, - toggleReaction, - } = ctx; - - const [mobileTab, setMobileTab] = useState<'script' | 'comment'>('script'); - - const handleTabKeyDown = useCallback((event: KeyboardEvent) => { - if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; - event.preventDefault(); - setMobileTab((prev) => { - if (prev === 'script') return event.key === 'ArrowRight' ? 'comment' : 'script'; - return event.key === 'ArrowLeft' ? 'script' : 'comment'; - }); - }, []); - - const getTabClassName = (isActive: boolean) => - clsx( - 'flex-1 py-3 max-[350px]:py-2 text-body-m-bold max-[350px]:text-body-s transition-colors', - isActive ? 'text-main border-b-2 border-main-variant1' : 'text-gray-600', - ); - - return ( -
-
- -
- -
- -
- -
- - -
- -
- {mobileTab === 'script' ? ( -
- -
- ) : ( -
-
- -
-
- setCommentDraft('')} - className="w-full" - initialValueOnFocus={timestampPrefix} - /> -
-
- )} -
-
- ); -} diff --git a/src/components/feedback/video/SlideWebcamStage.tsx b/src/components/feedback/video/SlideWebcamStage.tsx index 08c147c2..bba36bbe 100644 --- a/src/components/feedback/video/SlideWebcamStage.tsx +++ b/src/components/feedback/video/SlideWebcamStage.tsx @@ -17,6 +17,8 @@ import { useVideoSync } from '@/hooks/useVideoSync'; import type { Slide } from '@/types/slide'; import { getSlideIndexFromTime } from '@/utils/video'; +const LAYOUT_STORAGE_KEY = 'feedback-video-layout'; + // 공통 미디어 컨테이너 (Slide & Webcam) interface MediaBoxProps { isMain: boolean; // 현재 이 미디어가 메인 화면인가? @@ -103,10 +105,19 @@ export default function SlideWebcamStage({ // 비디오 동기화 훅 (콜백 ref, duration, currentTime, seekTo 처리) const { setVideoRef, videoElement, duration, currentTime } = useVideoSync(); - const [layout, setLayout] = useState<'slide-main' | 'webcam-main'>('slide-main'); + // 레이아웃 상태 (localStorage에 저장) + const [layout, setLayout] = useState<'slide-main' | 'webcam-main'>(() => { + const saved = localStorage.getItem(LAYOUT_STORAGE_KEY); + return saved === 'webcam-main' ? 'webcam-main' : 'slide-main'; + }); const isSlideMain = layout === 'slide-main'; const showPip = !disablePip; + // 레이아웃 변경 시 localStorage에 저장 + useEffect(() => { + localStorage.setItem(LAYOUT_STORAGE_KEY, layout); + }, [layout]); + // onTimeUpdate 콜백 호출 useEffect(() => { onTimeUpdate?.(currentTime); diff --git a/src/components/projects/DeleteProjectModal.tsx b/src/components/projects/DeleteProjectModal.tsx index 1e1f3a12..5f097486 100644 --- a/src/components/projects/DeleteProjectModal.tsx +++ b/src/components/projects/DeleteProjectModal.tsx @@ -28,9 +28,9 @@ export default function DeleteProjectModal({

{projectTitle}

발표를 정말 삭제하시겠습니까?

-
+
- -
- -
- {mobileTab === 'script' ? ( -
- -
-

- {currentSlide?.script || '대본이 없습니다.'} -

-
+ } + scriptTabContent={ +
+ +
+

+ {currentSlide?.script || '대본이 없습니다.'} +

- ) : ( -
-
- -
-
- setCommentDraft('')} - className="w-full" - /> -
+
+ } + commentTabContent={ + <> +
+
- )} -
-
+
+ setCommentDraft('')} + className="w-full" + /> +
+ + } + commentCount={comments.length} + />
); } diff --git a/src/pages/FeedbackVideoPage.tsx b/src/pages/FeedbackVideoPage.tsx index cc91ed9a..2329879d 100644 --- a/src/pages/FeedbackVideoPage.tsx +++ b/src/pages/FeedbackVideoPage.tsx @@ -1,19 +1,198 @@ /** * @file FeedbackVideoPage.tsx - * @description 비디오 피드백 페이지 - 데스크톱/모바일 뷰 분기 담당 + * @description 비디오 피드백 페이지 + * + * 데스크톱과 모바일 뷰를 모두 포함하며, 반응형으로 UI를 렌더링합니다. + * CSS-only 방식으로 단일 비디오 요소의 위치를 조정하여 심리스한 전환을 지원합니다. */ -import FeedbackVideoDesktop from '@/components/feedback/video/FeedbackVideoDesktop'; -import FeedbackVideoMobile from '@/components/feedback/video/FeedbackVideoMobile'; +import { useEffect, useRef, useState } from 'react'; + +import { CommentInput } from '@/components/comment'; +import CommentList from '@/components/comment/CommentList'; +import FeedbackMobileLayout from '@/components/feedback/FeedbackMobileLayout'; +import ReactionButtons from '@/components/feedback/ReactionButtons'; +import ScriptSection from '@/components/feedback/ScriptSection'; +import SlideWebcamStage from '@/components/feedback/video/SlideWebcamStage'; import { useFeedbackVideo } from '@/hooks/useFeedbackVideo'; import { useIsDesktop } from '@/hooks/useMediaQuery'; export default function FeedbackVideoPage() { const isDesktop = useIsDesktop(); const ctx = useFeedbackVideo(); + const { + isLoading, + currentTime, + projectSlides, + slideChangeTimes, + comments, + reactions, + commentDraft, + timestampPrefix, + webcamVideoUrl, + updateCurrentTime, + requestSeek, + setCommentDraft, + handleAddComment, + handleGoToTimeRef, + addReply, + deleteComment, + toggleReaction, + } = ctx; + + // 비디오 위치 계산을 위한 refs + const desktopPlaceholderRef = useRef(null); + const mobilePlaceholderRef = useRef(null); + const [videoStyle, setVideoStyle] = useState({ + position: 'fixed', + opacity: 0, // 위치 계산 전까지 숨김 + }); + + // 비디오 위치 업데이트 + useEffect(() => { + const updateVideoPosition = () => { + // 현재 viewport에 맞는 placeholder 우선 사용 + 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, + left: rect.left, + width: rect.width, + height: rect.height, + zIndex: 20, + opacity: 1, + }); + }; + + // 레이아웃 안정화 후 위치 계산 + const timers = [0, 50, 100, 200].map((delay) => setTimeout(updateVideoPosition, delay)); + + // 두 placeholder 모두 관찰 + const observer = new ResizeObserver(updateVideoPosition); + if (desktopPlaceholderRef.current) observer.observe(desktopPlaceholderRef.current); + if (mobilePlaceholderRef.current) observer.observe(mobilePlaceholderRef.current); + + // 리사이즈, 스크롤 이벤트 리스너 + window.addEventListener('resize', updateVideoPosition); + window.addEventListener('scroll', updateVideoPosition, true); + + return () => { + timers.forEach(clearTimeout); + observer.disconnect(); + window.removeEventListener('resize', updateVideoPosition); + window.removeEventListener('scroll', updateVideoPosition, true); + }; + }, [isDesktop]); return (
- {isDesktop ? : } + {/* 데스크톱 뷰 */} +
+
+ {/* 비디오 위치 placeholder */} +
+
+
+ +
+ + +
+ + {/* 모바일 뷰 */} + } + reactionSlot={ + + } + scriptTabContent={ + + } + commentTabContent={ + <> +
+ +
+
+ setCommentDraft('')} + className="w-full" + initialValueOnFocus={timestampPrefix} + /> +
+ + } + commentCount={comments.length} + /> + + {/* 단일 SlideWebcamStage - CSS로 위치 조정 */} +
+ +
); } diff --git a/src/router/Router.tsx b/src/router/Router.tsx index 4d65bfec..4564b039 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -52,17 +52,7 @@ export const router = createBrowserRouter([ }, { path: '/feedback/slide/:projectId', - element: ( - - - 발표 피드백 - - } - /> - ), + element: } />, children: [{ index: true, element: }], }, { diff --git a/src/stores/videoFeedbackStore.ts b/src/stores/videoFeedbackStore.ts index 74c6f629..75af4354 100644 --- a/src/stores/videoFeedbackStore.ts +++ b/src/stores/videoFeedbackStore.ts @@ -84,7 +84,7 @@ export const useVideoFeedbackStore = create()( updateCurrentTime: (time) => set({ currentTime: time }, false, 'video/updateTime'), - requestSeek: (time) => set({ seekTo: time }, false, 'video/requestSeek'), + requestSeek: (time) => set({ seekTo: time, currentTime: time }, false, 'video/requestSeek'), clearSeek: () => set({ seekTo: null }, false, 'video/clearSeek'), diff --git a/src/styles/index.css b/src/styles/index.css index b3f14245..c26829aa 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -4,6 +4,8 @@ @import "./toast.css"; @theme { + --breakpoint-md: 1024px; + --color-white: var(--color-white); --color-black: var(--color-black); --color-gray-100: var(--color-gray-100);