Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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