diff --git a/src/api/dto/comments.dto.ts b/src/api/dto/comments.dto.ts index 75acd3f2..0b396998 100644 --- a/src/api/dto/comments.dto.ts +++ b/src/api/dto/comments.dto.ts @@ -47,7 +47,7 @@ export interface CreateCommentResponseDto { * 댓글 작성자 정보 */ export interface CommentUserDto { - id: string; + userId: string; nickName: string; } @@ -55,7 +55,7 @@ export interface CommentUserDto { * 사용자 정보 포함 댓글 */ export interface CommentWithUserDto { - id: string; + commentId: string; content: string; user: CommentUserDto; createdAt: string; @@ -79,7 +79,7 @@ export interface GetSlideCommentsResponseDto { * 댓글 생성/수정 응답 */ export interface CommentResponseDto { - id: string; + commentId: string; content: string; parentId?: string; userId: string; @@ -94,7 +94,7 @@ export type GetReplyListResponseDto = CommentResponseDto[]; * 댓글 수정 */ export interface UpdateCommentResponseDto { - id: string; + commentId: string; content: string; userId: string; createdAt: string; diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts index e1441efc..ff2c0dde 100644 --- a/src/api/dto/index.ts +++ b/src/api/dto/index.ts @@ -20,13 +20,19 @@ export type { GetScriptVersionHistoryResponseDto, RestoreScriptResponseDto, } from './scripts.dto'; -export type { ToggleSlideReactionDto } from './reactions.dto'; +export type { + ReadReactionCountDto, + ToggleSlideReactionDto, + ToggleSlideReactionResponseDto, + ToggleVideoReactionDto, + ToggleVideoReactionResponseDto, +} from './reactions.dto'; export type { RestoreScriptRequestDto } from './analytics.dto'; export type { UpdateProjectDto } from './presentations.dto'; // export type { UploadFileResponseDto } from './files.dto'; export type { ChunkUploadResponseDto, - CreateOpinionDto, + CreateCommentDto, FinishVideoRequestDto, FinishVideoResponseDto, StartVideoRequestDto, diff --git a/src/api/dto/reactions.dto.ts b/src/api/dto/reactions.dto.ts index d306eafc..5a2f81d6 100644 --- a/src/api/dto/reactions.dto.ts +++ b/src/api/dto/reactions.dto.ts @@ -4,7 +4,29 @@ import type { ReactionType } from '@/types/script'; * 슬라이드 리액션 토글 요청 DTO */ export interface ToggleSlideReactionDto { - type: ReactionType; + emojiType: ReactionType; +} + +/** + * 슬라이드 리액션 토글 응답 DTO + */ +export interface ToggleSlideReactionResponseDto { + active: boolean; +} + +/** + * 영상 리액션 토글 요청 DTO + */ +export interface ToggleVideoReactionDto { + emojiType: ReactionType; + timestampMs: number; +} + +/** + * 영상 리액션 토글 응답 DTO + */ +export interface ToggleVideoReactionResponseDto { + active: boolean; } /** @@ -12,13 +34,7 @@ export interface ToggleSlideReactionDto { */ export interface ReadReactionCountDto { slideId: string; - reactions: { - fire: number; - good: number; - bad: number; - sleepy: number; - confused: number; - }; + reactions: Record; } /** @@ -26,12 +42,6 @@ export interface ReadReactionCountDto { */ export interface ReadReactionSummaryDto { projectId: string; - totalReactions: { - fire: number; - good: number; - bad: number; - sleepy: number; - confused: number; - }; + totalReactions: Record; totalCount: number; } diff --git a/src/api/dto/video.dto.ts b/src/api/dto/video.dto.ts index c34b3e5d..22f60a01 100644 --- a/src/api/dto/video.dto.ts +++ b/src/api/dto/video.dto.ts @@ -1,9 +1,9 @@ /** * 영상 타임스탬프 댓글 생성 */ -export interface CreateOpinionDto { +export interface CreateCommentDto { content: string; - /** 답글인 경우 부모 의견 내용 고쳐라이D */ + /** 답글인 경우 부모 댓글 ID */ parentId?: string; } diff --git a/src/api/endpoints/analytics.ts b/src/api/endpoints/analytics.ts index 0fa6de68..57a63d43 100644 --- a/src/api/endpoints/analytics.ts +++ b/src/api/endpoints/analytics.ts @@ -2,7 +2,7 @@ * @file analytics.ts * @description 인사이트 페이지 관련 API 엔드포인트 */ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { ReadProjectAnalyticsSummaryDto, ReadRecentCommentListResponseDto, diff --git a/src/api/endpoints/auth.ts b/src/api/endpoints/auth.ts index 56dfda24..bad0cfce 100644 --- a/src/api/endpoints/auth.ts +++ b/src/api/endpoints/auth.ts @@ -2,7 +2,7 @@ * @file auth.ts * @description 인증 관련 API 엔드포인트 */ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { SocialLoginSuccessResponseDto } from '@/api/dto'; import type { ApiResponse } from '@/types/api'; diff --git a/src/api/endpoints/comments.ts b/src/api/endpoints/comments.ts index 6167e0ea..718d4fe2 100644 --- a/src/api/endpoints/comments.ts +++ b/src/api/endpoints/comments.ts @@ -2,7 +2,7 @@ * @file comments.ts * @description 댓글 관련 API 엔드포인트 */ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { CommentResponseDto, GetReplyListResponseDto, diff --git a/src/api/endpoints/presentations.ts b/src/api/endpoints/presentations.ts index 864199a0..7556b0ee 100644 --- a/src/api/endpoints/presentations.ts +++ b/src/api/endpoints/presentations.ts @@ -5,12 +5,10 @@ * 서버와 통신하는 함수들을 정의합니다. * 이 함수들은 직접 호출하지 않고, hooks/queries에서 사용합니다. */ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { UpdateProjectDto } from '@/api/dto'; import type { ApiResponse, ConversionStatusResponse } from '@/types/api'; import type { - CreatePresentationRequest, - CreatePresentationSuccess, Presentation, PresentationListResponse, ProjectUpdateResponse, @@ -67,26 +65,6 @@ export async function updatePresentation( throw new Error(response.data.error.reason); } -/** - * 프로젝트 생성 (POST) - * - * @param data - 생성할 프로젝트 데이터 - * @returns CreatePresentationSuccess - 생성된 프로젝트 정보 - */ -export async function createPresentation( - data: CreatePresentationRequest, -): Promise { - const response = await apiClient.post>( - `/presentations`, - data, - ); - - if (response.data.resultType === 'SUCCESS') { - return response.data.success; - } - throw new Error(response.data.error.reason); -} - /** * 프로젝트 삭제 (DELETE) * @@ -108,7 +86,7 @@ export async function deletePresentation(projectId: string): Promise { */ export async function getConversionStatus(projectId: string): Promise { const response = await apiClient.get>( - `/presentations/${projectId}/conversion-status`, + `/presentations/${projectId}/status`, ); if (response.data.resultType === 'SUCCESS') { diff --git a/src/api/endpoints/reactions.ts b/src/api/endpoints/reactions.ts index b007a619..89ab2d67 100644 --- a/src/api/endpoints/reactions.ts +++ b/src/api/endpoints/reactions.ts @@ -1,51 +1,68 @@ -/** - * @file reactions.ts * @description Slide reaction APIs. +/** + * @file reactions.ts + * @description 슬라이드 리액션 관련 API 엔드포인트 */ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { ToggleSlideReactionDto } from '@/api/dto'; -import type { ReadReactionCountDto, ReadReactionSummaryDto } from '@/api/dto/reactions.dto'; -import type { ApiResponse } from '@/api/handlers'; -import type { Reaction } from '@/types/script'; +import type { + ReadReactionCountDto, + ReadReactionSummaryDto, + ToggleSlideReactionResponseDto, +} from '@/api/dto/reactions.dto'; +import type { ApiResponse } from '@/types/api'; /** - * Toggle a reaction for a slide. * 슬라이드 리액션 토글 * * @param slideId - 슬라이드 ID * @param data - 리액션 데이터 - * @returns 업데이트된 리액션 배열 + * @returns { active: boolean } - 토글 후 활성 상태 */ export async function toggleReaction( slideId: string, data: ToggleSlideReactionDto, -): Promise { - const { data: response } = await apiClient.post>( +): Promise { + const { data: response } = await apiClient.post>( `/slides/${slideId}/reactions/toggle`, data, ); - return response.success; + + if (response.resultType === 'SUCCESS') { + return response.success; + } + throw new Error(response.error.reason); } /** - * Get reaction summary counts for a slide. + * 슬라이드 리액션 집계 조회 + * + * @param slideId - 슬라이드 ID + * @returns Record - 이모지별 카운트 */ export async function getSlideReactionSummary(slideId: string) { const { data } = await apiClient.get>( `/slides/${slideId}/reactions/summary`, ); - // ⚠️ 핵심: success 안에 있는 reactions 객체만 리턴! (없으면 빈 객체) - return data.success?.reactions ?? {}; + if (data.resultType === 'SUCCESS') { + return data.success.reactions; + } + throw new Error(data.error.reason); } -// 슬라이드 이모지 피드백 분포 - 인사이트 페이지 +/** + * 프로젝트 전체 슬라이드 리액션 집계 조회 + * + * @param projectId - 프로젝트 ID + * @returns ReactionSummaryDto - 이모지별 총 카운트 + */ export async function getTotalReactions(projectId: string): Promise { - const response = await apiClient.get>( + const { data } = await apiClient.get>( `/presentations/${projectId}/slides/reactions/summary`, ); - // 데이터가 없으면 에러 발생 (null 반환 방지) - if (!response.data.success) { - throw new Error('슬라이드 이모지 피드백 분포를 불러올 수 없습니다.'); + + if (data.resultType === 'SUCCESS') { + return data.success; } - return response.data.success; + throw new Error(data.error.reason); } diff --git a/src/api/endpoints/scripts.ts b/src/api/endpoints/scripts.ts index e5288a97..505a1cfd 100644 --- a/src/api/endpoints/scripts.ts +++ b/src/api/endpoints/scripts.ts @@ -2,7 +2,7 @@ * @file scripts.ts * @description 대본 관련 API 엔드포인트 */ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { GetScriptResponseDto, GetScriptVersionHistoryResponseDto, diff --git a/src/api/endpoints/slides.ts b/src/api/endpoints/slides.ts index 9831934f..8d421018 100644 --- a/src/api/endpoints/slides.ts +++ b/src/api/endpoints/slides.ts @@ -5,7 +5,7 @@ * 서버와 통신하는 함수들을 정의합니다. * 이 함수들은 직접 호출하지 않고, hooks/queries에서 사용합니다. */ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { GetSlideResponseDto, UpdateSlideResponseDto, @@ -72,38 +72,3 @@ export async function updateSlide( } throw new Error(response.data.error.reason); } - -/** - * 슬라이드 생성 - * - * @param projectId - 프로젝트 ID - * @param data - 생성할 슬라이드 데이터 - * @returns 생성된 슬라이드 - */ -export async function createSlide( - projectId: string, - data: { title: string; script?: string }, -): Promise { - const response = await apiClient.post>( - `/presentations/${projectId}/slides`, - data, - ); - - if (response.data.resultType === 'SUCCESS') { - return response.data.success; - } - throw new Error(response.data.error.reason); -} - -/** - * 슬라이드 삭제 - * - * @param slideId - 삭제할 슬라이드 ID - */ -export async function deleteSlide(slideId: string): Promise { - const response = await apiClient.delete>(`/presentations/slides/${slideId}`); - - if (response.data.resultType === 'FAILURE') { - throw new Error(response.data.error.reason); - } -} diff --git a/src/api/endpoints/videoReactions.ts b/src/api/endpoints/videoReactions.ts index d2f3f766..c13df6d4 100644 --- a/src/api/endpoints/videoReactions.ts +++ b/src/api/endpoints/videoReactions.ts @@ -1,20 +1,34 @@ -import { apiClient } from '@/api'; -import type { Reaction, ReactionType } from '@/types/script'; +/** + * @file videoReactions.ts + * @description 영상 리액션 관련 API 엔드포인트 + */ +import { apiClient } from '@/api/client'; +import type { + ToggleVideoReactionDto, + ToggleVideoReactionResponseDto, +} from '@/api/dto/reactions.dto'; +import type { ApiResponse } from '@/types/api'; -export interface ToggleVideoReactionRequest { - type: ReactionType; - timestamp: number; -} - -export interface ToggleVideoReactionResponse { - timestamp: number; - reactions: Reaction[]; -} +export type { ToggleVideoReactionDto as ToggleVideoReactionRequest }; -export const toggleVideoReaction = async (videoId: string, data: ToggleVideoReactionRequest) => { - const { data: response } = await apiClient.post( +/** + * 영상 리액션 토글 + * + * @param videoId - 영상 ID + * @param data - 리액션 데이터 (type + timestamp) + * @returns { active: boolean } - 토글 후 활성 상태 + */ +export async function toggleVideoReaction( + videoId: string, + data: ToggleVideoReactionDto, +): Promise { + const { data: response } = await apiClient.post>( `/videos/${videoId}/reactions`, data, ); - return response; -}; + + if (response.resultType === 'SUCCESS') { + return response.success; + } + throw new Error(response.error.reason); +} diff --git a/src/api/endpoints/videos.ts b/src/api/endpoints/videos.ts index 6759d63a..bc4b83a9 100644 --- a/src/api/endpoints/videos.ts +++ b/src/api/endpoints/videos.ts @@ -1,4 +1,4 @@ -import { apiClient } from '@/api'; +import { apiClient } from '@/api/client'; import type { ChunkUploadResponseDto, FinishVideoRequestDto, diff --git a/src/api/index.ts b/src/api/index.ts index 838ea64c..f13d2732 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,6 @@ /** * API 모듈 배럴 파일 */ -export { apiClient } from './client'; export { queryClient, queryKeys } from './queryClient'; export * from './endpoints/auth'; export * from './endpoints/videos'; diff --git a/src/api/queryClient.ts b/src/api/queryClient.ts index f3a91104..3b8be482 100644 --- a/src/api/queryClient.ts +++ b/src/api/queryClient.ts @@ -102,8 +102,10 @@ export const queryKeys = { }, reactions: { all: ['reactions'] as const, - summary: (slideId: string) => [...queryKeys.reactions.all, 'summary', slideId] as const, - total: (projectId: string) => [...queryKeys.reactions.all, 'total', projectId] as const, + summaries: () => [...queryKeys.reactions.all, 'summary'] as const, + summary: (slideId: string) => [...queryKeys.reactions.summaries(), slideId] as const, + totals: () => [...queryKeys.reactions.all, 'total'] as const, + total: (projectId: string) => [...queryKeys.reactions.totals(), projectId] as const, }, } as const; diff --git a/src/assets/icons/icon-edit.svg b/src/assets/icons/icon-edit.svg new file mode 100644 index 00000000..e6859892 --- /dev/null +++ b/src/assets/icons/icon-edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/comment/Comment.tsx b/src/components/comment/Comment.tsx index 4e265006..e9f5e86d 100644 --- a/src/components/comment/Comment.tsx +++ b/src/components/comment/Comment.tsx @@ -11,6 +11,7 @@ import React, { useCallback } from 'react'; import clsx from 'clsx'; import FileIcon from '@/assets/icons/icon-document.svg?react'; +import EditIcon from '@/assets/icons/icon-edit.svg?react'; import RemoveIcon from '@/assets/icons/icon-remove.svg?react'; import ReplyIcon from '@/assets/icons/icon-reply.svg?react'; import { MOCK_USERS } from '@/mocks/users'; @@ -42,6 +43,12 @@ function Comment({ comment, isIndented = false }: CommentProps) { submitReply, cancelReply, deleteComment, + editingId, + editDraft, + setEditDraft, + startEdit, + cancelEdit, + submitEdit, goToRef, } = useCommentContext(); @@ -50,6 +57,16 @@ function Comment({ comment, isIndented = false }: CommentProps) { const authorProfileImage = user?.profileImage; const isActive = replyingToId === comment.id; + const isEditing = editingId === comment.id; + + const handleStartEdit = useCallback(() => { + if (editingId === comment.id) return; + startEdit(comment.id, comment.content); + }, [startEdit, editingId, comment.id, comment.content]); + + const handleSubmitEdit = useCallback(() => { + submitEdit(comment.id); + }, [submitEdit, comment.id]); const handleToggleReply = useCallback(() => { toggleReply(comment.id); @@ -82,7 +99,7 @@ function Comment({ comment, isIndented = false }: CommentProps) { className={clsx( 'flex gap-3 py-3 pr-4 transition-colors', isIndented ? 'pl-15' : 'pl-4', - isActive ? 'bg-gray-200' : 'bg-gray-100', + isEditing ? 'bg-gray-100' : isActive ? 'bg-gray-200' : 'bg-gray-100', )} >
@@ -107,42 +124,72 @@ function Comment({ comment, isIndented = false }: CommentProps) {
- {comment.isMine && deleteComment && ( - - )} - - -
- {comment.ref && ( - + {deleteComment && ( + )} - aria-label={ - comment.ref.kind === 'slide' ? `${refLabel}로 이동` : `영상 ${refLabel}로 이동` - } - > - {comment.ref.kind === 'slide' && ( -
)} - - {comment.content} + + {isEditing ? ( + + ) : ( +
+ {comment.ref && ( + + )} + + {comment.content} +
+ )}
diff --git a/src/components/comment/CommentContext.tsx b/src/components/comment/CommentContext.tsx index 83680bba..f2c4f533 100644 --- a/src/components/comment/CommentContext.tsx +++ b/src/components/comment/CommentContext.tsx @@ -26,6 +26,18 @@ interface CommentContextValue { cancelReply: () => void; /** 댓글 삭제 */ deleteComment?: (id: string) => void; + /** 현재 수정 중인 댓글 ID */ + editingId: string | null; + /** 수정 입력값 */ + editDraft: string; + /** 수정 입력값 변경 */ + setEditDraft: (text: string) => void; + /** 수정 모드 시작 */ + startEdit: (id: string, currentContent: string) => void; + /** 수정 취소 */ + cancelEdit: () => void; + /** 수정 제출 */ + submitEdit: (id: string) => void; /** 참조로 이동 (슬라이드/영상) */ goToRef: (ref: CommentRef) => void; } diff --git a/src/components/comment/CommentList.tsx b/src/components/comment/CommentList.tsx index 56af247d..2840fb82 100644 --- a/src/components/comment/CommentList.tsx +++ b/src/components/comment/CommentList.tsx @@ -18,6 +18,7 @@ interface CommentListProps { onAddReply: (targetId: string, content: string) => void; onGoToRef: (ref: NonNullable) => void; onDeleteComment?: (commentId: string) => void; + onUpdateComment?: (commentId: string, content: string) => void; isLoading?: boolean; } @@ -28,10 +29,13 @@ export default function CommentList({ onAddReply, onGoToRef, onDeleteComment, + onUpdateComment, isLoading = false, }: CommentListProps) { const [replyingToId, setReplyingToId] = useState(null); const [replyDraft, setReplyDraft] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editDraft, setEditDraft] = useState(''); const submitReply = useCallback( (targetId: string) => { @@ -54,6 +58,27 @@ export default function CommentList({ setReplyDraft(''); }, []); + const startEdit = useCallback((id: string, currentContent: string) => { + setEditingId(id); + setEditDraft(currentContent); + }, []); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setEditDraft(''); + }, []); + + const submitEdit = useCallback( + (id: string) => { + if (editDraft.trim() && onUpdateComment) { + onUpdateComment(id, editDraft.trim()); + } + setEditingId(null); + setEditDraft(''); + }, + [editDraft, onUpdateComment], + ); + const contextValue = useMemo( () => ({ replyingToId, @@ -63,9 +88,28 @@ export default function CommentList({ submitReply, cancelReply, deleteComment: onDeleteComment, + editingId, + editDraft, + setEditDraft, + startEdit, + cancelEdit, + submitEdit, goToRef: onGoToRef, }), - [replyingToId, replyDraft, toggleReply, submitReply, cancelReply, onDeleteComment, onGoToRef], + [ + replyingToId, + replyDraft, + toggleReply, + submitReply, + cancelReply, + onDeleteComment, + editingId, + editDraft, + startEdit, + cancelEdit, + submitEdit, + onGoToRef, + ], ); if (isLoading) { diff --git a/src/components/comment/CommentPopover.tsx b/src/components/comment/CommentPopover.tsx index 94784821..80742996 100644 --- a/src/components/comment/CommentPopover.tsx +++ b/src/components/comment/CommentPopover.tsx @@ -10,16 +10,18 @@ import { useCallback, useMemo, useState } from 'react'; import clsx from 'clsx'; import { Popover } from '@/components/common'; -import { useSlideActions, useSlideOpinions } from '@/hooks'; +import { useSlideActions, useSlideComments } from '@/hooks'; import Comment from './Comment'; import { CommentProvider } from './CommentContext'; export default function CommentPopover() { - const opinions = useSlideOpinions(); - const { deleteOpinion, addReply } = useSlideActions(); + const comments = useSlideComments(); + const { deleteComment, updateComment, addReply } = useSlideActions(); const [replyingToId, setReplyingToId] = useState(null); const [replyDraft, setReplyDraft] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editDraft, setEditDraft] = useState(''); const submitReply = useCallback( (targetId: string) => { @@ -42,6 +44,27 @@ export default function CommentPopover() { setReplyDraft(''); }, []); + const startEdit = useCallback((id: string, currentContent: string) => { + setEditingId(id); + setEditDraft(currentContent); + }, []); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setEditDraft(''); + }, []); + + const submitEdit = useCallback( + (id: string) => { + if (editDraft.trim()) { + updateComment(id, editDraft.trim()); + } + setEditingId(null); + setEditDraft(''); + }, + [editDraft, updateComment], + ); + const contextValue = useMemo( () => ({ replyingToId, @@ -50,10 +73,28 @@ export default function CommentPopover() { toggleReply, submitReply, cancelReply, - deleteComment: deleteOpinion, + deleteComment, + editingId, + editDraft, + setEditDraft, + startEdit, + cancelEdit, + submitEdit, goToRef: () => {}, // 슬라이드 페이지에서는 ref 이동 불필요 }), - [replyingToId, replyDraft, toggleReply, submitReply, cancelReply, deleteOpinion], + [ + replyingToId, + replyDraft, + toggleReply, + submitReply, + cancelReply, + deleteComment, + editingId, + editDraft, + startEdit, + cancelEdit, + submitEdit, + ], ); return ( @@ -61,7 +102,7 @@ export default function CommentPopover() { trigger={({ isOpen }) => ( )} @@ -101,8 +142,8 @@ export default function CommentPopover() { {/* 의견 목록 */}
- {opinions.map((opinion) => ( - + {comments.map((comment) => ( + ))}
diff --git a/src/components/common/ActionButton.tsx b/src/components/common/ActionButton.tsx index fb2f299a..e8e2a09e 100644 --- a/src/components/common/ActionButton.tsx +++ b/src/components/common/ActionButton.tsx @@ -1,3 +1,7 @@ +/** + * @file ActionButton.tsx + * @description 하단 영역 주요 액션 버튼 + */ interface ActionButtonProps { text: string; onClick: () => void; diff --git a/src/components/common/CardView.tsx b/src/components/common/CardView.tsx index 8ab02eaa..2c06ef0c 100644 --- a/src/components/common/CardView.tsx +++ b/src/components/common/CardView.tsx @@ -1,3 +1,7 @@ +/** + * @file CardView.tsx + * @description 제네릭 카드 그리드 뷰 컴포넌트 + */ import type { Key, ReactNode } from 'react'; import clsx from 'clsx'; diff --git a/src/components/common/DevFab.tsx b/src/components/common/DevFab.tsx index c532b7ed..ffa36499 100644 --- a/src/components/common/DevFab.tsx +++ b/src/components/common/DevFab.tsx @@ -1,3 +1,7 @@ +/** + * @file DevFab.tsx + * @description 개발 환경 전용 플로팅 액션 버튼 (DevTestPage 이동) + */ import GanadiIcon from '@/assets/icons/ganadi.webp'; export function DevFab() { diff --git a/src/components/common/FileDropzone.tsx b/src/components/common/FileDropzone.tsx index 68c9240c..7e660e4d 100644 --- a/src/components/common/FileDropzone.tsx +++ b/src/components/common/FileDropzone.tsx @@ -1,3 +1,7 @@ +/** + * @file FileDropzone.tsx + * @description 드래그 앤 드롭 파일 업로드 컴포넌트 + */ import { useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; diff --git a/src/components/common/HighlightText.tsx b/src/components/common/HighlightText.tsx index b6aea477..87f3844d 100644 --- a/src/components/common/HighlightText.tsx +++ b/src/components/common/HighlightText.tsx @@ -1,3 +1,7 @@ +/** + * @file HighlightText.tsx + * @description 검색어 일치 부분을 하이라이트 처리하는 텍스트 컴포넌트 + */ import { useMemo } from 'react'; type Props = { diff --git a/src/components/common/ListView.tsx b/src/components/common/ListView.tsx index f642ad9a..fb145abb 100644 --- a/src/components/common/ListView.tsx +++ b/src/components/common/ListView.tsx @@ -1,3 +1,7 @@ +/** + * @file ListView.tsx + * @description 제네릭 리스트 뷰 컴포넌트 + */ import type { Key, ReactNode } from 'react'; import clsx from 'clsx'; diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx index bfe61852..bd406c1d 100644 --- a/src/components/common/Modal.tsx +++ b/src/components/common/Modal.tsx @@ -178,7 +178,7 @@ export function Modal({
; + +export const TextField = forwardRef( + ({ className, disabled, ...rest }, ref) => { + return ( + + ); + }, +); + +TextField.displayName = 'TextField'; diff --git a/src/components/common/TitleEditorPopover.tsx b/src/components/common/TitleEditorPopover.tsx new file mode 100644 index 00000000..5213132c --- /dev/null +++ b/src/components/common/TitleEditorPopover.tsx @@ -0,0 +1,114 @@ +/** + * @file TitleEditorPopover.tsx + * @description 제목 편집/정보 팝오버 컴포넌트 + * + * readOnlyContent가 제공되면 InfoIcon + 정보 팝오버를 표시하고, + * 없으면 ArrowDownIcon + 편집 팝오버를 표시합니다. + */ +import { type ReactNode, useEffect, useState } from 'react'; + +import clsx from 'clsx'; + +import ArrowDownIcon from '@/assets/icons/icon-arrow-down.svg?react'; +import InfoIcon from '@/assets/icons/icon-info.svg?react'; + +import { Popover } from './Popover'; +import { TextField } from './TextField'; + +interface TitleEditorPopoverProps { + title: string; + onSave?: (newTitle: string, close: () => void) => void; + readOnlyContent?: ReactNode; + isCollapsed?: boolean; + ariaLabel: string; + isPending?: boolean; +} + +export function TitleEditorPopover({ + title, + onSave, + readOnlyContent, + isCollapsed = false, + ariaLabel, + isPending = false, +}: TitleEditorPopoverProps) { + const [editTitle, setEditTitle] = useState(title); + + useEffect(() => { + setEditTitle(title); + }, [title]); + + if (readOnlyContent) { + return ( + + {title} + + ); + } + + return ( + ( + + )} + position={isCollapsed ? 'top' : 'bottom'} + align="start" + ariaLabel={ariaLabel} + className="flex w-80 items-center gap-2 border border-gray-200 px-3 py-2" + > + {({ close }) => ( + <> + setEditTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onSave?.(editTitle, close); + } + }} + disabled={isPending} + aria-label={ariaLabel} + className="h-9 flex-1 text-sm" + /> + + + )} + + ); +} diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 5f691ab5..b6092650 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -20,3 +20,5 @@ export { default as ProgressBar } from './ProgressBar'; export { default as ListView } from './ListView'; export { default as CardView } from './CardView'; export { default as EmojiConfetti } from './EmojiConfetti'; +export { TextField } from './TextField'; +export { TitleEditorPopover } from './TitleEditorPopover'; diff --git a/src/components/common/layout/HeaderButton.tsx b/src/components/common/layout/HeaderButton.tsx index fa4373de..c0a40ddc 100644 --- a/src/components/common/layout/HeaderButton.tsx +++ b/src/components/common/layout/HeaderButton.tsx @@ -1,3 +1,7 @@ +/** + * @file HeaderButton.tsx + * @description 헤더 우측 영역 아이콘+텍스트 버튼 + */ import type { ReactNode } from 'react'; import clsx from 'clsx'; diff --git a/src/components/common/layout/Layout.tsx b/src/components/common/layout/Layout.tsx index 7e8d513f..41325c18 100644 --- a/src/components/common/layout/Layout.tsx +++ b/src/components/common/layout/Layout.tsx @@ -32,7 +32,6 @@ interface LayoutProps { export function Layout({ left, center, right, theme, scrollable = false, children }: LayoutProps) { const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const appliedTheme = theme ?? resolvedTheme; - const isDark = appliedTheme === 'dark'; // 테마가 변경되거나 오버라이드될 때 document.documentElement에 적용 (모달 등 포탈 지원) useEffect(() => { @@ -49,7 +48,7 @@ export function Layout({ left, center, right, theme, scrollable = false, childre return (
{left ?? }
@@ -58,7 +57,7 @@ export function Layout({ left, center, right, theme, scrollable = false, childre
{children || }
diff --git a/src/components/common/layout/Logo.tsx b/src/components/common/layout/Logo.tsx index 01e3cb66..5c1e81d3 100644 --- a/src/components/common/layout/Logo.tsx +++ b/src/components/common/layout/Logo.tsx @@ -3,19 +3,31 @@ * @description 또랑 로고 컴포넌트 * * 홈에서는 전체 로고, 그 외 페이지에서는 아이콘 로고를 표시합니다. - * 클릭 시 홈으로 이동합니다. + * 클릭 시 홈으로 이동합니다. onClick이 제공되면 네비게이션 대신 해당 핸들러를 실행합니다. */ +import { type MouseEvent } from 'react'; import { Link, useLocation } from 'react-router-dom'; import logoFull from '@/assets/logo-full@4x.webp'; import logoIcon from '@/assets/logo-icon@4x.webp'; -export function Logo() { +interface LogoProps { + onClick?: () => void; +} + +export function Logo({ onClick }: LogoProps) { const { pathname } = useLocation(); const isHome = pathname === '/'; + const handleClick = (e: MouseEvent) => { + if (onClick) { + e.preventDefault(); + onClick(); + } + }; + return ( - + 또랑 ); diff --git a/src/components/common/layout/PresentationTitleEditor.tsx b/src/components/common/layout/PresentationTitleEditor.tsx index c44f6216..c17c4e41 100644 --- a/src/components/common/layout/PresentationTitleEditor.tsx +++ b/src/components/common/layout/PresentationTitleEditor.tsx @@ -6,34 +6,47 @@ * - 클릭하면 Popover 열리고, 입력/저장 가능 * - Enter 또는 저장 버튼으로 제출 */ -import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import clsx from 'clsx'; - -import ArrowDownIcon from '@/assets/icons/icon-arrow-down.svg?react'; -import { Popover } from '@/components/common/Popover'; import { usePresentation, useUpdatePresentation } from '@/hooks/queries/usePresentations'; import { showToast } from '@/utils/toast'; +import { TitleEditorPopover } from '../TitleEditorPopover'; + interface PresentationTitleEditorProps { - readOnly?: boolean; + readOnlyContent?: React.ReactNode; } -export function PresentationTitleEditor({ readOnly }: PresentationTitleEditorProps) { +export function PresentationTitleEditor({ readOnlyContent }: PresentationTitleEditorProps) { const { projectId } = useParams<{ projectId: string }>(); const { data: presentation } = usePresentation(projectId ?? ''); - const { mutate: updatePresentation, isPending } = useUpdatePresentation(); const resolvedTitle = presentation?.title?.trim() ? presentation.title : '내 발표'; - const [editTitle, setEditTitle] = useState(resolvedTitle); - useEffect(() => { - setEditTitle(resolvedTitle); - }, [resolvedTitle]); + if (readOnlyContent) { + return ( + + ); + } + + return ; +} + +function PresentationTitleEditorEditable({ + projectId, + title, +}: { + projectId?: string; + title: string; +}) { + const { mutate: updatePresentation, isPending } = useUpdatePresentation(); - const handleSave = (close: () => void) => { - const trimmedTitle = editTitle.trim(); + const handleSave = (newTitle: string, close: () => void) => { + const trimmedTitle = newTitle.trim(); if (!trimmedTitle) { showToast.error('제목을 입력해주세요'); return; @@ -55,76 +68,12 @@ export function PresentationTitleEditor({ readOnly }: PresentationTitleEditorPro ); }; - if (readOnly) { - return ( -
- - {presentation?.title ?? '내 발표'} - -
- ); - } - return ( - ( - - )} - position="bottom" - align="start" + - {({ close }) => ( - <> - setEditTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(close); - } - }} - disabled={isPending} - aria-label="발표 이름" - placeholder="발표 제목을 입력하세요" - className={clsx( - 'h-9 flex-1 rounded-md border border-gray-200 px-3 text-sm text-gray-800 outline-none', - 'focus:border-main focus-visible:outline-2 focus-visible:outline-main', - 'placeholder:text-gray-400', - 'disabled:opacity-50 disabled:cursor-not-allowed', - )} - /> - - - )} - + isPending={isPending} + /> ); } diff --git a/src/components/common/layout/ShareButton.tsx b/src/components/common/layout/ShareButton.tsx index ed40cbfd..e1a1873a 100644 --- a/src/components/common/layout/ShareButton.tsx +++ b/src/components/common/layout/ShareButton.tsx @@ -1,3 +1,7 @@ +/** + * @file ShareButton.tsx + * @description 공유 모달을 여는 헤더 버튼 + */ import ShareIcon from '@/assets/icons/icon-share.svg?react'; import { useShareStore } from '@/stores/shareStore'; diff --git a/src/components/feedback/FeedbackHeaderCenter.tsx b/src/components/feedback/FeedbackHeaderCenter.tsx new file mode 100644 index 00000000..0cc7986f --- /dev/null +++ b/src/components/feedback/FeedbackHeaderCenter.tsx @@ -0,0 +1,44 @@ +import { useParams } from 'react-router-dom'; + +import InfoIcon from '@/assets/icons/icon-info.svg?react'; +import { Popover } from '@/components/common'; +import { usePresentation } from '@/hooks/queries/usePresentations'; +import dayjs from '@/utils/dayjs'; + +export default function FeedbackHeaderCenter() { + const { projectId } = useParams<{ projectId: string }>(); + + const { data: presentation } = usePresentation(projectId ?? ''); + const title = presentation?.title ?? ''; + const postedAt = presentation?.updatedAt + ? dayjs(presentation.updatedAt).format('YYYY.MM.DD HH:mm:ss') + : '-'; + const publisherName = presentation?.userName ?? '알 수 없음'; + + return ( +
+ + {title} + +
+ ); +} diff --git a/src/components/feedback/FeedbackHeaderLeft.tsx b/src/components/feedback/FeedbackHeaderLeft.tsx index bf11e1be..727f33f8 100644 --- a/src/components/feedback/FeedbackHeaderLeft.tsx +++ b/src/components/feedback/FeedbackHeaderLeft.tsx @@ -1,7 +1,6 @@ import { useParams } from 'react-router-dom'; -import InfoIcon from '@/assets/icons/icon-info.svg?react'; -import { Logo, Popover, PresentationTitleEditor } from '@/components/common'; +import { Logo, PresentationTitleEditor } from '@/components/common'; import { usePresentation } from '@/hooks/queries/usePresentations'; import dayjs from '@/utils/dayjs'; @@ -17,31 +16,16 @@ export default function FeedbackHeaderLeft() { return ( <> -
- - - - - } - position="bottom" - align="start" - ariaLabel="발표 정보" - className="w-72 max-w-[90vw] -translate-x-30 md:translate-x-0 rounded-2xl border border-gray-200 px-6 py-3" - > + 게시자 {publisherName} 게시 날짜 {postedAt}
- -
+ } + /> ); } diff --git a/src/components/feedback/FeedbackMobileLayout.tsx b/src/components/feedback/FeedbackMobileLayout.tsx index 0f936aae..053bda6f 100644 --- a/src/components/feedback/FeedbackMobileLayout.tsx +++ b/src/components/feedback/FeedbackMobileLayout.tsx @@ -57,15 +57,15 @@ export default function FeedbackMobileLayout({ }`; return ( -
+
{/* 미디어 영역 */}
{mediaSlot}
{/* 콘텐츠 영역 */} -
+
{navigationSlot ?
{navigationSlot}
:
} -
{reactionSlot}
+
{reactionSlot}
{/* 탭 메뉴 */} diff --git a/src/components/feedback/ProgressBar.tsx b/src/components/feedback/ProgressBar.tsx index b03231ef..d09bf12d 100644 --- a/src/components/feedback/ProgressBar.tsx +++ b/src/components/feedback/ProgressBar.tsx @@ -9,7 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { REACTION_CONFIG } from '@/constants/reaction'; -import type { Slide } from '@/types/slide'; +import type { SlideListItem } from '@/types/slide'; import type { SegmentHighlight } from '@/types/video'; import { formatVideoTimestamp } from '@/utils/format'; import { getSlideIndexFromTime } from '@/utils/video'; @@ -22,7 +22,7 @@ interface ProgressBarProps { /** seek 콜백 */ onSeek: (time: number) => void; /** 슬라이드 목록 (썸네일 미리보기용) */ - slides?: Slide[]; + slides?: SlideListItem[]; /** 슬라이드 전환 시간 배열 */ slideChangeTimes?: number[]; /** 5초 버킷별 세그먼트 하이라이트 (재생바 위 이모지 표시) */ @@ -214,7 +214,7 @@ export default function ProgressBar({ > {slides && hoverSlideIndex !== null && slides[hoverSlideIndex] && ( slide thumbnail diff --git a/src/components/feedback/ReactionButtons.tsx b/src/components/feedback/ReactionButtons.tsx index dcdf02a2..9a2a740a 100644 --- a/src/components/feedback/ReactionButtons.tsx +++ b/src/components/feedback/ReactionButtons.tsx @@ -44,7 +44,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.5 ${showLabel ? 'flex-wrap' : 'flex-nowrap'} ${className ?? ''}`; + : `flex gap-2 ${showLabel ? 'flex-wrap' : 'flex-nowrap justify-center overflow-hidden'} ${className ?? ''}`; const handleToggle = (type: ReactionType, isCurrentlyActive: boolean) => { // 활성화될 때만 confetti 효과 트리거 @@ -62,16 +62,15 @@ export default function ReactionButtons({ {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'; + const baseBtn = showLabel + ? 'flex items-center justify-between px-2 py-2 rounded-full border transition text-body-m focus-visible:outline-2 focus-visible:outline-main w-42.25' + : 'flex items-center gap-2 px-3 py-2 rounded-full border transition text-body-m focus-visible:outline-2 focus-visible:outline-main shrink-0'; return ( - - ) : ( -
-

- -

-

{formatRelativeTime(updatedAt)}

-
- )} + +

{formatRelativeTime(updatedAt)}

+
{/* 더보기 */} - {!isRenaming && ( -
e.stopPropagation()} className="shrink-0 mt-1"> - ( -
- -
- )} - items={dropdownItems} - position="bottom" - align="end" - ariaLabel="더보기" - menuClassName="w-32" - /> -
- )} +
e.stopPropagation()} className="shrink-0 mt-1"> + ( +
+ +
+ )} + items={dropdownItems} + position="bottom" + align="end" + ariaLabel="더보기" + menuClassName="w-32" + /> +
@@ -252,6 +209,18 @@ function PresentationCard({ onConfirm={confirmDelete} />
+ + {/* 이름 변경 모달 */} +
e.stopPropagation()}> + +
); } diff --git a/src/components/presentation/PresentationHeader.tsx b/src/components/presentation/PresentationHeader.tsx index ddc154eb..cee9a072 100644 --- a/src/components/presentation/PresentationHeader.tsx +++ b/src/components/presentation/PresentationHeader.tsx @@ -1,7 +1,6 @@ import clsx from 'clsx'; import ArrowDownIcon from '@/assets/icons/icon-arrow-down.svg?react'; -import ArrowUpIcon from '@/assets/icons/icon-arrow-up.svg?react'; import FilterIcon from '@/assets/icons/icon-filter.svg?react'; import SearchIcon from '@/assets/icons/icon-search.svg?react'; import ViewCardIcon from '@/assets/icons/icon-view-card.svg?react'; @@ -40,7 +39,7 @@ export default function PresentationHeader({ return (
{/* 검색 부분 */} -
+
@@ -86,13 +85,18 @@ export default function PresentationHeader({ trigger={({ isOpen }) => ( )} position="bottom" @@ -111,7 +115,7 @@ export default function PresentationHeader({ - - ) : ( -
{displayTitle}
- )} +
{displayTitle}
{/* 메타 정보 */}
@@ -218,22 +175,20 @@ function PresentationList({
{/* 더보기 */} - {!isRenaming && ( -
e.stopPropagation()} className="-m-2"> - ( -
- -
- )} - items={dropdownItems} - position="bottom" - align="end" - ariaLabel="더보기" - menuClassName="w-32" - /> -
- )} +
e.stopPropagation()} className="-m-2"> + ( +
+ +
+ )} + items={dropdownItems} + position="bottom" + align="end" + ariaLabel="더보기" + menuClassName="w-32" + /> +
@@ -247,6 +202,18 @@ function PresentationList({ onConfirm={confirmDelete} />
+ + {/* 이름 변경 모달 */} +
e.stopPropagation()}> + +
); } diff --git a/src/components/presentation/RenamePresentationModal.tsx b/src/components/presentation/RenamePresentationModal.tsx new file mode 100644 index 00000000..8966d785 --- /dev/null +++ b/src/components/presentation/RenamePresentationModal.tsx @@ -0,0 +1,75 @@ +import { type KeyboardEvent, useEffect, useRef } from 'react'; + +import { Modal, TextField } from '../common'; + +type Props = { + isOpen: boolean; + currentTitle: string; + isPending?: boolean; + onClose: () => void; + onConfirm: () => void; + onTitleChange: (title: string) => void; +}; + +export default function RenamePresentationModal({ + isOpen, + currentTitle, + isPending = false, + onClose, + onConfirm, + onTitleChange, +}: Props) { + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setTimeout(() => inputRef.current?.select(), 50); + } + }, [isOpen]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + onConfirm(); + } + }; + + return ( + + onTitleChange(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isPending} + placeholder="발표 제목을 입력하세요" + /> +
+ + +
+
+ ); +} diff --git a/src/components/share/share-modal/ShareModal.tsx b/src/components/share/share-modal/ShareModal.tsx index a4dba2a6..1fce4f96 100644 --- a/src/components/share/share-modal/ShareModal.tsx +++ b/src/components/share/share-modal/ShareModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useParams } from 'react-router-dom'; import clsx from 'clsx'; @@ -39,9 +39,6 @@ export function ShareModal() { const setShareUrl = useShareStore((s) => s.setShareUrl); const setStep = useShareStore((s) => s.setStep); - // 복사완료 토스트 알림용 - const [copied, setCopied] = useState(false); - // 공유 가능 영상 목록 조회 (모달이 열려있고, 영상포함 유형일 때만 fetch) const { data: videosData, @@ -99,13 +96,12 @@ export function ShareModal() { const handleCopy = async () => { try { await navigator.clipboard.writeText(shareUrl); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + showToast.success('복사가 완료되었습니다.'); } catch { - setCopied(false); showToast.error('복사에 실패했습니다.'); } }; + const handleGenerate = async () => { // 프로젝트 id없으면 생성x if (!projectId) return; @@ -313,18 +309,6 @@ export function ShareModal() { > - {copied && ( -
- URL을 복사했습니다. -
- )}
diff --git a/src/components/slide/SlideWorkspace.tsx b/src/components/slide/SlideWorkspace.tsx index 69b23ccc..1a64168f 100644 --- a/src/components/slide/SlideWorkspace.tsx +++ b/src/components/slide/SlideWorkspace.tsx @@ -10,7 +10,8 @@ import { useEffect, useState } from 'react'; import { SLIDE_MAX_WIDTH } from '@/constants/layout'; -import { useSlideActions } from '@/hooks'; +import { useSlideActions, useSlideId } from '@/hooks'; +import { useSlideCommentsQuery } from '@/hooks/queries/useCommentQueries'; import type { SlideListItem } from '@/types/slide'; import SlideViewer from './SlideViewer'; @@ -23,13 +24,22 @@ interface SlideWorkspaceProps { export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps) { const [isScriptCollapsed, setIsScriptCollapsed] = useState(false); - const { initSlide } = useSlideActions(); + const { initSlide, setComments } = useSlideActions(); + const slideId = useSlideId(); + const { data: fetchedComments } = useSlideCommentsQuery(slideId); useEffect(() => { if (slide) { initSlide(slide); + setComments([]); } - }, [slide, initSlide]); + }, [slide, initSlide, setComments]); + + useEffect(() => { + if (fetchedComments) { + setComments(fetchedComments); + } + }, [fetchedComments, setComments]); return (
diff --git a/src/components/slide/script/CommentPopover.tsx b/src/components/slide/script/CommentPopover.tsx index 857c73e1..68f34679 100644 --- a/src/components/slide/script/CommentPopover.tsx +++ b/src/components/slide/script/CommentPopover.tsx @@ -12,7 +12,7 @@ import clsx from 'clsx'; import Comment from '@/components/comment/Comment'; import { CommentProvider } from '@/components/comment/CommentContext'; import { Popover, Skeleton } from '@/components/common'; -import { useSlideOpinions } from '@/hooks'; +import { useSlideComments } from '@/hooks'; import { useComments } from '@/hooks/useComments'; interface CommentPopoverProps { @@ -20,11 +20,13 @@ interface CommentPopoverProps { } export default function CommentPopover({ isLoading }: CommentPopoverProps) { - const opinions = useSlideOpinions(); - const { comments: treeOpinions, addReply, deleteComment } = useComments(); + const slideComments = useSlideComments(); + const { comments: treeComments, addReply, deleteComment, updateComment } = useComments(); const [replyingToId, setReplyingToId] = useState(null); const [replyDraft, setReplyDraft] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editDraft, setEditDraft] = useState(''); const submitReply = useCallback( (targetId: string) => { @@ -47,6 +49,27 @@ export default function CommentPopover({ isLoading }: CommentPopoverProps) { setReplyDraft(''); }, []); + const startEdit = useCallback((id: string, currentContent: string) => { + setEditingId(id); + setEditDraft(currentContent); + }, []); + + const cancelEdit = useCallback(() => { + setEditingId(null); + setEditDraft(''); + }, []); + + const submitEdit = useCallback( + (id: string) => { + if (editDraft.trim()) { + updateComment(id, editDraft.trim()); + } + setEditingId(null); + setEditDraft(''); + }, + [editDraft, updateComment], + ); + const contextValue = useMemo( () => ({ replyingToId, @@ -56,9 +79,27 @@ export default function CommentPopover({ isLoading }: CommentPopoverProps) { submitReply, cancelReply, deleteComment, + editingId, + editDraft, + setEditDraft, + startEdit, + cancelEdit, + submitEdit, goToRef: () => {}, // 슬라이드 페이지에서는 ref 이동 불필요 }), - [replyingToId, replyDraft, toggleReply, submitReply, cancelReply, deleteComment], + [ + replyingToId, + replyDraft, + toggleReply, + submitReply, + cancelReply, + deleteComment, + editingId, + editDraft, + startEdit, + cancelEdit, + submitEdit, + ], ); return ( @@ -66,7 +107,7 @@ export default function CommentPopover({ isLoading }: CommentPopoverProps) { trigger={({ isOpen }) => ( @@ -110,8 +151,8 @@ export default function CommentPopover({ isLoading }: CommentPopoverProps) { {/* 의견 목록 */}
- {treeOpinions.map((opinion) => ( - + {treeComments.map((comment) => ( + ))}
diff --git a/src/components/slide/script/SlideTitle.tsx b/src/components/slide/script/SlideTitle.tsx index f0670c4b..32ed0d27 100644 --- a/src/components/slide/script/SlideTitle.tsx +++ b/src/components/slide/script/SlideTitle.tsx @@ -5,12 +5,7 @@ * ScriptBox 헤더에서 슬라이드 제목을 클릭하면 나타나는 편집 UI입니다. * Zustand store를 통해 슬라이드 제목을 읽고 업데이트합니다. */ -import { useEffect, useState } from 'react'; - -import clsx from 'clsx'; - -import ArrowDownIcon from '@/assets/icons/icon-arrow-down.svg?react'; -import { Popover } from '@/components/common'; +import { TitleEditorPopover } from '@/components/common'; import { useSlideActions, useSlideId, useSlideTitle, useUpdateSlide } from '@/hooks'; interface SlideTitleProps { @@ -25,91 +20,60 @@ export default function SlideTitle({ fallbackTitle, readOnly = false, }: SlideTitleProps) { - const slideId = useSlideId(); const title = useSlideTitle(); - const { updateSlide } = useSlideActions(); - const { mutate: updateSlideApi } = useUpdateSlide(); const resolvedFallback = fallbackTitle?.trim() ? fallbackTitle : undefined; const resolvedTitle = title?.trim() ? title : (resolvedFallback ?? ''); - const [editTitle, setEditTitle] = useState(resolvedTitle); - useEffect(() => { - setEditTitle(resolvedTitle); - }, [resolvedTitle]); + if (readOnly) { + return ( + + {resolvedTitle} + + ); + } + + return ( + + ); +} + +function SlideTitleEditable({ + title, + fallbackTitle, + isCollapsed, +}: { + title: string; + fallbackTitle?: string; + isCollapsed: boolean; +}) { + const slideId = useSlideId(); + const storeTitle = useSlideTitle(); + const { updateSlide } = useSlideActions(); + const { mutate: updateSlideApi } = useUpdateSlide(); - /** - * 변경된 제목을 저장합니다. - */ - const handleSave = () => { - const nextTitle = editTitle.trim() || title || resolvedFallback; + const handleSave = (newTitle: string, close: () => void) => { + const nextTitle = newTitle.trim() || storeTitle || fallbackTitle; if (!nextTitle) return; - // 로컬 store 즉시 업데이트 + updateSlide({ title: nextTitle }); - // API 호출 if (slideId) { updateSlideApi({ slideId, data: { title: nextTitle } }); } - }; - if (readOnly) { - return ( - - {resolvedTitle} - - ); - } + close(); + }; return ( - ( - - )} - position={isCollapsed ? 'top' : 'bottom'} - align="start" + - {({ close }) => ( - <> - setEditTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(); - close(); - } - }} - aria-label="슬라이드 이름" - className="h-9 flex-1 rounded-md border border-gray-200 px-3 text-sm text-gray-800 outline-none focus:border-main focus-visible:outline-2 focus-visible:outline-main" - /> - - - )} - + /> ); } diff --git a/src/components/video/DeviceTestSection.tsx b/src/components/video/DeviceTestSection.tsx index 6322ff57..804aadc8 100644 --- a/src/components/video/DeviceTestSection.tsx +++ b/src/components/video/DeviceTestSection.tsx @@ -40,16 +40,16 @@ export const DeviceTestSection = ({ onComplete }: DeviceTestSectionProps) => { const renderTrigger = (label: string, value: string, kind: MediaDeviceKind) => { const currentDevice = devices.find((d) => d.deviceId === value && d.kind === kind); return ( -
+
{currentDevice?.label || `${label} 선택`} @@ -58,20 +58,28 @@ export const DeviceTestSection = ({ onComplete }: DeviceTestSectionProps) => { }; return ( -
-

- 웹캠, 마이크를 테스트해주세요. -

+
+
+

+ 웹캠, 마이크를 테스트해주세요. +

-
-
-
-
-
- +
+
+ { onClick: () => setSelectedVideo(d.deviceId), }))} className="w-full" - menuClassName="w-full max-h-[15rem] overflow-y-auto" + menuClassName="w-full max-h-60 overflow-y-auto" />
-
- +
+ { onClick: () => setSelectedAudio(d.deviceId), }))} className="w-full" - menuClassName="w-full max-h-[15rem] overflow-y-auto" + menuClassName="w-full max-h-60 overflow-y-auto" /> -
+
-

- 또랑또랑한 목소리를 들려주세요. -

+

또랑또랑한 목소리를 들려주세요.

-
+
{ if (stream) { - console.log('📹 Original stream:', { - id: stream.id, - active: stream.active, - tracks: stream.getTracks().map((t) => ({ - kind: t.kind, - enabled: t.enabled, - readyState: t.readyState, - })), - }); - - // 스트림 복제 const clonedStream = stream.clone(); - console.log('📹 Cloned stream:', { - id: clonedStream.id, - active: clonedStream.active, - tracks: clonedStream.getTracks().map((t) => ({ - kind: t.kind, - enabled: t.enabled, - readyState: t.readyState, - })), - }); - onComplete({ cam: clonedStream }); } }} - />{' '} + />
); diff --git a/src/components/video/RecordingSection.tsx b/src/components/video/RecordingSection.tsx index 092ce2d9..69f9f76b 100644 --- a/src/components/video/RecordingSection.tsx +++ b/src/components/video/RecordingSection.tsx @@ -2,11 +2,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import IconArrowLeft from '@/assets/icons/icon-arrow-left.svg?react'; import IconArrowRight from '@/assets/icons/icon-arrow-right.svg?react'; -import IconStop from '@/assets/icons/icon-stop.svg?react'; -import { Logo, PresentationTitleEditor, SlideImage } from '@/components/common'; +import { Logo, SlideImage } from '@/components/common'; +import { usePresentation } from '@/hooks/queries/usePresentations'; import { useSlides } from '@/hooks/queries/useSlides'; import { useRecorder } from '../../hooks/useRecorder'; +import StopButton from './StopButton'; interface SlideData { page: number; @@ -18,14 +19,21 @@ interface RecordingSectionProps { projectId: string; initialStream: MediaStream; onFinish: (videoBlob: Blob, durations: { [key: number]: number }) => void; + onExitClick?: () => void; } -export const RecordingSection = ({ projectId, initialStream, onFinish }: RecordingSectionProps) => { +export const RecordingSection = ({ + projectId, + initialStream, + onFinish, + onExitClick, +}: RecordingSectionProps) => { const slideImgRef = useRef(null); const logContainerRef = useRef(null); const { canvasRef, isRecording, recordedChunks, startRecording, stopRecording } = useRecorder(); + const { data: presentation } = usePresentation(projectId); const { data: slidesData } = useSlides(projectId); const slidesList = slidesData || []; const totalPages = slidesList.length > 0 ? slidesList.length : 1; @@ -155,13 +163,15 @@ export const RecordingSection = ({ projectId, initialStream, onFinish }: Recordi }, [isFinishing, isRecording, stopRecording, recordedChunks, slideProgress, onFinish]); return ( -
+
{/* Header */} -
+
- - + + + {presentation?.title || '내 발표'} +
@@ -186,14 +196,11 @@ export const RecordingSection = ({ projectId, initialStream, onFinish }: Recordi {formatTime(totalSeconds)}
- + onClick={handleFinish} + />
@@ -251,7 +258,7 @@ export const RecordingSection = ({ projectId, initialStream, onFinish }: Recordi {/* Sidebar */} -