From 898baf7ff92e77b365a29ee7587054960da8f762 Mon Sep 17 00:00:00 2001 From: sandy Date: Sat, 31 Jan 2026 01:15:16 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20FM=5FSLD=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EA=B9=A8=EC=A7=90=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feedback/ReactionButtons.tsx | 14 ++++++++---- src/pages/FeedbackSlidePage.tsx | 25 ++++++++++++--------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/components/feedback/ReactionButtons.tsx b/src/components/feedback/ReactionButtons.tsx index f79f7cf5..9c7bb841 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 ${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 ( diff --git a/src/pages/FeedbackSlidePage.tsx b/src/pages/FeedbackSlidePage.tsx index b650a518..20db8e4c 100644 --- a/src/pages/FeedbackSlidePage.tsx +++ b/src/pages/FeedbackSlidePage.tsx @@ -155,8 +155,9 @@ export default function FeedbackSlidePage() {
+ {/** 모바일 뷰 */}
-
+
{currentSlide ? (
@@ -207,10 +208,10 @@ export default function FeedbackSlidePage() { aria-controls={panelIds.script} onClick={() => setMobileTab('script')} className={`flex-1 py-3 text-body-m-bold transition-colors ${ - mobileTab === 'script' ? 'text-main border-b border-main-variant1' : 'text-gray-600' + mobileTab === 'script' ? 'text-main border-b-2 border-main-variant1' : 'text-gray-600' }`} > - 스크립트 + 대본
-
+
{mobileTab === 'script' ? (
@@ -249,9 +252,9 @@ export default function FeedbackSlidePage() { id={panelIds.comment} role="tabpanel" aria-labelledby={tabIds.comment} - className="flex flex-col min-h-full" + className="flex flex-col min-h-0 h-full" > -
+
-
+
Date: Sat, 31 Jan 2026 01:30:36 +0900 Subject: [PATCH 02/15] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EB=B3=B4=EC=88=98=EC=84=B1=20=ED=96=A5=EC=83=81=20(#8?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feedback/ReactionButtons.tsx | 4 ++-- src/pages/FeedbackSlidePage.tsx | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/feedback/ReactionButtons.tsx b/src/components/feedback/ReactionButtons.tsx index 9c7bb841..4764e674 100644 --- a/src/components/feedback/ReactionButtons.tsx +++ b/src/components/feedback/ReactionButtons.tsx @@ -52,7 +52,7 @@ export default function ReactionButtons({
{reaction.count > 0 ? formatReactionCount(reaction.count) : ''} diff --git a/src/pages/FeedbackSlidePage.tsx b/src/pages/FeedbackSlidePage.tsx index 20db8e4c..6db8027a 100644 --- a/src/pages/FeedbackSlidePage.tsx +++ b/src/pages/FeedbackSlidePage.tsx @@ -106,6 +106,11 @@ export default function FeedbackSlidePage() { }); }, []); + const getTabClassName = (isActive: boolean) => + `flex-1 py-3 text-body-m-bold transition-colors ${ + isActive ? 'text-main border-b-2 border-main-variant1' : 'text-gray-600' + }`; + if (isLoading) { return (
@@ -207,9 +212,7 @@ export default function FeedbackSlidePage() { aria-selected={mobileTab === 'script'} aria-controls={panelIds.script} onClick={() => setMobileTab('script')} - className={`flex-1 py-3 text-body-m-bold transition-colors ${ - mobileTab === 'script' ? 'text-main border-b-2 border-main-variant1' : 'text-gray-600' - }`} + className={getTabClassName(mobileTab === 'script')} > 대본 @@ -219,11 +222,7 @@ export default function FeedbackSlidePage() { aria-selected={mobileTab === 'comment'} aria-controls={panelIds.comment} onClick={() => setMobileTab('comment')} - className={`flex-1 py-3 text-body-m-bold transition-colors ${ - mobileTab === 'comment' - ? 'text-main border-b-2 border-main-variant1' - : 'text-gray-600' - }`} + className={getTabClassName(mobileTab === 'comment')} > 댓글 {comments.length > 0 && `${comments.length}`} From 4a80d96cd804565ee8bc57b408358cc3f6e67751 Mon Sep 17 00:00:00 2001 From: sandy Date: Sat, 31 Jan 2026 01:42:51 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20FdSlidePage=EC=97=90=20FeedbackHea?= =?UTF-8?q?derLeft=20=EB=B3=B5=EA=B5=AC=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/router/Router.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) 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: }], }, { From bc902633d63edb2365f5416c29a5aa7fac99e9e5 Mon Sep 17 00:00:00 2001 From: sandy Date: Sat, 31 Jan 2026 02:02:55 +0900 Subject: [PATCH 04/15] =?UTF-8?q?style:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EA=B0=84=EA=B2=A9=20=EC=A1=B0=EC=A0=95=20?= =?UTF-8?q?(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feedback/ReactionButtons.tsx | 6 ++---- src/pages/FeedbackSlidePage.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/feedback/ReactionButtons.tsx b/src/components/feedback/ReactionButtons.tsx index 4764e674..503a8fdd 100644 --- a/src/components/feedback/ReactionButtons.tsx +++ b/src/components/feedback/ReactionButtons.tsx @@ -36,7 +36,7 @@ export default function ReactionButtons({ const total = reactions.length; const containerClass = isGrid ? `grid grid-cols-2 gap-2 justify-items-center ${className ?? ''}` - : `flex gap-1 ${showLabel ? 'flex-wrap' : 'flex-nowrap'} ${className ?? ''}`; + : `flex gap-1.5 ${showLabel ? 'flex-wrap' : 'flex-nowrap'} ${className ?? ''}`; return (
@@ -65,9 +65,7 @@ export default function ReactionButtons({ {showLabel && {config.label}}
- + {reaction.count > 0 ? formatReactionCount(reaction.count) : ''} diff --git a/src/pages/FeedbackSlidePage.tsx b/src/pages/FeedbackSlidePage.tsx index d928165c..0892a389 100644 --- a/src/pages/FeedbackSlidePage.tsx +++ b/src/pages/FeedbackSlidePage.tsx @@ -179,7 +179,7 @@ export default function FeedbackSlidePage() { )}
-
+
Date: Sat, 31 Jan 2026 02:40:14 +0900 Subject: [PATCH 05/15] =?UTF-8?q?feat:=20readonly=20prop=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feedback/slide/SlideInfoPanel.tsx | 2 +- src/components/slide/script/SlideTitle.tsx | 16 +++++++++++++++- src/pages/FeedbackSlidePage.tsx | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) 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({
- +
+ {resolvedTitle} + + ); + } + return ( ( diff --git a/src/pages/FeedbackSlidePage.tsx b/src/pages/FeedbackSlidePage.tsx index 0892a389..4eed09a7 100644 --- a/src/pages/FeedbackSlidePage.tsx +++ b/src/pages/FeedbackSlidePage.tsx @@ -237,7 +237,7 @@ export default function FeedbackSlidePage() { aria-labelledby={tabIds.script} className="px-4 py-4 overflow-y-auto" > - +

Date: Sat, 31 Jan 2026 02:40:49 +0900 Subject: [PATCH 06/15] =?UTF-8?q?design:=20=EB=8D=B0=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=ED=86=B1=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EB=B7=B0=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feedback/ReactionButtons.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/feedback/ReactionButtons.tsx b/src/components/feedback/ReactionButtons.tsx index 503a8fdd..9817f870 100644 --- a/src/components/feedback/ReactionButtons.tsx +++ b/src/components/feedback/ReactionButtons.tsx @@ -56,16 +56,28 @@ export default function ReactionButtons({ isLastOdd ? 'col-span-2 justify-self-start' : '' } ${ reaction.active - ? 'bg-gray-900 border-main-variant1 text-main-variant2 text-body-m-bold' + ? 'bg-gray-900 border-main-variant1 text-black' : 'bg-gray-200 border-gray-400 text-black hover:border-gray-600' }`} > -

+
{config.emoji} - {showLabel && {config.label}} + {showLabel && ( + + {config.label} + + )}
- + {reaction.count > 0 ? formatReactionCount(reaction.count) : ''} From 472d1d6511dd171277b8ae79d8fc304d0c377e02 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sat, 31 Jan 2026 02:42:26 +0900 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20=ED=97=A4=EB=8D=94=20layout=20shif?= =?UTF-8?q?t=20=ED=95=B4=EA=B2=B0=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/FeedbackSlidePage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/FeedbackSlidePage.tsx b/src/pages/FeedbackSlidePage.tsx index 4eed09a7..03d3d1b9 100644 --- a/src/pages/FeedbackSlidePage.tsx +++ b/src/pages/FeedbackSlidePage.tsx @@ -107,8 +107,8 @@ export default function FeedbackSlidePage() { }, []); const getTabClassName = (isActive: boolean) => - `flex-1 py-3 text-body-m-bold transition-colors ${ - isActive ? 'text-main border-b-2 border-main-variant1' : 'text-gray-600' + `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' }`; if (isLoading) { From 38350a66f53c23d845fe3075c2a14e774480175e Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sat, 31 Jan 2026 02:56:35 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/FeedbackMobileLayout.tsx | 120 +++++++++++ .../feedback/video/FeedbackVideoMobile.tsx | 146 ++++--------- src/pages/FeedbackSlidePage.tsx | 193 ++++++------------ 3 files changed, 219 insertions(+), 240 deletions(-) create mode 100644 src/components/feedback/FeedbackMobileLayout.tsx diff --git a/src/components/feedback/FeedbackMobileLayout.tsx b/src/components/feedback/FeedbackMobileLayout.tsx new file mode 100644 index 00000000..03ca88ce --- /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/video/FeedbackVideoMobile.tsx b/src/components/feedback/video/FeedbackVideoMobile.tsx index 2afc8792..e621f3d5 100644 --- a/src/components/feedback/video/FeedbackVideoMobile.tsx +++ b/src/components/feedback/video/FeedbackVideoMobile.tsx @@ -2,12 +2,9 @@ * @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 FeedbackMobileLayout from '@/components/feedback/FeedbackMobileLayout'; import ReactionButtons from '@/components/feedback/ReactionButtons'; import ScriptSection from '@/components/feedback/ScriptSection'; import SlideWebcamStage from '@/components/feedback/video/SlideWebcamStage'; @@ -17,16 +14,6 @@ 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, @@ -47,26 +34,9 @@ export default function FeedbackVideoMobile({ ctx }: FeedbackVideoMobileProps) { 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 ( -
-
+ -
- -
+ } + reactionSlot={ -
- -
- - -
- -
- {mobileTab === 'script' ? ( -
- + +
+ } + commentTabContent={ + <> +
+
- ) : ( -
-
- -
-
- setCommentDraft('')} - className="w-full" - initialValueOnFocus={timestampPrefix} - /> -
+
+ setCommentDraft('')} + className="w-full" + initialValueOnFocus={timestampPrefix} + />
- )} -
-
+ + } + commentCount={comments.length} + /> ); } diff --git a/src/pages/FeedbackSlidePage.tsx b/src/pages/FeedbackSlidePage.tsx index 03d3d1b9..5cde0e0a 100644 --- a/src/pages/FeedbackSlidePage.tsx +++ b/src/pages/FeedbackSlidePage.tsx @@ -5,12 +5,13 @@ * 슬라이드 뷰어, 댓글 목록, 리액션 버튼을 포함합니다. * 좌우 화살표 키로 슬라이드 이동이 가능합니다. */ -import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { CommentInput } from '@/components/comment'; import CommentList from '@/components/comment/CommentList'; import { Spinner } from '@/components/common'; +import FeedbackMobileLayout from '@/components/feedback/FeedbackMobileLayout'; import ReactionButtons from '@/components/feedback/ReactionButtons'; import SlideNavigation from '@/components/feedback/SlideNavigation'; import SlideViewer from '@/components/feedback/SlideViewer'; @@ -39,16 +40,7 @@ export default function FeedbackSlidePage() { const { reactions, toggleReaction } = useReactions(); const initSlide = useSlideStore((state) => state.initSlide); - const [mobileTab, setMobileTab] = useState<'script' | 'comment'>('script'); const [commentDraft, setCommentDraft] = useState(''); - const tabIds = { - script: 'feedback-tab-script', - comment: 'feedback-tab-comment', - } as const; - const panelIds = { - script: 'feedback-panel-script', - comment: 'feedback-panel-comment', - } as const; const handleAddComment = () => { if (!commentDraft.trim()) return; @@ -97,20 +89,6 @@ export default function FeedbackSlidePage() { [goToIndex], ); - 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) => - `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' - }`; - if (isLoading) { return (
@@ -161,121 +139,74 @@ export default function FeedbackSlidePage() {
- {/** 모바일 뷰 */} -
-
- {currentSlide ? ( -
- {currentSlide.title} -
- ) : ( -
- 슬라이드를 불러오는 중... -
- )} -
- -
-
- -
+ ) : ( +
슬라이드를 불러오는 중...
+ ) + } + navigationSlot={ + + } + reactionSlot={ 0 ? reactions : createDefaultReactions()} onToggleReaction={toggleReaction} showLabel={false} - className="w-full flex-wrap justify-between" - buttonClassName="min-w-[3.5rem]" /> -
- -
- - -
- -
- {mobileTab === 'script' ? ( -
- -
-

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

-
+ } + scriptTabContent={ +
+ +
+

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

- ) : ( -
-
- -
-
- setCommentDraft('')} - className="w-full" - /> -
+
+ } + commentTabContent={ + <> +
+
- )} -
-
+
+ setCommentDraft('')} + className="w-full" + /> +
+ + } + commentCount={comments.length} + />
); } From 4b86801e40c9302d07b6e7deac43f867453b01be Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sat, 31 Jan 2026 03:37:10 +0900 Subject: [PATCH 09/15] =?UTF-8?q?refactor:=20=EB=8D=B0=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=ED=86=B1=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B3=91=ED=95=A9=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/video/FeedbackVideoDesktop.tsx | 87 ----------- .../feedback/video/FeedbackVideoMobile.tsx | 91 ------------ src/pages/FeedbackVideoPage.tsx | 136 +++++++++++++++++- src/stores/videoFeedbackStore.ts | 2 +- 4 files changed, 131 insertions(+), 185 deletions(-) delete mode 100644 src/components/feedback/video/FeedbackVideoDesktop.tsx delete mode 100644 src/components/feedback/video/FeedbackVideoMobile.tsx diff --git a/src/components/feedback/video/FeedbackVideoDesktop.tsx b/src/components/feedback/video/FeedbackVideoDesktop.tsx deleted file mode 100644 index 05c09d06..00000000 --- a/src/components/feedback/video/FeedbackVideoDesktop.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @file FeedbackVideoDesktop.tsx - * @description 비디오 피드백 페이지 - 데스크톱 뷰 - */ -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 FeedbackVideoDesktopProps { - ctx: FeedbackVideoContext; -} - -export default function FeedbackVideoDesktop({ ctx }: FeedbackVideoDesktopProps) { - const { - isLoading, - currentTime, - projectSlides, - slideChangeTimes, - comments, - reactions, - commentDraft, - timestampPrefix, - webcamVideoUrl, - updateCurrentTime, - requestSeek, - setCommentDraft, - handleAddComment, - handleGoToTimeRef, - addReply, - deleteComment, - toggleReaction, - } = ctx; - - return ( -
-
- {/* 슬라이드 + 웹캠 + 재생바 (오버레이) */} - - - {/* 대본 섹션 */} - -
- - -
- ); -} diff --git a/src/components/feedback/video/FeedbackVideoMobile.tsx b/src/components/feedback/video/FeedbackVideoMobile.tsx deleted file mode 100644 index e621f3d5..00000000 --- a/src/components/feedback/video/FeedbackVideoMobile.tsx +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @file FeedbackVideoMobile.tsx - * @description 비디오 피드백 페이지 - 모바일 뷰 - */ -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 type { FeedbackVideoContext } from '@/hooks/useFeedbackVideo'; - -interface FeedbackVideoMobileProps { - ctx: FeedbackVideoContext; -} - -export default function FeedbackVideoMobile({ ctx }: FeedbackVideoMobileProps) { - const { - currentTime, - projectSlides, - slideChangeTimes, - comments, - reactions, - commentDraft, - timestampPrefix, - webcamVideoUrl, - updateCurrentTime, - requestSeek, - setCommentDraft, - handleAddComment, - handleGoToTimeRef, - addReply, - deleteComment, - toggleReaction, - } = ctx; - - return ( - - } - reactionSlot={ - - } - scriptTabContent={ -
- -
- } - commentTabContent={ - <> -
- -
-
- setCommentDraft('')} - className="w-full" - initialValueOnFocus={timestampPrefix} - /> -
- - } - commentCount={comments.length} - /> - ); -} diff --git a/src/pages/FeedbackVideoPage.tsx b/src/pages/FeedbackVideoPage.tsx index cc91ed9a..c4219bfe 100644 --- a/src/pages/FeedbackVideoPage.tsx +++ b/src/pages/FeedbackVideoPage.tsx @@ -1,19 +1,143 @@ /** * @file FeedbackVideoPage.tsx - * @description 비디오 피드백 페이지 - 데스크톱/모바일 뷰 분기 담당 + * @description 비디오 피드백 페이지 + * + * 데스크톱과 모바일 뷰를 모두 포함하며, 반응형으로 UI를 렌더링합니다. */ -import FeedbackVideoDesktop from '@/components/feedback/video/FeedbackVideoDesktop'; -import FeedbackVideoMobile from '@/components/feedback/video/FeedbackVideoMobile'; +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; return (
- {isDesktop ? : } + {/* 데스크톱 뷰 */} +
+
+ + +
+ + +
+ + {/* 모바일 뷰 */} +
+ + } + reactionSlot={ + + } + scriptTabContent={ +
+ +
+ } + commentTabContent={ + <> +
+ +
+
+ setCommentDraft('')} + className="w-full" + initialValueOnFocus={timestampPrefix} + /> +
+ + } + commentCount={comments.length} + /> +
); } 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'), From 9a77911345dd36d9775adfeec21a81105bad5666 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sat, 31 Jan 2026 03:59:46 +0900 Subject: [PATCH 10/15] =?UTF-8?q?design:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EB=B2=94=EC=9C=84=20=ED=99=95?= =?UTF-8?q?=EB=8C=80=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/styles/index.css | 2 ++ 1 file changed, 2 insertions(+) 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); From 8f32c9de139ba3d7fabf2ef817ed0cf01cceda1e Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sun, 1 Feb 2026 00:45:17 +0900 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20CLAUDE.md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From c3de84e02d0c2fec8c3d50532f9448f37f71173c Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sun, 1 Feb 2026 00:53:10 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat:=20Claude=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/FeedbackMobileLayout.tsx | 2 +- src/components/feedback/ReactionButtons.tsx | 18 +--- src/pages/FeedbackVideoPage.tsx | 100 +++++++++--------- 3 files changed, 52 insertions(+), 68 deletions(-) diff --git a/src/components/feedback/FeedbackMobileLayout.tsx b/src/components/feedback/FeedbackMobileLayout.tsx index 03ca88ce..94d445c4 100644 --- a/src/components/feedback/FeedbackMobileLayout.tsx +++ b/src/components/feedback/FeedbackMobileLayout.tsx @@ -99,7 +99,7 @@ export default function FeedbackMobileLayout({ id={PANEL_IDS.script} role="tabpanel" aria-labelledby={TAB_IDS.script} - className="h-full overflow-y-auto" + className="h-full flex flex-col" > {scriptTabContent}
diff --git a/src/components/feedback/ReactionButtons.tsx b/src/components/feedback/ReactionButtons.tsx index 9817f870..5748f1dd 100644 --- a/src/components/feedback/ReactionButtons.tsx +++ b/src/components/feedback/ReactionButtons.tsx @@ -56,28 +56,16 @@ export default function ReactionButtons({ isLastOdd ? 'col-span-2 justify-self-start' : '' } ${ reaction.active - ? 'bg-gray-900 border-main-variant1 text-black' + ? 'bg-gray-900 border-main-variant1 text-main-variant2 text-body-m-bold' : 'bg-gray-200 border-gray-400 text-black hover:border-gray-600' }`} >
{config.emoji} - {showLabel && ( - - {config.label} - - )} + {showLabel && {config.label}}
- + {reaction.count > 0 ? formatReactionCount(reaction.count) : ''} diff --git a/src/pages/FeedbackVideoPage.tsx b/src/pages/FeedbackVideoPage.tsx index c4219bfe..a3e66632 100644 --- a/src/pages/FeedbackVideoPage.tsx +++ b/src/pages/FeedbackVideoPage.tsx @@ -84,60 +84,56 @@ export default function FeedbackVideoPage() {
{/* 모바일 뷰 */} -
- - } - reactionSlot={ - - } - scriptTabContent={ -
- + } + reactionSlot={ + + } + scriptTabContent={ + + } + commentTabContent={ + <> +
+
- } - commentTabContent={ - <> -
- -
-
- setCommentDraft('')} - className="w-full" - initialValueOnFocus={timestampPrefix} - /> -
- - } - commentCount={comments.length} - /> -
+
+ setCommentDraft('')} + className="w-full" + initialValueOnFocus={timestampPrefix} + /> +
+ + } + commentCount={comments.length} + />
); } From 566ff465acedee961530683af3e883ef35e42dd4 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sun, 1 Feb 2026 01:19:12 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20css=EB=A1=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20SlideWebcamStage=20=EC=9C=84=EC=B9=98=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback/FeedbackMobileLayout.tsx | 2 +- .../feedback/video/SlideWebcamStage.tsx | 13 ++- src/pages/FeedbackVideoPage.tsx | 91 +++++++++++++++---- 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/components/feedback/FeedbackMobileLayout.tsx b/src/components/feedback/FeedbackMobileLayout.tsx index 94d445c4..0f936aae 100644 --- a/src/components/feedback/FeedbackMobileLayout.tsx +++ b/src/components/feedback/FeedbackMobileLayout.tsx @@ -59,7 +59,7 @@ export default function FeedbackMobileLayout({ return (
{/* 미디어 영역 */} -
{mediaSlot}
+
{mediaSlot}
{/* 콘텐츠 영역 */}
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/pages/FeedbackVideoPage.tsx b/src/pages/FeedbackVideoPage.tsx index a3e66632..2329879d 100644 --- a/src/pages/FeedbackVideoPage.tsx +++ b/src/pages/FeedbackVideoPage.tsx @@ -3,7 +3,10 @@ * @description 비디오 피드백 페이지 * * 데스크톱과 모바일 뷰를 모두 포함하며, 반응형으로 UI를 렌더링합니다. + * CSS-only 방식으로 단일 비디오 요소의 위치를 조정하여 심리스한 전환을 지원합니다. */ +import { useEffect, useRef, useState } from 'react'; + import { CommentInput } from '@/components/comment'; import CommentList from '@/components/comment/CommentList'; import FeedbackMobileLayout from '@/components/feedback/FeedbackMobileLayout'; @@ -11,8 +14,10 @@ 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, @@ -34,17 +39,68 @@ export default function FeedbackVideoPage() { 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 (
{/* 데스크톱 뷰 */}
- + {/* 비디오 위치 placeholder */} +
+
+
- } + mediaSlot={
} reactionSlot={ + + {/* 단일 SlideWebcamStage - CSS로 위치 조정 */} +
+ +
); } From 9bb838446f71b55b27a48c8582209579103c52e6 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sun, 1 Feb 2026 01:23:36 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20=ED=94=8C=EB=9E=98=EA=B7=B8=20=EC=97=AD?= =?UTF-8?q?=EC=B9=98=20=EC=A6=9D=EA=B0=80=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/feedback/ScriptSection.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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); } }} From 98f1830762f277c03520297888c9d20ec4f8e571 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Sun, 1 Feb 2026 01:28:21 +0900 Subject: [PATCH 15/15] =?UTF-8?q?design:=20error=20=EC=97=91=EC=84=BC?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=8B=AC=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/projects/DeleteProjectModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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}

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

-
+