Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()`.

Expand Down Expand Up @@ -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

Expand Down
120 changes: 120 additions & 0 deletions src/components/feedback/FeedbackMobileLayout.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>) => {
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 (
<div className="flex md:hidden flex-1 flex-col overflow-hidden">
{/* 미디어 영역 */}
<div className="shrink-0 w-full bg-gray-400">{mediaSlot}</div>

{/* 콘텐츠 영역 */}
<div className="flex-1 min-h-0 flex flex-col bg-gray-100 overflow-hidden">
<div className="px-5 shrink-0">
{navigationSlot ? <div className="py-4">{navigationSlot}</div> : <div className="h-4" />}
<div className="py-2">{reactionSlot}</div>
</div>

{/* 탭 메뉴 */}
<div role="tablist" aria-label="대본/댓글 탭" className="flex" onKeyDown={handleTabKeyDown}>
<button
role="tab"
id={TAB_IDS.script}
aria-selected={activeTab === 'script'}
aria-controls={PANEL_IDS.script}
onClick={() => setActiveTab('script')}
className={getTabClassName(activeTab === 'script')}
>
대본
</button>
<button
role="tab"
id={TAB_IDS.comment}
aria-selected={activeTab === 'comment'}
aria-controls={PANEL_IDS.comment}
onClick={() => setActiveTab('comment')}
className={getTabClassName(activeTab === 'comment')}
>
댓글 {commentCount > 0 && commentCount}
</button>
</div>

{/* 탭 콘텐츠 */}
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
{activeTab === 'script' ? (
<div
id={PANEL_IDS.script}
role="tabpanel"
aria-labelledby={TAB_IDS.script}
className="h-full flex flex-col"
>
{scriptTabContent}
</div>
) : (
<div
id={PANEL_IDS.comment}
role="tabpanel"
aria-labelledby={TAB_IDS.comment}
className="flex flex-col h-full overflow-hidden"
>
{commentTabContent}
</div>
)}
</div>
</div>
</div>
);
}
10 changes: 7 additions & 3 deletions src/components/feedback/ReactionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={containerClass}>
{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 (
<button
key={reaction.type}
onClick={() => onToggleReaction(reaction.type)}
className={`flex items-center justify-between w-42.25 px-3 py-2 rounded-full border transition text-body-m focus-visible:outline-2 focus-visible:outline-main ${buttonClassName ?? ''} ${
className={`${baseBtn} ${widthClass} ${buttonClassName ?? ''} ${
isLastOdd ? 'col-span-2 justify-self-start' : ''
} ${
reaction.active
Expand All @@ -61,7 +65,7 @@ export default function ReactionButtons({
{showLabel && <span className="whitespace-nowrap">{config.label}</span>}
</div>

<span className={reaction.active ? 'font-semibold' : ''}>
<span className="tabular-nums text-right min-w-0">
{reaction.count > 0 ? formatReactionCount(reaction.count) : ''}
</span>
</button>
Expand Down
6 changes: 3 additions & 3 deletions src/components/feedback/ScriptSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -152,7 +152,7 @@ export default function ScriptSection({
});
setTimeout(() => {
isScrollingRef.current = false;
}, 300);
}, 800);
setAutoScroll(true);
}
}}
Expand Down
2 changes: 1 addition & 1 deletion src/components/feedback/slide/SlideInfoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function SlideInfoPanel({
<div className="shrink-0 flex flex-col gap-4 px-5 py-4">
<div className="flex justify-between items-center gap-4">
<div className="min-w-0">
<SlideTitle fallbackTitle={`슬라이드 ${slideIndex + 1}`} />
<SlideTitle fallbackTitle={`슬라이드 ${slideIndex + 1}`} readOnly />
</div>

<SlideNavigation
Expand Down
87 changes: 0 additions & 87 deletions src/components/feedback/video/FeedbackVideoDesktop.tsx

This file was deleted.

Loading
Loading