Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
6 changes: 3 additions & 3 deletions src/api/dto/analytics.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface RecordExitRequestDto {
export interface SlideAnalyticsDto {
slideId: string;
slideNum: number;
title: string;
title: string | null;
viewCount: number;
exitCount: number;
exitRate: number;
Expand Down Expand Up @@ -117,7 +117,7 @@ export interface ReadPresentationAnalyticsSummaryDto {
export interface SlideRetentionDto {
slideId: string;
slideNum: number;
title: string;
title: string | null;
sessionCount: number;
retentionRate: number;
}
Expand Down Expand Up @@ -185,7 +185,7 @@ export interface RecentCommentUserDto {
export interface RecentCommentSlideDto {
slideId: string;
slideNum: number;
title: string;
title: string | null;
imageUrl: string;
}

Expand Down
1 change: 1 addition & 0 deletions src/api/dto/scripts.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface RestoreScriptResponseDto {
*/
export interface ProjectScriptItemDto {
slideId: string;
title?: string | null;
scriptText: string;
}

Expand Down
6 changes: 3 additions & 3 deletions src/api/dto/slides.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
export interface CreateSlideResponseDto {
slideId: string;
projectId: string;
title: string;
title: string | null;
slideNum: number;
imageUrl: string;
createdAt: string;
Expand All @@ -24,7 +24,7 @@ export interface UpdateSlideTitleRequestDto {
export interface GetSlideResponseDto {
slideId: string;
projectId: string;
title: string;
title: string | null;
slideNum: number;
imageUrl: string;
prevSlideId: string | null;
Expand All @@ -37,7 +37,7 @@ export interface GetSlideResponseDto {
*/
export interface UpdateSlideResponseDto {
slideId: string;
title: string;
title: string | null;
slideNum: number;
imageUrl: string;
updatedAt: string;
Expand Down
1 change: 1 addition & 0 deletions src/api/dto/video.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export interface ReadVideoCommentsAllResponseDto {
*/
export interface VideoSlideTimelineItemDto {
slideId: string;
title: string | null;
timestampMs: number;
}

Expand Down
3 changes: 2 additions & 1 deletion src/components/comment/Comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { UserAvatar } from '@/components/common';
import { useAuthStore } from '@/stores/authStore';
import type { Comment as CommentType } from '@/types/comment';
import { formatRelativeTime, formatVideoTimestamp } from '@/utils/format';
import { getSlideTitle } from '@/utils/slideTitle';

import { useCommentContext } from './CommentContext';
import CommentInput from './CommentInput';
Expand Down Expand Up @@ -119,7 +120,7 @@ function Comment({ comment, isIndented = false, rootCommentId }: CommentProps) {
const commentRef = comment.ref;
const refLabel = commentRef
? commentRef.kind === 'slide'
? `슬라이드 ${commentRef.index + 1}`
? getSlideTitle(undefined, commentRef.index + 1)
: formatVideoTimestamp(commentRef.seconds)
: null;

Expand Down
63 changes: 63 additions & 0 deletions src/components/common/TitleEditorPopover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

import { TitleEditorPopover } from './TitleEditorPopover';

describe('TitleEditorPopover', () => {
it('re-initializes input value from inputTitle whenever popover opens', async () => {
const user = userEvent.setup();

render(
<TitleEditorPopover
title="슬라이드 1"
inputTitle=""
inputPlaceholder="슬라이드 1"
ariaLabel="슬라이드 이름 변경"
/>,
);

const triggerButton = screen.getByRole('button', { name: '슬라이드 이름 변경' });

await user.click(triggerButton);
const input = screen.getByRole('textbox', { name: '슬라이드 이름 변경' });
expect(input).toHaveValue('');
expect(input).toHaveAttribute('placeholder', '슬라이드 1');

await user.type(input, '임시 제목');
expect(input).toHaveValue('임시 제목');

await user.click(triggerButton); // close
await user.click(triggerButton); // reopen

expect(screen.getByRole('textbox', { name: '슬라이드 이름 변경' })).toHaveValue('');
});

it('uses title as default input value when title exists', async () => {
const user = userEvent.setup();

render(
<TitleEditorPopover
title="도입"
inputTitle="도입"
inputPlaceholder="슬라이드 1"
ariaLabel="슬라이드 이름 변경"
/>,
);

const triggerButton = screen.getByRole('button', { name: '슬라이드 이름 변경' });

await user.click(triggerButton);
const input = screen.getByRole('textbox', { name: '슬라이드 이름 변경' });
expect(input).toHaveValue('도입');

await user.clear(input);
await user.type(input, '변경 전 임시 값');
expect(input).toHaveValue('변경 전 임시 값');

await user.click(triggerButton); // close
await user.click(triggerButton); // reopen

expect(screen.getByRole('textbox', { name: '슬라이드 이름 변경' })).toHaveValue('도입');
});
});
19 changes: 14 additions & 5 deletions src/components/common/TitleEditorPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* readOnlyContent가 제공되면 InfoIcon + 정보 팝오버를 표시하고,
* 없으면 ArrowDownIcon + 편집 팝오버를 표시합니다.
*/
import { type ReactNode, useEffect, useState } from 'react';
import { type ReactNode, useState } from 'react';

import clsx from 'clsx';

Expand All @@ -17,6 +17,8 @@ import { TextField } from './TextField';

interface TitleEditorPopoverProps {
title: string;
inputTitle?: string;
inputPlaceholder?: string;
onSave?: (newTitle: string, close: () => void) => void;
readOnlyContent?: ReactNode;
isCollapsed?: boolean;
Expand All @@ -28,6 +30,8 @@ interface TitleEditorPopoverProps {

export function TitleEditorPopover({
title,
inputTitle,
inputPlaceholder,
onSave,
readOnlyContent,
isCollapsed = false,
Expand All @@ -36,11 +40,14 @@ export function TitleEditorPopover({
titleClassName = 'max-w-60 truncate',
showOnMobile = false,
}: TitleEditorPopoverProps) {
const [editTitle, setEditTitle] = useState(title);
const resolvedInputTitle = inputTitle ?? title;
const [editTitle, setEditTitle] = useState(resolvedInputTitle);

useEffect(() => {
setEditTitle(title);
}, [title]);
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) return;
// Popover를 열 때마다 현재 슬라이드 기준 입력값으로 초기화합니다.
setEditTitle(resolvedInputTitle);
};

if (readOnlyContent) {
return (
Expand Down Expand Up @@ -69,6 +76,7 @@ export function TitleEditorPopover({

return (
<Popover
onOpenChange={handleOpenChange}
trigger={({ isOpen }) => (
<button
type="button"
Expand Down Expand Up @@ -107,6 +115,7 @@ export function TitleEditorPopover({
disabled={isPending}
aria-label={ariaLabel}
className="h-9 flex-1 text-sm"
placeholder={inputPlaceholder}
spellCheck={false}
/>
<button
Expand Down
7 changes: 6 additions & 1 deletion src/components/feedback/SlideViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* @description 피드백 화면 좌측 슬라이드 뷰어
*/
import type { SlideListItem } from '@/types/slide';
import { getSlideTitle } from '@/utils/slideTitle';

import SlideInfoPanel from './slide/SlideInfoPanel';

Expand Down Expand Up @@ -39,7 +40,11 @@ export default function SlideViewer({
<div className="flex-1 flex items-start justify-center min-w-0 bg-gray-100">
<div className="flex flex-col max-w-full max-h-full">
<div className="flex items-center justify-center">
<img src={slide.imageUrl} alt={slide.title} className="max-w-full max-h-full shadow-lg" />
<img
src={slide.imageUrl}
alt={getSlideTitle(slide.title, slideIndex + 1)}
className="max-w-full max-h-full shadow-lg"
/>
</div>

<SlideInfoPanel
Expand Down
3 changes: 2 additions & 1 deletion src/components/feedback/slide/SlideInfoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* @description 슬라이드 제목과 대본을 표시하는 패널
*/
import SlideTitle from '@/components/slide/script/SlideTitle';
import { getSlideTitle } from '@/utils/slideTitle';

import SlideNavigation from '../SlideNavigation';

Expand All @@ -29,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}`} readOnly />
<SlideTitle fallbackTitle={getSlideTitle(undefined, slideIndex + 1)} readOnly />
</div>

<SlideNavigation
Expand Down
3 changes: 2 additions & 1 deletion src/components/feedback/video/SlideWebcamStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import VideoPlaybackBar from '@/components/feedback/video/VideoPlaybackBar';
import { useVideoSync } from '@/hooks/useVideoSync';
import type { SlideListItem } from '@/types/slide';
import type { SegmentHighlight } from '@/types/video';
import { getSlideTitle } from '@/utils/slideTitle';
import { getSlideIndexFromTime } from '@/utils/video';

const LAYOUT_STORAGE_KEY = 'feedback-video-layout';
Expand Down Expand Up @@ -329,7 +330,7 @@ export default function SlideWebcamStage({
>
<img
src={activeSlide.imageUrl}
alt={`슬라이드 ${activeIndex + 1} - ${activeSlide.title}`}
alt={`슬라이드 ${activeIndex + 1} - ${getSlideTitle(activeSlide.title, activeIndex + 1)}`}
className={clsx(
'h-full w-full',
// 슬라이드는 메인일 때 전체 보기(contain), 작은 박스일 땐 꽉 차게(cover)
Expand Down
3 changes: 2 additions & 1 deletion src/components/insight/DropOffAnalysisSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DropOffSlide, DropOffTime } from '@/types/insight';
import { getSlideTitle } from '@/utils/slideTitle';

import SlideThumb from './SlideThumb';

Expand Down Expand Up @@ -131,7 +132,7 @@ export default function DropOffAnalysisSection({
>
{renderSlideThumb(
item.slideIndex,
`슬라이드 ${item.slideIndex + 1} 썸네일`,
`${getSlideTitle(undefined, item.slideIndex + 1)} 썸네일`,
'h-12 w-20 shrink-0 rounded object-cover md:h-16.75 md:w-30',
'h-12 w-20 shrink-0 rounded bg-gray-200 md:h-[67px] md:w-[120px]',
)}
Expand Down
46 changes: 46 additions & 0 deletions src/components/insight/RecentCommentsSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,50 @@ describe('RecentCommentsSection', () => {
'https://example.com/avatar.png',
);
});

it('renders slide title when present and falls back to 슬라이드 N when title is null', () => {
const recentCommentsData: ReadRecentCommentListResponseDto = {
comments: [
{
commentId: 'comment-1',
content: '좋은 발표였어요.',
timestampMs: 42000,
createdAt: '2026-02-01T00:00:00.000Z',
user: {
userId: 'user-1',
nickName: 'alex',
name: 'Alex',
},
slide: {
slideId: 'slide-1',
slideNum: 1,
title: '도입',
imageUrl: 'https://example.com/slide-1.png',
},
},
{
commentId: 'comment-2',
content: '두 번째 슬라이드 코멘트',
timestampMs: 52000,
createdAt: '2026-02-01T00:00:01.000Z',
user: {
userId: 'user-2',
nickName: 'jamie',
name: 'Jamie',
},
slide: {
slideId: 'slide-2',
slideNum: 2,
title: null,
imageUrl: 'https://example.com/slide-2.png',
},
},
],
};

render(<RecentCommentsSection hasVideo recentCommentsData={recentCommentsData} />);

expect(screen.getByText('도입')).toBeInTheDocument();
expect(screen.getByText('슬라이드 2')).toBeInTheDocument();
});
});
8 changes: 6 additions & 2 deletions src/components/insight/RecentCommentsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { ReadRecentCommentListResponseDto } from '@/api/dto/analytics.dto';
import { RecentCommentItem } from '@/components/insight';
import { formatVideoTimestamp } from '@/utils/format';
import { getSlideTitle } from '@/utils/slideTitle';

const thumbBase = 'bg-gray-100 rounded-lg aspect-video';
const sampleComments = [
Expand Down Expand Up @@ -47,7 +48,7 @@ export function RecentCommentsSection({
<div key={comment.commentId} className={idx > 0 ? 'hidden md:block' : ''}>
<RecentCommentItem
user={comment.user}
slideLabel={`슬라이드 ${comment.slideNum}`}
slideLabel={getSlideTitle(undefined, comment.slideNum)}
time={comment.time}
text={comment.text}
thumbFallbackClassName={thumbBase}
Expand All @@ -64,14 +65,17 @@ export function RecentCommentsSection({
<>
{recentCommentsData?.comments.slice(0, 5).map((comment) => {
const seconds = Math.max(0, comment.timestampMs / 1000);
const slideLabel = comment.slide
? getSlideTitle(comment.slide.title, comment.slide.slideNum)
: '전체';
return (
<RecentCommentItem
key={comment.commentId}
user={comment.user.name}
userProfileImage={
comment.user.profileImage ?? comment.user.profileImageUrl ?? undefined
}
slideLabel={comment.slide ? `슬라이드 ${comment.slide.slideNum}` : '전체'}
slideLabel={slideLabel}
time={formatVideoTimestamp(seconds)}
text={comment.content}
thumbUrl={comment.slide?.imageUrl}
Expand Down
3 changes: 2 additions & 1 deletion src/components/slide/SlideThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import clsx from 'clsx';
import { SlideImage } from '@/components/common';
import { getTabPath } from '@/constants/navigation';
import type { SlideListItem } from '@/types/slide';
import { getSlideTitle } from '@/utils/slideTitle';

interface SlideThumbnailProps {
/** 슬라이드 데이터 */
Expand Down Expand Up @@ -71,7 +72,7 @@ export default function SlideThumbnail({
<div className="relative flex-1 rounded overflow-hidden bg-gray-200">
<SlideImage
src={slide.imageUrl}
alt={`슬라이드 ${index + 1}: ${slide.title}`}
alt={`슬라이드 ${index + 1}: ${getSlideTitle(slide.title, index + 1)}`}
loading="lazy"
decoding="async"
fetchPriority="low"
Expand Down
Loading