From ab2962123712dd6b2200b44923d9b930885bb812 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 02:51:01 +0900 Subject: [PATCH 01/22] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=94=EA=B8=B0=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/client.ts | 26 ++-- src/api/dto/index.ts | 10 ++ src/api/dto/opinions.dto.ts | 8 ++ src/api/dto/projects.dto.ts | 14 ++ src/api/dto/reactions.dto.ts | 8 ++ src/api/dto/scripts.dto.ts | 13 ++ src/api/dto/slides.dto.ts | 14 ++ src/api/endpoints/opinions.ts | 27 ++-- src/api/endpoints/presentations.ts | 93 +++++++++--- src/api/endpoints/reactions.ts | 41 ++++-- src/api/endpoints/scripts.ts | 24 +++- src/api/endpoints/slides.ts | 69 ++++++--- src/api/index.ts | 5 +- src/api/queryClient.ts | 8 +- src/components/feedback/SlideViewer.tsx | 6 +- .../feedback/slide/SlideInfoPanel.tsx | 4 +- src/components/home/PresentationsSection.tsx | 4 +- .../presentation/PresentationCard.tsx | 8 +- .../presentation/PresentationList.tsx | 8 +- src/components/slide/SlideList.tsx | 12 +- src/components/slide/SlideThumbnail.tsx | 8 +- src/components/slide/SlideWorkspace.tsx | 4 +- src/components/video/VideoListPage.tsx | 23 +-- src/hooks/queries/usePresentations.ts | 8 +- src/hooks/useComments.ts | 2 +- src/hooks/useReactions.ts | 2 +- src/hooks/useSlideSelectors.ts | 4 +- src/hooks/useUpload.ts | 6 +- src/mocks/handlers.ts | 136 ++++++++++-------- src/mocks/projects.ts | 8 +- src/pages/FeedbackSlidePage.tsx | 8 +- src/pages/InsightPage.tsx | 4 +- src/pages/SlidePage.tsx | 16 +-- src/stores/slideStore.ts | 14 +- src/types/api.ts | 51 +++++-- src/types/presentation.ts | 11 +- src/types/slide.ts | 55 ++++++- 37 files changed, 531 insertions(+), 231 deletions(-) create mode 100644 src/api/dto/index.ts create mode 100644 src/api/dto/opinions.dto.ts create mode 100644 src/api/dto/projects.dto.ts create mode 100644 src/api/dto/reactions.dto.ts create mode 100644 src/api/dto/scripts.dto.ts create mode 100644 src/api/dto/slides.dto.ts diff --git a/src/api/client.ts b/src/api/client.ts index e7ea4618..be991884 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -19,12 +19,21 @@ import { useAuthStore } from '@/stores/authStore'; export const API_TIMEOUT_MS = 10000; /** - * API 에러 응답 타입 + * API 에러 응답 타입 (FAILURE 응답의 error 필드) */ -export interface ApiError { - message: string; - code?: string; - status?: number; +export interface ApiErrorResponse { + errorCode: string; + reason: string; + data?: unknown; +} + +/** + * API FAILURE 응답 구조 + */ +export interface ApiFailureResponse { + resultType: 'FAILURE'; + error: ApiErrorResponse; + success: null; } /** @@ -69,14 +78,15 @@ apiClient.interceptors.request.use( */ apiClient.interceptors.response.use( (response) => response, - (error: AxiosError) => { + (error: AxiosError) => { const status = error.response?.status; - const message = error.response?.data?.message || '알 수 없는 오류가 발생했습니다'; + const errorData = error.response?.data?.error; + const reason = errorData?.reason || '알 수 없는 오류가 발생했습니다'; // [하이브리드 전략] // 시스템 에러 (401 인증, 500 서버 장애)는 Axios가 즉시 처리 if (status === 401 || (status && status >= 500)) { - handleApiError(status, message); + handleApiError(status, reason); // 다운스트림(React Query 등)에서 중복 처리하지 않도록 플래그 설정 error.isHandled = true; } diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts new file mode 100644 index 00000000..2dc92e8f --- /dev/null +++ b/src/api/dto/index.ts @@ -0,0 +1,10 @@ +/** + * @file index.ts + * @description DTO 배럴 export + */ + +export type { CreateProjectDto, UpdateProjectDto } from './projects.dto'; +export type { CreateSlideDto, UpdateSlideDto } from './slides.dto'; +export type { UpdateScriptDto, RestoreScriptDto } from './scripts.dto'; +export type { CreateOpinionDto } from './opinions.dto'; +export type { ToggleSlideReactionDto } from './reactions.dto'; diff --git a/src/api/dto/opinions.dto.ts b/src/api/dto/opinions.dto.ts new file mode 100644 index 00000000..e6de12d7 --- /dev/null +++ b/src/api/dto/opinions.dto.ts @@ -0,0 +1,8 @@ +/** + * 의견(댓글) 생성 요청 DTO + */ +export interface CreateOpinionDto { + content: string; + /** 답글인 경우 부모 의견 ID */ + parentId?: string; +} diff --git a/src/api/dto/projects.dto.ts b/src/api/dto/projects.dto.ts new file mode 100644 index 00000000..5f3e7ff3 --- /dev/null +++ b/src/api/dto/projects.dto.ts @@ -0,0 +1,14 @@ +/** + * 프로젝트 수정 요청 DTO + */ +export interface UpdateProjectDto { + title: string; +} + +/** + * 프로젝트 생성 요청 DTO + */ +export interface CreateProjectDto { + title: string; + uploadFileId: string; +} diff --git a/src/api/dto/reactions.dto.ts b/src/api/dto/reactions.dto.ts new file mode 100644 index 00000000..bdc86777 --- /dev/null +++ b/src/api/dto/reactions.dto.ts @@ -0,0 +1,8 @@ +import type { ReactionType } from '@/types/script'; + +/** + * 슬라이드 리액션 토글 요청 DTO + */ +export interface ToggleSlideReactionDto { + type: ReactionType; +} diff --git a/src/api/dto/scripts.dto.ts b/src/api/dto/scripts.dto.ts new file mode 100644 index 00000000..b3946555 --- /dev/null +++ b/src/api/dto/scripts.dto.ts @@ -0,0 +1,13 @@ +/** + * 대본 저장 요청 DTO + */ +export interface UpdateScriptDto { + script: string; +} + +/** + * 대본 복원 요청 DTO + */ +export interface RestoreScriptDto { + version: number; +} diff --git a/src/api/dto/slides.dto.ts b/src/api/dto/slides.dto.ts new file mode 100644 index 00000000..bc3e8e83 --- /dev/null +++ b/src/api/dto/slides.dto.ts @@ -0,0 +1,14 @@ +/** + * 슬라이드 제목 수정 요청 DTO + */ +export interface UpdateSlideDto { + title?: string; +} + +/** + * 슬라이드 생성 요청 DTO + */ +export interface CreateSlideDto { + title: string; + script?: string; +} diff --git a/src/api/endpoints/opinions.ts b/src/api/endpoints/opinions.ts index e9c5c21b..adeee697 100644 --- a/src/api/endpoints/opinions.ts +++ b/src/api/endpoints/opinions.ts @@ -3,16 +3,15 @@ * @description 의견(댓글) 관련 API 엔드포인트 */ import { apiClient } from '@/api'; +import type { CreateOpinionDto } from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; import type { Comment } from '@/types/comment'; /** - * 의견 생성 요청 타입 + * 의견 생성 요청 타입 (하위 호환성) + * @deprecated CreateOpinionDto 사용 권장 */ -export interface CreateOpinionRequest { - content: string; - /** 답글인 경우 부모 의견 ID */ - parentId?: string; -} +export type CreateOpinionRequest = CreateOpinionDto; /** * 의견 추가 @@ -21,9 +20,13 @@ export interface CreateOpinionRequest { * @param data - 의견 데이터 * @returns 생성된 의견 */ -export async function createOpinion(slideId: string, data: CreateOpinionRequest): Promise { - const response = await apiClient.post(`/slides/${slideId}/opinions`, data); - return response.data; +export async function createOpinion(slideId: string, data: CreateOpinionDto): Promise { + const response = await apiClient.post>(`/slides/${slideId}/opinions`, data); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -32,5 +35,9 @@ export async function createOpinion(slideId: string, data: CreateOpinionRequest) * @param opinionId - 삭제할 의견 ID */ export async function deleteOpinion(opinionId: string): Promise { - await apiClient.delete(`/opinions/${opinionId}`); + const response = await apiClient.delete>(`/opinions/${opinionId}`); + + if (response.data.resultType === 'FAILURE') { + throw new Error(response.data.error.reason); + } } diff --git a/src/api/endpoints/presentations.ts b/src/api/endpoints/presentations.ts index c4ff2f45..658d6226 100644 --- a/src/api/endpoints/presentations.ts +++ b/src/api/endpoints/presentations.ts @@ -1,16 +1,19 @@ /** - * @file projects.ts + * @file presentations.ts * @description 프로젝트 관련 API 엔드포인트 * * 서버와 통신하는 함수들을 정의합니다. * 이 함수들은 직접 호출하지 않고, hooks/queries에서 사용합니다. - * - * 위에 interface로 받는 타입 정의 해주고 - * 아래에서 endpoint 맞춰주고 */ -import type { Presentation } from '@/types/presentation'; - -import { apiClient } from '../client'; +import { apiClient } from '@/api'; +import type { UpdateProjectDto } from '@/api/dto'; +import type { ApiResponse, ConversionStatusResponse } from '@/types/api'; +import type { + CreatePresentationRequest, + CreatePresentationSuccess, + Presentation, + ProjectUpdateResponse, +} from '@/types/presentation'; /** * 프로젝트 목록 조회 @@ -19,8 +22,12 @@ import { apiClient } from '../client'; * @returns 프로젝트 배열 */ export async function getPresentations(): Promise { - const response = await apiClient.get(`/presentations`); - return response.data; + const response = await apiClient.get>(`/presentations`); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -29,16 +36,19 @@ export async function getPresentations(): Promise { * @param projectId - 프로젝트 ID */ export async function getPresentation(projectId: string): Promise { - const response = await apiClient.get(`/presentations/${projectId}`); - return response.data; + const response = await apiClient.get>(`/presentations/${projectId}`); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** - * 프로젝트 수정 요청 타입 + * 프로젝트 수정 요청 타입 (하위 호환성) + * @deprecated UpdateProjectDto 사용 권장 */ -export interface UpdatePresentationRequest { - title?: string; -} +export type UpdatePresentationRequest = UpdateProjectDto; /** * 프로젝트 수정 @@ -49,10 +59,17 @@ export interface UpdatePresentationRequest { */ export async function updatePresentation( projectId: string, - data: UpdatePresentationRequest, -): Promise { - const response = await apiClient.patch(`/presentations/${projectId}`, data); - return response.data; + data: UpdateProjectDto, +): Promise { + const response = await apiClient.patch>( + `/presentations/${projectId}`, + data, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -61,9 +78,18 @@ export async function updatePresentation( * @param data - 생성할 프로젝트 데이터 * @returns 생성된 프로젝트 */ -export async function createPresentation(data: { title: string }): Promise { - const response = await apiClient.post(`/presentations`, data); - return response.data; +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); } /** @@ -72,5 +98,26 @@ export async function createPresentation(data: { title: string }): Promise { - await apiClient.delete(`/presentations/${projectId}`); + const response = await apiClient.delete>(`/presentations/${projectId}`); + + if (response.data.resultType === 'FAILURE') { + throw new Error(response.data.error.reason); + } +} + +/** + * 프로젝트 파일 변환 상태 조회 + * + * @param projectId - 프로젝트 ID + * @returns 변환 상태 정보 + */ +export async function getConversionStatus(projectId: string): Promise { + const response = await apiClient.get>( + `/presentations/${projectId}/conversion-status`, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } diff --git a/src/api/endpoints/reactions.ts b/src/api/endpoints/reactions.ts index 3bbdfe73..474ad4da 100644 --- a/src/api/endpoints/reactions.ts +++ b/src/api/endpoints/reactions.ts @@ -1,11 +1,36 @@ +/** + * @file reactions.ts + * @description 슬라이드 리액션 관련 API 엔드포인트 + */ import { apiClient } from '@/api'; -import type { Reaction, ReactionType } from '@/types/script'; +import type { ToggleSlideReactionDto } from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; +import type { Reaction } from '@/types/script'; -export interface ToggleReactionRequest { - type: ReactionType; -} +/** + * 리액션 토글 요청 타입 (하위 호환성) + * @deprecated ToggleSlideReactionDto 사용 권장 + */ +export type ToggleReactionRequest = ToggleSlideReactionDto; + +/** + * 슬라이드 리액션 토글 + * + * @param slideId - 슬라이드 ID + * @param data - 리액션 데이터 + * @returns 업데이트된 리액션 배열 + */ +export async function toggleReaction( + slideId: string, + data: ToggleSlideReactionDto, +): Promise { + const response = await apiClient.post>( + `/slides/${slideId}/reactions`, + data, + ); -export const toggleReaction = async (slideId: string, data: ToggleReactionRequest) => { - const { data: response } = await apiClient.post(`/slides/${slideId}/reactions`, data); - return response; -}; + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} diff --git a/src/api/endpoints/scripts.ts b/src/api/endpoints/scripts.ts index b7644f98..47bbcae8 100644 --- a/src/api/endpoints/scripts.ts +++ b/src/api/endpoints/scripts.ts @@ -15,7 +15,11 @@ export async function getScript(slideId: string): Promise { const response = await apiClient.get>( `/presentations/slides/${slideId}/script`, ); - return response.data.success; + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -40,7 +44,11 @@ export async function updateScript( `/presentations/slides/${slideId}/script`, data, ); - return response.data.success; + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -53,7 +61,11 @@ export async function getScriptVersions(slideId: string): Promise>( `/presentations/slides/${slideId}/versions`, ); - return response.data.success; + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -78,5 +90,9 @@ export async function restoreScript( `/presentations/slides/${slideId}/restore`, data, ); - return response.data.success; + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } diff --git a/src/api/endpoints/slides.ts b/src/api/endpoints/slides.ts index 725cce4b..e4a1d7c3 100644 --- a/src/api/endpoints/slides.ts +++ b/src/api/endpoints/slides.ts @@ -6,7 +6,9 @@ * 이 함수들은 직접 호출하지 않고, hooks/queries에서 사용합니다. */ import { apiClient } from '@/api'; -import type { Slide } from '@/types/slide'; +import type { UpdateSlideDto } from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; +import type { SlideDetail, SlideListItem, SlideUpdateResponse } from '@/types/slide'; /** * 프로젝트의 슬라이드 목록 조회 @@ -17,9 +19,15 @@ import type { Slide } from '@/types/slide'; * @example * const slides = await getSlides('project-123'); */ -export async function getSlides(projectId: string): Promise { - const response = await apiClient.get(`/projects/${projectId}/slides`); - return response.data; +export async function getSlides(projectId: string): Promise { + const response = await apiClient.get>( + `/projects/${projectId}/slides`, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -28,18 +36,20 @@ export async function getSlides(projectId: string): Promise { * @param slideId - 슬라이드 ID * @returns 슬라이드 정보 */ -export async function getSlide(slideId: string): Promise { - const response = await apiClient.get(`/slides/${slideId}`); - return response.data; +export async function getSlide(slideId: string): Promise { + const response = await apiClient.get>(`/slides/${slideId}`); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** - * 슬라이드 수정 요청 타입 + * 슬라이드 수정 요청 타입 (하위 호환성) + * @deprecated UpdateSlideDto 사용 권장 */ -export interface UpdateSlideRequest { - title?: string; - script?: string; -} +export type UpdateSlideRequest = UpdateSlideDto; /** * 슬라이드 수정 @@ -48,9 +58,19 @@ export interface UpdateSlideRequest { * @param data - 수정할 데이터 * @returns 수정된 슬라이드 */ -export async function updateSlide(slideId: string, data: UpdateSlideRequest): Promise { - const response = await apiClient.patch(`/slides/${slideId}`, data); - return response.data; +export async function updateSlide( + slideId: string, + data: UpdateSlideDto, +): Promise { + const response = await apiClient.patch>( + `/slides/${slideId}`, + data, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -63,9 +83,16 @@ export async function updateSlide(slideId: string, data: UpdateSlideRequest): Pr export async function createSlide( projectId: string, data: { title: string; script?: string }, -): Promise { - const response = await apiClient.post(`/projects/${projectId}/slides`, data); - return response.data; +): 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); } /** @@ -74,5 +101,9 @@ export async function createSlide( * @param slideId - 삭제할 슬라이드 ID */ export async function deleteSlide(slideId: string): Promise { - await apiClient.delete(`/slides/${slideId}`); + const response = await apiClient.delete>(`/slides/${slideId}`); + + if (response.data.resultType === 'FAILURE') { + throw new Error(response.data.error.reason); + } } diff --git a/src/api/index.ts b/src/api/index.ts index 8f7231cf..69c43e33 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,6 +2,9 @@ * API 모듈 배럴 파일 */ export { apiClient } from './client'; -export type { ApiError } from './client'; +export type { ApiErrorResponse } from './client'; +/** @deprecated ApiErrorResponse 사용 권장 */ +export type { ApiErrorResponse as ApiError } from './client'; export { queryClient, queryKeys } from './queryClient'; export * from './endpoints/videos'; +export * from './dto'; diff --git a/src/api/queryClient.ts b/src/api/queryClient.ts index 70645a76..07a52254 100644 --- a/src/api/queryClient.ts +++ b/src/api/queryClient.ts @@ -9,7 +9,7 @@ import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; import { isAxiosError } from 'axios'; -import { type ApiError } from '@/api/client'; +import { type ApiFailureResponse } from '@/api/client'; import { handleApiError } from '@/api/errorHandler'; /** @@ -25,16 +25,16 @@ export const MAX_RETRIES = 1; // 실패 시 재시도 횟수 */ const handleGlobalError = (error: unknown) => { // 1. Axios 에러인 경우 - if (isAxiosError(error)) { + if (isAxiosError(error)) { // 이미 Axios 인터셉터에서 처리된 에러라면 무시 (중복 토스트 방지) if (error.isHandled) { return; } const status = error.response?.status; - const message = error.response?.data?.message || error.message; + const reason = error.response?.data?.error?.reason || error.message; - handleApiError(status, message); + handleApiError(status, reason); return; } diff --git a/src/components/feedback/SlideViewer.tsx b/src/components/feedback/SlideViewer.tsx index c254f0fb..05babcbe 100644 --- a/src/components/feedback/SlideViewer.tsx +++ b/src/components/feedback/SlideViewer.tsx @@ -2,12 +2,12 @@ * @file SlideViewer.tsx * @description 피드백 화면 좌측 슬라이드 뷰어 */ -import type { Slide } from '@/types/slide'; +import type { SlideListItem } from '@/types/slide'; import SlideInfoPanel from './slide/SlideInfoPanel'; interface SlideViewerProps { - slide: Slide | undefined; + slide: SlideListItem | undefined; slideIndex: number; totalSlides: number; isFirst: boolean; @@ -36,7 +36,7 @@ export default function SlideViewer({ return (
- {slide.title} + {slide.title}
item.id} + getKey={(item) => item.projectId} className="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-3" renderCard={(item) => } empty={null} @@ -140,7 +140,7 @@ export default function PresentationsSection({ ) : ( item.id} + getKey={(item) => item.projectId} className="mt-6 flex flex-col gap-3" renderInfo={(item) => } empty={null} diff --git a/src/components/presentation/PresentationCard.tsx b/src/components/presentation/PresentationCard.tsx index 23d5a65d..78e85f05 100644 --- a/src/components/presentation/PresentationCard.tsx +++ b/src/components/presentation/PresentationCard.tsx @@ -83,7 +83,7 @@ function PresentationCardSkeleton() { } function PresentationCard({ - id, + projectId, title, highlightQuery = '', updatedAt, @@ -96,7 +96,7 @@ function PresentationCard({ }: Props) { const navigate = useNavigate(); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = - usePresentationDeletion(id); + usePresentationDeletion(projectId); const { isRenaming, @@ -108,11 +108,11 @@ function PresentationCard({ startRenaming, handleSubmit, cancelRenaming, - } = useRename({ projectId: id, initialTitle: title }); + } = useRename({ projectId, initialTitle: title }); const handleCardClick = () => { if (isRenaming) return; - navigate(getTabPath(id, 'slide')); + navigate(getTabPath(projectId, 'slide')); }; const dropdownItems: DropdownItem[] = [ diff --git a/src/components/presentation/PresentationList.tsx b/src/components/presentation/PresentationList.tsx index 04bae944..23219856 100644 --- a/src/components/presentation/PresentationList.tsx +++ b/src/components/presentation/PresentationList.tsx @@ -81,7 +81,7 @@ function PresentationListSkeleton() { } function PresentationList({ - id, + projectId, title, updatedAt, durationMinutes, @@ -93,7 +93,7 @@ function PresentationList({ }: Props) { const navigate = useNavigate(); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = - usePresentationDeletion(id); + usePresentationDeletion(projectId); const { isRenaming, @@ -105,11 +105,11 @@ function PresentationList({ startRenaming, handleSubmit, cancelRenaming, - } = useRename({ projectId: id, initialTitle: title }); + } = useRename({ projectId, initialTitle: title }); const handleListClick = () => { if (isRenaming) return; - navigate(getTabPath(id, 'slide')); + navigate(getTabPath(projectId, 'slide')); }; const dropdownItems: DropdownItem[] = [ diff --git a/src/components/slide/SlideList.tsx b/src/components/slide/SlideList.tsx index b44b074f..541afcb1 100644 --- a/src/components/slide/SlideList.tsx +++ b/src/components/slide/SlideList.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useHotkey } from '@/hooks'; -import type { Slide } from '@/types/slide'; +import type { SlideListItem } from '@/types/slide'; import SlideThumbnail from './SlideThumbnail'; @@ -18,7 +18,7 @@ const SKELETON_COUNT = 3; interface SlideListProps { /** 슬라이드 목록 */ - slides?: Slide[]; + slides?: SlideListItem[]; /** 현재 선택된 슬라이드 ID */ currentSlideId?: string; /** 로딩 상태 */ @@ -35,12 +35,12 @@ export default function SlideList({ slides, currentSlideId, isLoading }: SlideLi const navigate = useNavigate(); const listRef = useRef(null); - const currentIndex = slides?.findIndex((slide) => slide.id === currentSlideId) ?? -1; + const currentIndex = slides?.findIndex((slide) => slide.slideId === currentSlideId) ?? -1; const navigateToSlide = useCallback( (index: number) => { if (!slides || index < 0 || index >= slides.length) return; - navigate({ search: `?slideId=${slides[index].id}` }, { replace: true }); + navigate({ search: `?slideId=${slides[index].slideId}` }, { replace: true }); }, [slides, navigate], ); @@ -76,10 +76,10 @@ export default function SlideList({ slides, currentSlideId, isLoading }: SlideLi )) : slides?.map((slide, idx) => ( ))}
diff --git a/src/components/slide/SlideThumbnail.tsx b/src/components/slide/SlideThumbnail.tsx index b75dbe6d..25561f6f 100644 --- a/src/components/slide/SlideThumbnail.tsx +++ b/src/components/slide/SlideThumbnail.tsx @@ -10,11 +10,11 @@ import { Link } from 'react-router-dom'; import clsx from 'clsx'; import { SlideImage } from '@/components/common'; -import type { Slide } from '@/types/slide'; +import type { SlideListItem } from '@/types/slide'; interface SlideThumbnailProps { /** 슬라이드 데이터 */ - slide?: Slide; + slide?: SlideListItem; /** 슬라이드 순서 (0-indexed) */ index: number; /** 현재 선택된 슬라이드 여부 */ @@ -46,7 +46,7 @@ export default function SlideThumbnail({ return ( - + ); diff --git a/src/components/slide/SlideWorkspace.tsx b/src/components/slide/SlideWorkspace.tsx index 4b7038b2..69b23ccc 100644 --- a/src/components/slide/SlideWorkspace.tsx +++ b/src/components/slide/SlideWorkspace.tsx @@ -11,13 +11,13 @@ import { useEffect, useState } from 'react'; import { SLIDE_MAX_WIDTH } from '@/constants/layout'; import { useSlideActions } from '@/hooks'; -import type { Slide } from '@/types/slide'; +import type { SlideListItem } from '@/types/slide'; import SlideViewer from './SlideViewer'; import { ScriptBox } from './script'; interface SlideWorkspaceProps { - slide?: Slide; + slide?: SlideListItem; isLoading?: boolean; } diff --git a/src/components/video/VideoListPage.tsx b/src/components/video/VideoListPage.tsx index 3e615c9b..32a1b370 100644 --- a/src/components/video/VideoListPage.tsx +++ b/src/components/video/VideoListPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import type { Presentation } from '@/types'; import type { FilterMode, SortMode, ViewMode } from '@/types/home'; import PresentationCard from '../presentation/PresentationCard'; @@ -17,24 +18,12 @@ interface MockVideo { size: number; } -interface Project { - id: string; - title: string; - updatedAt: string; - durationMinutes: number; - pageCount: number; - commentCount: number; - reactionCount: number; - viewCount: number; - [key: string]: unknown; -} - export default function VideoListPage() { const navigate = useNavigate(); const location = useLocation(); const [showSuccessToast, setShowSuccessToast] = useState(false); - const [projects, setProjects] = useState([]); + const [projects, setProjects] = useState([]); const [query, setQuery] = useState(''); const [sort, setSort] = useState('recent'); const [filter, setFilter] = useState('all'); @@ -63,8 +52,8 @@ export default function VideoListPage() { const mockVideos: MockVideo[] = JSON.parse(storedData); - const formattedProjects: Project[] = mockVideos.map((video) => ({ - id: String(video.id), + const formattedProjects: Presentation[] = mockVideos.map((video) => ({ + projectId: String(video.id), title: video.title, updatedAt: video.createdAt, durationMinutes: Math.ceil(video.durationSeconds / 60), @@ -154,13 +143,13 @@ export default function VideoListPage() { {viewMode === 'card' ? (
{projects.map((item) => ( - + ))}
) : (
{projects.map((item) => ( - + ))}
)} diff --git a/src/hooks/queries/usePresentations.ts b/src/hooks/queries/usePresentations.ts index a797ce18..ea070352 100644 --- a/src/hooks/queries/usePresentations.ts +++ b/src/hooks/queries/usePresentations.ts @@ -15,7 +15,7 @@ import { getPresentation, updatePresentation, } from '@/api/endpoints/presentations'; -import type { Presentation } from '@/types/presentation'; +import type { CreatePresentationRequest, Presentation } from '@/types/presentation'; import { showToast } from '@/utils/toast'; /** 프로젝트 목록 조회 */ @@ -45,7 +45,7 @@ export function useUpdatePresentation() { onSuccess: (updatePresentation) => { // Detail 캐시는 API 응답으로 직접 업데이트 (네트워크 절약) queryClient.setQueryData( - queryKeys.presentations.detail(updatePresentation.id), + queryKeys.presentations.detail(updatePresentation.projectId), updatePresentation, ); // 목록은 최신 데이터 반영을 위해 무효화 @@ -58,7 +58,7 @@ export function useUpdatePresentation() { export function useCreatePresentation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: { title: string }) => createPresentation(data), + mutationFn: (data: CreatePresentationRequest) => createPresentation(data), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.presentations.lists() }); @@ -75,7 +75,7 @@ export function useDeletePresentation() { onSuccess: (_, projectId) => { // 1) 목록 캐시에서 삭제된 프로젝트를 제거하여 즉시 UI에 반영합니다. queryClient.setQueryData(queryKeys.presentations.lists(), (oldData) => - oldData?.filter((project) => project.id !== projectId), + oldData?.filter((project) => project.projectId !== projectId), ); // 상세 정보 캐시는 API 응답으로 받은 데이터로 직접 업데이트합니다. void queryClient.removeQueries({ queryKey: queryKeys.presentations.detail(projectId) }); diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts index 057c7d42..1cd41f85 100644 --- a/src/hooks/useComments.ts +++ b/src/hooks/useComments.ts @@ -20,7 +20,7 @@ import { useCreateOpinion, useDeleteOpinion } from './queries/useOpinions'; const EMPTY_COMMENTS: Comment[] = []; export function useComments() { - const slideId = useSlideStore((state) => state.slide?.id); + const slideId = useSlideStore((state) => state.slide?.slideId); const flatComments = useSlideStore((state) => state.slide?.opinions); const addOpinionStore = useSlideStore((state) => state.addOpinion); const addReplyStore = useSlideStore((state) => state.addReply); diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts index d8718cf3..a22433e6 100644 --- a/src/hooks/useReactions.ts +++ b/src/hooks/useReactions.ts @@ -15,7 +15,7 @@ import { useToggleReaction } from './queries/useReactionQueries'; const EMPTY_REACTIONS: Reaction[] = []; export function useReactions() { - const slideId = useSlideStore((state) => state.slide?.id); + const slideId = useSlideStore((state) => state.slide?.slideId); const reactions = useSlideStore((state) => state.slide?.emojiReactions ?? EMPTY_REACTIONS); const toggleReactionStore = useSlideStore((state) => state.toggleReaction); diff --git a/src/hooks/useSlideSelectors.ts b/src/hooks/useSlideSelectors.ts index 56cc125e..f50150a9 100644 --- a/src/hooks/useSlideSelectors.ts +++ b/src/hooks/useSlideSelectors.ts @@ -16,13 +16,13 @@ const EMPTY_HISTORY: ScriptVersion[] = []; const EMPTY_EMOJIS: Reaction[] = []; /** 슬라이드 ID 구독 */ -export const useSlideId = () => useSlideStore((state) => state.slide?.id ?? ''); +export const useSlideId = () => useSlideStore((state) => state.slide?.slideId ?? ''); /** 슬라이드 제목 구독 */ export const useSlideTitle = () => useSlideStore((state) => state.slide?.title ?? ''); /** 슬라이드 썸네일 구독 */ -export const useSlideThumb = () => useSlideStore((state) => state.slide?.thumb ?? ''); +export const useSlideThumb = () => useSlideStore((state) => state.slide?.imageUrl ?? ''); /** 슬라이드 대본 구독 */ export const useSlideScript = () => useSlideStore((state) => state.slide?.script ?? ''); diff --git a/src/hooks/useUpload.ts b/src/hooks/useUpload.ts index 734604b4..26bae3a8 100644 --- a/src/hooks/useUpload.ts +++ b/src/hooks/useUpload.ts @@ -13,7 +13,7 @@ import { useState } from 'react'; import type { AxiosError } from 'axios'; -import { type ApiError, apiClient } from '@/api/client'; +import { type ApiFailureResponse, apiClient } from '@/api/client'; type UploadState = 'idle' | 'uploading' | 'done' | 'error'; @@ -48,8 +48,8 @@ export function useUpload() { setState('error'); if (typeof e === 'object' && e !== null && 'response' in e) { - const axiosError = e as AxiosError; - setError(axiosError.response?.data?.message ?? axiosError.message); + const axiosError = e as AxiosError; + setError(axiosError.response?.data?.error?.reason ?? axiosError.message); } else if (e instanceof Error) { setError(e.message); } else { diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 86a1e911..761f35cd 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -39,10 +39,16 @@ MOCK_SLIDES.forEach((slide) => { // API 응답 래퍼 헬퍼 const wrapResponse = (data: T) => ({ resultType: 'SUCCESS' as const, - reason: null, + error: null, success: data, }); +const wrapError = (errorCode: string, reason: string) => ({ + resultType: 'FAILURE' as const, + error: { errorCode, reason }, + success: null, +}); + /** * MSW 핸들러 정의 * @@ -61,7 +67,7 @@ export const handlers = [ http.get(`${BASE_URL}/presentations`, async () => { await delay(200); console.log('[MSW] GET /presentations'); - return HttpResponse.json(presentations); + return HttpResponse.json(wrapResponse(presentations)); }), /** @@ -69,11 +75,11 @@ export const handlers = [ * GET /presentations/:projectId */ http.get(`${BASE_URL}/presentations/:projectId`, async ({ params }) => { - await delay(150); + await delay(200); const { projectId } = params; console.log(`[MSW] GET /presentations/${projectId}`); - const presentation = presentations.find((p) => p.id === projectId); + const presentation = presentations.find((p) => p.projectId === projectId); if (!presentation) { return new HttpResponse(null, { @@ -95,7 +101,7 @@ export const handlers = [ console.log('[MSW] POST /presentations', data); const newPresentation: Presentation = { - id: `p${Date.now()}`, + projectId: `p${Date.now()}`, title: data.title, updatedAt: new Date().toISOString(), durationMinutes: 0, @@ -120,7 +126,7 @@ export const handlers = [ const data = (await request.json()) as { title?: string }; console.log(`[MSW] PATCH /presentations/${projectId}`, data); - const presentationIndex = presentations.findIndex((p) => p.id === projectId); + const presentationIndex = presentations.findIndex((p) => p.projectId === projectId); if (presentationIndex === -1) { return new HttpResponse(null, { @@ -147,7 +153,7 @@ export const handlers = [ const { projectId } = params; console.log(`[MSW] DELETE /presentations/${projectId}`); - const presentationIndex = presentations.findIndex((p) => p.id === projectId); + const presentationIndex = presentations.findIndex((p) => p.projectId === projectId); if (presentationIndex === -1) { return new HttpResponse(null, { @@ -156,7 +162,7 @@ export const handlers = [ }); } - presentations = presentations.filter((p) => p.id !== projectId); + presentations = presentations.filter((p) => p.projectId !== projectId); return new HttpResponse(null, { status: 204 }); }), @@ -166,16 +172,52 @@ export const handlers = [ /** * 프로젝트의 슬라이드 목록 조회 + * GET /projects/:projectId/slides + */ + http.get(`${BASE_URL}/projects/:projectId/slides`, async ({ params }) => { + await delay(200); + + const { projectId } = params; + console.log(`[MSW] GET /projects/${projectId}/slides`); + + const presentationSlides = slides + .filter((s) => s.projectId === projectId) + .map((s, index) => ({ + slideId: s.id, + projectId: s.projectId, + title: s.title, + slideNum: index + 1, + imageUrl: s.thumb, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + + return HttpResponse.json(wrapResponse(presentationSlides)); + }), + + /** + * 프로젝트의 슬라이드 목록 조회 (Legacy) * GET /presentations/:projectId/slides */ http.get(`${BASE_URL}/presentations/:projectId/slides`, async ({ params }) => { - await delay(200); // 네트워크 지연 시뮬레이션 + await delay(200); const { projectId } = params; - console.log(`[MSW] GET /presentations/${projectId}/slides`); + console.log(`[MSW] GET /presentations/${projectId}/slides (legacy)`); + + const presentationSlides = slides + .filter((s) => s.projectId === projectId) + .map((s, index) => ({ + slideId: s.id, + projectId: s.projectId, + title: s.title, + slideNum: index + 1, + imageUrl: s.thumb, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); - const presentationSlides = slides.filter((s) => s.projectId === projectId); - return HttpResponse.json(presentationSlides); + return HttpResponse.json(wrapResponse(presentationSlides)); }), /** @@ -530,14 +572,7 @@ export const handlers = [ const slide = slides.find((s) => s.id === slideId); if (!slide) { - return new HttpResponse( - JSON.stringify({ - resultType: 'FAILURE', - error: { code: 'NOT_FOUND', message: 'Slide not found' }, - success: null, - }), - { status: 404 }, - ); + return HttpResponse.json(wrapError('NOT_FOUND', 'Slide not found'), { status: 404 }); } return HttpResponse.json( @@ -561,26 +596,30 @@ export const handlers = [ await delay(200); const { slideId } = params as { slideId: string }; - const { script } = (await request.json()) as { script: string }; - console.log(`[MSW] PATCH /presentations/slides/${slideId}/script`); + const body = (await request.json()) as { script: string }; + console.log(`[MSW] PATCH /presentations/slides/${slideId}/script`, { + slideId, + scriptLength: body.script?.length, + body, + }); + + if (!body || body.script === undefined) { + console.error('[MSW] 대본 저장 요청 body가 올바르지 않습니다:', body); + return HttpResponse.json(wrapError('INVALID_REQUEST', 'script field is required'), { + status: 400, + }); + } const slideIndex = slides.findIndex((s) => s.id === slideId); if (slideIndex === -1) { - return new HttpResponse( - JSON.stringify({ - resultType: 'FAILURE', - error: { code: 'NOT_FOUND', message: 'Slide not found' }, - success: null, - }), - { status: 404 }, - ); + return HttpResponse.json(wrapError('NOT_FOUND', 'Slide not found'), { status: 404 }); } const currentSlide = slides[slideIndex]; // 기존 스크립트가 있으면 버전 저장 - if (currentSlide.script.trim() && currentSlide.script !== script) { + if (currentSlide.script.trim() && currentSlide.script !== body.script) { const versions = scriptVersions.get(slideId) || []; versions.unshift({ versionNumber: versions.length + 1, @@ -592,15 +631,15 @@ export const handlers = [ } // 스크립트 업데이트 - slides[slideIndex] = { ...currentSlide, script }; + slides[slideIndex] = { ...currentSlide, script: body.script }; return HttpResponse.json( wrapResponse({ message: '대본이 성공적으로 저장되었습니다.', slideId, - charCount: script.length, - scriptText: script, - estimatedDurationSeconds: Math.ceil(script.length / 5), + charCount: body.script.length, + scriptText: body.script, + estimatedDurationSeconds: Math.ceil(body.script.length / 5), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }), @@ -620,14 +659,7 @@ export const handlers = [ const slide = slides.find((s) => s.id === slideId); if (!slide) { - return new HttpResponse( - JSON.stringify({ - resultType: 'FAILURE', - error: { code: 'NOT_FOUND', message: 'Slide not found' }, - success: null, - }), - { status: 404 }, - ); + return HttpResponse.json(wrapError('NOT_FOUND', 'Slide not found'), { status: 404 }); } const versions = scriptVersions.get(slideId) || []; @@ -648,28 +680,14 @@ export const handlers = [ const slideIndex = slides.findIndex((s) => s.id === slideId); if (slideIndex === -1) { - return new HttpResponse( - JSON.stringify({ - resultType: 'FAILURE', - error: { code: 'NOT_FOUND', message: 'Slide not found' }, - success: null, - }), - { status: 404 }, - ); + return HttpResponse.json(wrapError('NOT_FOUND', 'Slide not found'), { status: 404 }); } const versions = scriptVersions.get(slideId) || []; const targetVersion = versions.find((v) => v.versionNumber === version); if (!targetVersion) { - return new HttpResponse( - JSON.stringify({ - resultType: 'FAILURE', - error: { code: 'NOT_FOUND', message: 'Version not found' }, - success: null, - }), - { status: 404 }, - ); + return HttpResponse.json(wrapError('NOT_FOUND', 'Version not found'), { status: 404 }); } // 현재 스크립트를 버전으로 저장 diff --git a/src/mocks/projects.ts b/src/mocks/projects.ts index 04b3ea2b..5437af43 100644 --- a/src/mocks/projects.ts +++ b/src/mocks/projects.ts @@ -4,7 +4,7 @@ import { daysAgo } from './utils'; export const MOCK_PROJECTS: Presentation[] = [ { - id: 'p1', + projectId: 'p1', title: '네이버 지도 리브랜딩, 새로운 여정의 시작', updatedAt: daysAgo(1), durationMinutes: 2, @@ -15,7 +15,7 @@ export const MOCK_PROJECTS: Presentation[] = [ thumbnailUrl: '/thumbnails/p1/0.webp', }, { - id: 'p2', + projectId: 'p2', title: '당근페이 송금의 플랫폼화: 중고거래 채팅 벗어나기', updatedAt: daysAgo(2), durationMinutes: 3, @@ -26,7 +26,7 @@ export const MOCK_PROJECTS: Presentation[] = [ thumbnailUrl: '/thumbnails/p2/0.webp', }, { - id: 'p3', + projectId: 'p3', title: '강남언니 회사소개서', updatedAt: daysAgo(6), durationMinutes: 5, @@ -37,7 +37,7 @@ export const MOCK_PROJECTS: Presentation[] = [ thumbnailUrl: '/thumbnails/p3/0.webp', }, { - id: 'p4', + projectId: 'p4', title: '모빌리티 혁신 플랫폼, 소카', updatedAt: daysAgo(8), pageCount: 28, diff --git a/src/pages/FeedbackSlidePage.tsx b/src/pages/FeedbackSlidePage.tsx index 5cde0e0a..5868ac83 100644 --- a/src/pages/FeedbackSlidePage.tsx +++ b/src/pages/FeedbackSlidePage.tsx @@ -57,10 +57,10 @@ export default function FeedbackSlidePage() { const slideLabel = `Slide ${index + 1}`; return (slide.opinions || []).map((op) => ({ ...op, - id: `${slide.id}-${op.id}`, - parentId: op.parentId ? `${slide.id}-${op.parentId}` : undefined, + id: `${slide.slideId}-${op.id}`, + parentId: op.parentId ? `${slide.slideId}-${op.parentId}` : undefined, serverId: op.id, - slideId: slide.id, + slideId: slide.slideId, slideRef: slideLabel, ref: { kind: 'slide' as const, index }, })); @@ -144,7 +144,7 @@ export default function FeedbackSlidePage() { mediaSlot={ currentSlide ? ( {currentSlide.title} diff --git a/src/pages/InsightPage.tsx b/src/pages/InsightPage.tsx index 46e59618..180447e2 100644 --- a/src/pages/InsightPage.tsx +++ b/src/pages/InsightPage.tsx @@ -146,7 +146,7 @@ export default function InsightPage() { .slice(0, 3); }, [slides]); - const getThumb = (slideIndex: number) => slides?.[slideIndex]?.thumb; + const getThumb = (slideIndex: number) => slides?.[slideIndex]?.imageUrl; return (
s.id === slideIdParam) ?? slides?.[0]; + const currentSlide = slides?.find((s) => s.slideId === slideIdParam) ?? slides?.[0]; /** * 슬라이드 로드 에러 처리 @@ -31,10 +31,10 @@ export default function SlidePage() { if (!isLoading && slides && slides.length > 0) { if (!slideIdParam) { // slideId가 아예 없으면 첫 번째 슬라이드로 - setSearchParams({ slideId: slides[0].id }, { replace: true }); - } else if (!slides.find((s) => s.id === slideIdParam)) { + setSearchParams({ slideId: slides[0].slideId }, { replace: true }); + } else if (!slides.find((s) => s.slideId === slideIdParam)) { // slideId가 있지만 목록에 없으면 첫 번째 슬라이드로 - setSearchParams({ slideId: slides[0].id }, { replace: true }); + setSearchParams({ slideId: slides[0].slideId }, { replace: true }); } } }, [isLoading, slides, slideIdParam, setSearchParams]); @@ -44,15 +44,15 @@ export default function SlidePage() { * 마지막으로 보던 슬라이드로 복원하기 위함 */ useEffect(() => { - if (projectId && currentSlide?.id) { - setLastSlideId(projectId, currentSlide.id); + if (projectId && currentSlide?.slideId) { + setLastSlideId(projectId, currentSlide.slideId); } - }, [projectId, currentSlide?.id]); + }, [projectId, currentSlide?.slideId]); return (
- +
diff --git a/src/stores/slideStore.ts b/src/stores/slideStore.ts index 5651d721..def17004 100644 --- a/src/stores/slideStore.ts +++ b/src/stores/slideStore.ts @@ -12,14 +12,14 @@ import { devtools } from 'zustand/middleware'; import { MOCK_CURRENT_USER } from '@/mocks/users'; import type { Comment } from '@/types/comment'; import type { ReactionType } from '@/types/script'; -import type { Slide } from '@/types/slide'; +import type { SlideListItem } from '@/types/slide'; import { addReplyToFlat, createComment, deleteFromFlat } from '@/utils/comment'; interface SlideState { - slide: Slide | null; + slide: SlideListItem | null; - initSlide: (slide: Slide) => void; - updateSlide: (updates: Partial) => void; + initSlide: (slide: SlideListItem) => void; + updateSlide: (updates: Partial) => void; updateScript: (script: string) => void; deleteOpinion: (id: string) => void; addReply: (parentId: string, content: string) => void; @@ -63,7 +63,7 @@ export const useSlideStore = create()( slide: state.slide ? { ...state.slide, - opinions: deleteFromFlat(state.slide.opinions, id), + opinions: deleteFromFlat(state.slide.opinions ?? [], id), } : null, }), @@ -80,7 +80,7 @@ export const useSlideStore = create()( return { slide: { ...state.slide, - opinions: addReplyToFlat(state.slide.opinions, parentId, { + opinions: addReplyToFlat(state.slide.opinions ?? [], parentId, { content, authorId: MOCK_CURRENT_USER.id, }), @@ -134,7 +134,7 @@ export const useSlideStore = create()( slide: state.slide ? { ...state.slide, - opinions: [newComment, ...state.slide.opinions], + opinions: [newComment, ...(state.slide.opinions ?? [])], } : null, }), diff --git a/src/types/api.ts b/src/types/api.ts index 780d4aa6..e8bcbffa 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -2,22 +2,30 @@ * @file api.ts * @description API 응답 공통 타입 */ + /** * API 에러 정보 */ -export interface ApiError { - code: string; - message: string; +export interface ApiError { + errorCode: string; + reason: string; + data?: TErrorData; } /** - * API 응답 래퍼 + * API 응답 래퍼 (Discriminated Union) */ -export interface ApiResponse { - resultType: 'SUCCESS' | 'FAILURE'; - reason: ApiError | null; - success: T; -} +export type ApiResponse = + | { + resultType: 'SUCCESS'; + error: null; + success: TSuccess; + } + | { + resultType: 'FAILURE'; + error: ApiError; + success: null; + }; /** * 페이지네이션 응답 @@ -52,3 +60,28 @@ export interface ScriptVersion { charCount: number; createdAt: string; } + +/** + * 변환 상태 + */ +export type ConversionStatus = 'processing' | 'completed' | 'failed'; + +/** + * 변환 진행 상황 + */ +export interface ConversionProgress { + slides: { + total: number; + generated: number; + }; + thumbnail: ConversionStatus; + metadata: ConversionStatus; +} + +/** + * 변환 상태 응답 + */ +export interface ConversionStatusResponse { + status: ConversionStatus; + progress: ConversionProgress; +} diff --git a/src/types/presentation.ts b/src/types/presentation.ts index 23f3656b..0c2553e8 100644 --- a/src/types/presentation.ts +++ b/src/types/presentation.ts @@ -18,7 +18,7 @@ export interface CreatePresentationSuccess { } export interface Presentation { - id: string; + projectId: string; title: string; updatedAt: string; durationMinutes: number; @@ -28,3 +28,12 @@ export interface Presentation { viewCount: number; thumbnailUrl?: string; } + +/** + * API 응답 타입: 프로젝트 수정 응답 + */ +export interface ProjectUpdateResponse { + projectId: string; + title: string; + updatedAt: string; +} diff --git a/src/types/slide.ts b/src/types/slide.ts index 8e5d3d45..592e96e7 100644 --- a/src/types/slide.ts +++ b/src/types/slide.ts @@ -3,10 +3,56 @@ import type { Comment } from './comment'; import type { Reaction } from './script'; /** - * 슬라이드 데이터 모델 - * - * 프레젠테이션의 개별 슬라이드를 나타냅니다. - * 각 슬라이드는 대본, 의견, 수정 기록, 이모지 반응을 포함합니다. + * API 응답 타입: 슬라이드 목록 조회 + */ +export interface SlideListItem { + slideId: string; + projectId: string; + title: string; + slideNum: number; + imageUrl: string; + createdAt: string; + updatedAt: string; + /** 프론트엔드 확장용 - 대본 */ + script?: string; + /** 프론트엔드 확장용 - 의견 목록 */ + opinions?: Comment[]; + /** 프론트엔드 확장용 - 수정 기록 */ + history?: ScriptVersion[]; + /** 프론트엔드 확장용 - 이모지 반응 */ + emojiReactions?: Reaction[]; + /** 영상 피드백에서 슬라이드 시작 시간 (초) */ + startTime?: number; +} + +/** + * API 응답 타입: 슬라이드 상세 조회 + */ +export interface SlideDetail { + slideId: string; + projectId: string; + title: string; + slideNum: number; + imageUrl: string; + prevSlideId: string | null; + nextSlideId: string | null; + updatedAt: string; +} + +/** + * API 응답 타입: 슬라이드 수정 응답 + */ +export interface SlideUpdateResponse { + slideId: string; + title: string; + slideNum: number; + imageUrl: string; + updatedAt: string; +} + +/** + * 슬라이드 데이터 모델 (프론트엔드 스토어용) + * @deprecated SlideListItem 사용 권장 */ export interface Slide { id: string; @@ -17,6 +63,5 @@ export interface Slide { opinions: Comment[]; history: ScriptVersion[]; emojiReactions: Reaction[]; - /** 영상 피드백에서 슬라이드 시작 시간 (초) */ startTime?: number; } From e0566ff7a7be771c7a766865fed81e7111fdcd34 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 03:25:58 +0900 Subject: [PATCH 02/22] =?UTF-8?q?fix:=20API=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/endpoints/presentations.ts | 2 +- src/api/endpoints/slides.ts | 10 ++++++---- src/mocks/handlers.ts | 18 +++++++++--------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/api/endpoints/presentations.ts b/src/api/endpoints/presentations.ts index 658d6226..a73fe36d 100644 --- a/src/api/endpoints/presentations.ts +++ b/src/api/endpoints/presentations.ts @@ -18,7 +18,7 @@ import type { /** * 프로젝트 목록 조회 * - * 각 프로젝트는 id를 포함하며, 수정/삭제 시 이 id를 사용함. + * 각 프로젝트는 projectId를 포함하며, 수정/삭제 시 이 projectId를 사용함. * @returns 프로젝트 배열 */ export async function getPresentations(): Promise { diff --git a/src/api/endpoints/slides.ts b/src/api/endpoints/slides.ts index e4a1d7c3..ff159135 100644 --- a/src/api/endpoints/slides.ts +++ b/src/api/endpoints/slides.ts @@ -21,7 +21,7 @@ import type { SlideDetail, SlideListItem, SlideUpdateResponse } from '@/types/sl */ export async function getSlides(projectId: string): Promise { const response = await apiClient.get>( - `/projects/${projectId}/slides`, + `/presentations/${projectId}/slides`, ); if (response.data.resultType === 'SUCCESS') { @@ -37,7 +37,9 @@ export async function getSlides(projectId: string): Promise { * @returns 슬라이드 정보 */ export async function getSlide(slideId: string): Promise { - const response = await apiClient.get>(`/slides/${slideId}`); + const response = await apiClient.get>( + `/presentations/slides/${slideId}`, + ); if (response.data.resultType === 'SUCCESS') { return response.data.success; @@ -63,7 +65,7 @@ export async function updateSlide( data: UpdateSlideDto, ): Promise { const response = await apiClient.patch>( - `/slides/${slideId}`, + `/presentations/slides/${slideId}`, data, ); @@ -101,7 +103,7 @@ export async function createSlide( * @param slideId - 삭제할 슬라이드 ID */ export async function deleteSlide(slideId: string): Promise { - const response = await apiClient.delete>(`/slides/${slideId}`); + const response = await apiClient.delete>(`/presentations/slides/${slideId}`); if (response.data.resultType === 'FAILURE') { throw new Error(response.data.error.reason); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 761f35cd..975d2767 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -222,13 +222,13 @@ export const handlers = [ /** * 특정 슬라이드 조회 - * GET /slides/:slideId + * GET /presentations/slides/:slideId */ - http.get(`${BASE_URL}/slides/:slideId`, async ({ params }) => { + http.get(`${BASE_URL}/presentations/slides/:slideId`, async ({ params }) => { await delay(150); const { slideId } = params; - console.log(`[MSW] GET /slides/${slideId}`); + console.log(`[MSW] GET /presentations/slides/${slideId}`); const slide = slides.find((s) => s.id === slideId); @@ -244,14 +244,14 @@ export const handlers = [ /** * 슬라이드 수정 - * PATCH /slides/:slideId + * PATCH /presentations/slides/:slideId */ - http.patch(`${BASE_URL}/slides/:slideId`, async ({ params, request }) => { + http.patch(`${BASE_URL}/presentations/slides/:slideId`, async ({ params, request }) => { await delay(200); const { slideId } = params; const updates = (await request.json()) as Partial; - console.log(`[MSW] PATCH /slides/${slideId}`, updates); + console.log(`[MSW] PATCH /presentations/slides/${slideId}`, updates); const slideIndex = slides.findIndex((s) => s.id === slideId); @@ -316,13 +316,13 @@ export const handlers = [ /** * 슬라이드 삭제 - * DELETE /slides/:slideId + * DELETE /presentations/slides/:slideId */ - http.delete(`${BASE_URL}/slides/:slideId`, async ({ params }) => { + http.delete(`${BASE_URL}/presentations/slides/:slideId`, async ({ params }) => { await delay(200); const { slideId } = params; - console.log(`[MSW] DELETE /slides/${slideId}`); + console.log(`[MSW] DELETE /presentations/slides/${slideId}`); const slideIndex = slides.findIndex((s) => s.id === slideId); From e7fd61a1e43b31bfa0b7ee9d77efe1d0dd572d6b Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 03:41:01 +0900 Subject: [PATCH 03/22] =?UTF-8?q?fix:=20Presentation=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/endpoints/presentations.ts | 10 ++--- .../presentation/PresentationCard.tsx | 26 ++++------- .../presentation/PresentationList.tsx | 24 +++------- src/components/video/VideoListPage.tsx | 10 ++--- src/hooks/usePresentationList.ts | 2 +- src/mocks/handlers.ts | 34 ++++++++++---- src/mocks/projects.ts | 44 +++++++++---------- src/pages/HomePage.tsx | 4 +- src/types/presentation.ts | 22 +++++++--- 9 files changed, 90 insertions(+), 86 deletions(-) diff --git a/src/api/endpoints/presentations.ts b/src/api/endpoints/presentations.ts index a73fe36d..a86de73a 100644 --- a/src/api/endpoints/presentations.ts +++ b/src/api/endpoints/presentations.ts @@ -12,20 +12,20 @@ import type { CreatePresentationRequest, CreatePresentationSuccess, Presentation, + PresentationListResponse, ProjectUpdateResponse, } from '@/types/presentation'; /** - * 프로젝트 목록 조회 + * 프로젝트 목록 조회 (GET) * - * 각 프로젝트는 projectId를 포함하며, 수정/삭제 시 이 projectId를 사용함. - * @returns 프로젝트 배열 + * @returns Presentation[] */ export async function getPresentations(): Promise { - const response = await apiClient.get>(`/presentations`); + const response = await apiClient.get>(`/presentations`); if (response.data.resultType === 'SUCCESS') { - return response.data.success; + return response.data.success.presentations; } throw new Error(response.data.error.reason); } diff --git a/src/components/presentation/PresentationCard.tsx b/src/components/presentation/PresentationCard.tsx index 78e85f05..e2266644 100644 --- a/src/components/presentation/PresentationCard.tsx +++ b/src/components/presentation/PresentationCard.tsx @@ -87,11 +87,9 @@ function PresentationCard({ title, highlightQuery = '', updatedAt, - durationMinutes, - pageCount, - commentCount, - reactionCount, - viewCount = 0, + durationSeconds, + slideCount, + feedbackCount, thumbnailUrl, }: Props) { const navigate = useNavigate(); @@ -221,31 +219,23 @@ function PresentationCard({
- {/* 왼쪽: 소요 시간, 페이지 수 */} + {/* 왼쪽: 소요 시간, 슬라이드 수 */}
- {durationMinutes} + {Math.ceil(durationSeconds / 60)}분
- {pageCount} 페이지 + {slideCount} 슬라이드
- {/* 오른쪽: 반응 모음 */} + {/* 오른쪽: 피드백 수 */}
- {commentCount} -
-
- - {reactionCount} -
-
- - {viewCount} + {feedbackCount}
diff --git a/src/components/presentation/PresentationList.tsx b/src/components/presentation/PresentationList.tsx index 23219856..2d1e4f32 100644 --- a/src/components/presentation/PresentationList.tsx +++ b/src/components/presentation/PresentationList.tsx @@ -84,11 +84,9 @@ function PresentationList({ projectId, title, updatedAt, - durationMinutes, - pageCount, - commentCount, - reactionCount, - viewCount = 0, + durationSeconds, + slideCount, + feedbackCount, thumbnailUrl, }: Props) { const navigate = useNavigate(); @@ -198,30 +196,22 @@ function PresentationList({ {formatRelativeTime(updatedAt)} - {durationMinutes} + {Math.ceil(durationSeconds / 60)}분
{/* 구분선 */} - {/* 페이지 수 & 반응 모음 */} + {/* 슬라이드 수 & 피드백 수 */}
- {pageCount} 페이지 + {slideCount} 슬라이드 - {commentCount} - - - - {reactionCount} - - - - {viewCount} + {feedbackCount}
diff --git a/src/components/video/VideoListPage.tsx b/src/components/video/VideoListPage.tsx index 32a1b370..cfd7c763 100644 --- a/src/components/video/VideoListPage.tsx +++ b/src/components/video/VideoListPage.tsx @@ -55,12 +55,12 @@ export default function VideoListPage() { const formattedProjects: Presentation[] = mockVideos.map((video) => ({ projectId: String(video.id), title: video.title, + thumbnailUrl: undefined, + slideCount: video.slideCount, + feedbackCount: 0, + durationSeconds: video.durationSeconds, + createdAt: video.createdAt, updatedAt: video.createdAt, - durationMinutes: Math.ceil(video.durationSeconds / 60), - pageCount: video.slideCount, - commentCount: 0, - reactionCount: 0, - viewCount: 0, })); setProjects(formattedProjects); diff --git a/src/hooks/usePresentationList.ts b/src/hooks/usePresentationList.ts index a1ebcd7e..ed8f7639 100644 --- a/src/hooks/usePresentationList.ts +++ b/src/hooks/usePresentationList.ts @@ -44,7 +44,7 @@ export function usePresentationList(presentations: Presentation[], options?: Opt const next = [...result]; if (sort === 'commentCount') { - return next.sort((a, b) => b.commentCount - a.commentCount); + return next.sort((a, b) => b.feedbackCount - a.feedbackCount); } if (sort === 'name') { return next.sort((a, b) => a.title.localeCompare(b.title)); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 975d2767..b4cc78db 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -64,10 +64,29 @@ export const handlers = [ * 프로젝트 목록 조회 * GET /presentations */ - http.get(`${BASE_URL}/presentations`, async () => { + http.get(`${BASE_URL}/presentations`, async ({ request }) => { await delay(200); console.log('[MSW] GET /presentations'); - return HttpResponse.json(wrapResponse(presentations)); + + const url = new URL(request.url); + const page = parseInt(url.searchParams.get('page') || '1', 10); + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + + const total = presentations.length; + const totalPages = Math.ceil(total / limit); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedPresentations = presentations.slice(startIndex, endIndex); + + return HttpResponse.json( + wrapResponse({ + presentations: paginatedPresentations, + total, + page, + limit, + totalPages, + }), + ); }), /** @@ -103,13 +122,12 @@ export const handlers = [ const newPresentation: Presentation = { projectId: `p${Date.now()}`, title: data.title, - updatedAt: new Date().toISOString(), - durationMinutes: 0, - pageCount: 0, - commentCount: 0, - reactionCount: 0, - viewCount: 0, thumbnailUrl: '/thumbnails/p1/0.webp', + slideCount: 0, + feedbackCount: 0, + durationSeconds: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; presentations = [newPresentation, ...presentations]; diff --git a/src/mocks/projects.ts b/src/mocks/projects.ts index 5437af43..bec7e3c0 100644 --- a/src/mocks/projects.ts +++ b/src/mocks/projects.ts @@ -6,45 +6,41 @@ export const MOCK_PROJECTS: Presentation[] = [ { projectId: 'p1', title: '네이버 지도 리브랜딩, 새로운 여정의 시작', - updatedAt: daysAgo(1), - durationMinutes: 2, - pageCount: 70, - commentCount: 19, - reactionCount: 325, - viewCount: 18, thumbnailUrl: '/thumbnails/p1/0.webp', + slideCount: 70, + feedbackCount: 19, + durationSeconds: 120, // 2분 + createdAt: daysAgo(2), + updatedAt: daysAgo(1), }, { projectId: 'p2', title: '당근페이 송금의 플랫폼화: 중고거래 채팅 벗어나기', - updatedAt: daysAgo(2), - durationMinutes: 3, - pageCount: 59, - commentCount: 0, - reactionCount: 0, - viewCount: 23, thumbnailUrl: '/thumbnails/p2/0.webp', + slideCount: 59, + feedbackCount: 0, + durationSeconds: 180, // 3분 + createdAt: daysAgo(3), + updatedAt: daysAgo(2), }, { projectId: 'p3', title: '강남언니 회사소개서', - updatedAt: daysAgo(6), - durationMinutes: 5, - pageCount: 17, - commentCount: 2, - reactionCount: 1, - viewCount: 36, thumbnailUrl: '/thumbnails/p3/0.webp', + slideCount: 17, + feedbackCount: 2, + durationSeconds: 300, // 5분 + createdAt: daysAgo(7), + updatedAt: daysAgo(6), }, { projectId: 'p4', title: '모빌리티 혁신 플랫폼, 소카', - updatedAt: daysAgo(8), - pageCount: 28, - durationMinutes: 6, - commentCount: 4, - reactionCount: 3, - viewCount: 61, thumbnailUrl: '/thumbnails/p4/0.webp', + slideCount: 28, + feedbackCount: 4, + durationSeconds: 360, // 6분 + createdAt: daysAgo(9), + updatedAt: daysAgo(8), }, ]; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 26a360f7..ee838575 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -30,9 +30,9 @@ export default function HomePage() { return (p: Presentation) => { switch (filter) { case '3m': - return p.durationMinutes <= 3; + return p.durationSeconds <= 180; // 3분 = 180초 case '5m': - return p.durationMinutes <= 5; + return p.durationSeconds <= 300; // 5분 = 300초 default: return true; diff --git a/src/types/presentation.ts b/src/types/presentation.ts index 0c2553e8..f07b7b77 100644 --- a/src/types/presentation.ts +++ b/src/types/presentation.ts @@ -20,13 +20,23 @@ export interface CreatePresentationSuccess { export interface Presentation { projectId: string; title: string; - updatedAt: string; - durationMinutes: number; - pageCount: number; - commentCount: number; - reactionCount: number; - viewCount: number; thumbnailUrl?: string; + slideCount: number; + feedbackCount: number; + durationSeconds: number; + createdAt: string; + updatedAt: string; +} + +/** + * API 응답 타입: 프로젝트 목록 조회 (페이지네이션) + */ +export interface PresentationListResponse { + presentations: Presentation[]; + total: number; + page: number; + limit: number; + totalPages: number; } /** From b5ec59b25b7047495dfebc26b4da5b930b316992 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 03:53:11 +0900 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20/presentations=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/endpoints/presentations.ts | 29 +++++++++++---------------- src/hooks/queries/usePresentations.ts | 4 ++-- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/api/endpoints/presentations.ts b/src/api/endpoints/presentations.ts index a86de73a..864199a0 100644 --- a/src/api/endpoints/presentations.ts +++ b/src/api/endpoints/presentations.ts @@ -33,7 +33,8 @@ export async function getPresentations(): Promise { /** * 프로젝트 상세 조회 * - * @param projectId - 프로젝트 ID + * @param projectId + * @returns Presentation */ export async function getPresentation(projectId: string): Promise { const response = await apiClient.get>(`/presentations/${projectId}`); @@ -45,17 +46,11 @@ export async function getPresentation(projectId: string): Promise } /** - * 프로젝트 수정 요청 타입 (하위 호환성) - * @deprecated UpdateProjectDto 사용 권장 - */ -export type UpdatePresentationRequest = UpdateProjectDto; - -/** - * 프로젝트 수정 + * 프로젝트 제목 수정 (PATCH) * - * @param projectId - 수정할 프로젝트 ID - * @param data - 수정할 데이터 - * @returns 수정된 프로젝트 + * @param projectId + * @param data - 수정할 프로젝트 데이터 + * @returns ProjectUpdateResponse - 수정된 프로젝트 정보 */ export async function updatePresentation( projectId: string, @@ -73,10 +68,10 @@ export async function updatePresentation( } /** - * 프로젝트 생성 + * 프로젝트 생성 (POST) * * @param data - 생성할 프로젝트 데이터 - * @returns 생성된 프로젝트 + * @returns CreatePresentationSuccess - 생성된 프로젝트 정보 */ export async function createPresentation( data: CreatePresentationRequest, @@ -93,9 +88,9 @@ export async function createPresentation( } /** - * 프로젝트 삭제 + * 프로젝트 삭제 (DELETE) * - * @param projectId - 삭제할 프로젝트 ID + * @param projectId */ export async function deletePresentation(projectId: string): Promise { const response = await apiClient.delete>(`/presentations/${projectId}`); @@ -106,10 +101,10 @@ export async function deletePresentation(projectId: string): Promise { } /** - * 프로젝트 파일 변환 상태 조회 + * 프로젝트 파일 변환 상태 조회 (GET) * * @param projectId - 프로젝트 ID - * @returns 변환 상태 정보 + * @returns ConversionStatusResponse - 변환 상태 정보 */ export async function getConversionStatus(projectId: string): Promise { const response = await apiClient.get>( diff --git a/src/hooks/queries/usePresentations.ts b/src/hooks/queries/usePresentations.ts index ea070352..13752a13 100644 --- a/src/hooks/queries/usePresentations.ts +++ b/src/hooks/queries/usePresentations.ts @@ -7,9 +7,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/api'; +import type { UpdateProjectDto } from '@/api/dto'; import { getPresentations } from '@/api/endpoints/presentations'; import { - type UpdatePresentationRequest, createPresentation, deletePresentation, getPresentation, @@ -39,7 +39,7 @@ export function usePresentation(projectId: string) { export function useUpdatePresentation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, data }: { projectId: string; data: UpdatePresentationRequest }) => + mutationFn: ({ projectId, data }: { projectId: string; data: UpdateProjectDto }) => updatePresentation(projectId, data), onSuccess: (updatePresentation) => { From ff9abe7893f87b61c5311b2ed07b1d01b9cab491 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 05:44:01 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20/comments=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/endpoints/comments.ts | 123 ++++++++++++++++ src/mocks/handlers.ts | 268 ++++++++++++++++++++++++++++++++++ src/types/comment.ts | 43 ++++++ 3 files changed, 434 insertions(+) create mode 100644 src/api/endpoints/comments.ts diff --git a/src/api/endpoints/comments.ts b/src/api/endpoints/comments.ts new file mode 100644 index 00000000..ac1e2bce --- /dev/null +++ b/src/api/endpoints/comments.ts @@ -0,0 +1,123 @@ +/** + * @file comments.ts + * @description 댓글 관련 API 엔드포인트 + */ +import { apiClient } from '@/api/client'; +import type { ApiResponse } from '@/types/api'; +import type { CommentListResponse, CommentResponse, ReplyListResponse } from '@/types/comment'; + +/** + * 슬라이드 댓글 목록 조회 + * + * @param slideId - 슬라이드 ID + * @param page - 페이지 번호 (기본값: 1) + * @param limit - 페이지당 개수 (기본값: 20) + * @returns 댓글 목록 및 페이지네이션 정보 + */ +export async function getSlideComments( + slideId: string, + page = 1, + limit = 20, +): Promise { + const response = await apiClient.get>( + `/slides/${slideId}/comments`, + { + params: { page, limit }, + }, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} + +/** + * 슬라이드에 댓글 작성 + * + * @param slideId - 슬라이드 ID + * @param data - 댓글 내용 + * @returns 생성된 댓글 정보 + */ +export async function createSlideComment( + slideId: string, + data: { content: string }, +): Promise { + const response = await apiClient.post>( + `/slides/${slideId}/comments`, + data, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} + +/** + * 댓글에 답글 작성 + * + * @param commentId - 부모 댓글 ID + * @param data - 답글 내용 + * @returns 생성된 답글 정보 + */ +export async function createReply( + commentId: string, + data: { content: string }, +): Promise { + const response = await apiClient.post>( + `/comments/${commentId}/replies`, + data, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} + +/** + * 댓글의 답글 목록 조회 + * + * @param commentId - 댓글 ID + * @returns 답글 목록 + */ +export async function getReplies(commentId: string): Promise { + const response = await apiClient.get(`/comments/${commentId}/replies`); + return response.data; +} + +/** + * 댓글 수정 + * + * @param commentId - 댓글 ID + * @param data - 수정할 내용 + * @returns 수정된 댓글 정보 + */ +export async function updateComment( + commentId: string, + data: { content: string }, +): Promise { + const response = await apiClient.patch>( + `/comments/${commentId}`, + data, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} + +/** + * 댓글 삭제 + * + * @param commentId - 댓글 ID + */ +export async function deleteComment(commentId: string): Promise { + const response = await apiClient.delete>(`/comments/${commentId}`); + + if (response.data.resultType === 'FAILURE') { + throw new Error(response.data.error.reason); + } +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index b4cc78db..b0b72111 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -29,6 +29,20 @@ const scriptVersions: Map< { versionNumber: number; scriptText: string; charCount: number; createdAt: string }[] > = new Map(); +// 댓글 저장소 (슬라이드별) +interface StoredComment { + id: string; + content: string; + parentId?: string; + userId: string; + slideId?: string; + createdAt: string; + updatedAt: string; +} + +const slideComments: Map = new Map(); +const commentReplies: Map = new Map(); + // slides의 history 데이터로 scriptVersions 초기화 MOCK_SLIDES.forEach((slide) => { if (slide.history.length > 0) { @@ -841,4 +855,258 @@ export const handlers = [ }, }); }), + + /** + * 슬라이드 댓글 목록 조회 + * GET /slides/:slideId/comments + */ + http.get(`${BASE_URL}/slides/:slideId/comments`, async ({ params, request }) => { + await delay(150); + const { slideId } = params as { slideId: string }; + const url = new URL(request.url); + const page = parseInt(url.searchParams.get('page') || '1', 10); + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + + console.log(`[MSW] GET /slides/${slideId}/comments`, { page, limit }); + + const comments = slideComments.get(slideId) || []; + const total = comments.length; + const totalPages = Math.ceil(total / limit); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedComments = comments.slice(startIndex, endIndex); + + const commentsWithUser = paginatedComments.map((comment) => ({ + id: comment.id, + content: comment.content, + user: { + id: comment.userId, + nickName: MOCK_USERS.find((u) => u.id === comment.userId)?.name || '익명', + }, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + })); + + return HttpResponse.json( + wrapResponse({ + comments: commentsWithUser, + pagination: { + page, + limit, + total, + totalPages, + }, + }), + ); + }), + + /** + * 슬라이드에 댓글 작성 + * POST /slides/:slideId/comments + */ + http.post(`${BASE_URL}/slides/:slideId/comments`, async ({ params, request }) => { + await delay(200); + const { slideId } = params as { slideId: string }; + const { content } = (await request.json()) as { content: string }; + + console.log(`[MSW] POST /slides/${slideId}/comments`, { content }); + + const newComment: StoredComment = { + id: `${Date.now()}`, + content, + userId: MOCK_USERS[0].id, + slideId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const comments = slideComments.get(slideId) || []; + comments.push(newComment); + slideComments.set(slideId, comments); + + return HttpResponse.json( + wrapResponse({ + id: newComment.id, + content: newComment.content, + userId: newComment.userId, + createdAt: newComment.createdAt, + }), + ); + }), + + /** + * 댓글에 답글 작성 + * POST /comments/:commentId/replies + */ + http.post(`${BASE_URL}/comments/:commentId/replies`, async ({ params, request }) => { + await delay(200); + const { commentId } = params as { commentId: string }; + const { content } = (await request.json()) as { content: string }; + + console.log(`[MSW] POST /comments/${commentId}/replies`, { content }); + + // 부모 댓글 존재 확인 + let parentExists = false; + for (const comments of slideComments.values()) { + if (comments.some((c) => c.id === commentId)) { + parentExists = true; + break; + } + } + + if (!parentExists) { + return HttpResponse.json(wrapError('C005', '댓글을 찾을 수 없습니다.'), { status: 404 }); + } + + const newReply: StoredComment = { + id: `${Date.now()}`, + content, + parentId: commentId, + userId: MOCK_USERS[0].id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const replies = commentReplies.get(commentId) || []; + replies.push(newReply); + commentReplies.set(commentId, replies); + + return HttpResponse.json({ + id: newReply.id, + content: newReply.content, + parentId: newReply.parentId, + userId: newReply.userId, + createdAt: newReply.createdAt, + }); + }), + + /** + * 댓글의 답글 목록 조회 + * GET /comments/:commentId/replies + */ + http.get(`${BASE_URL}/comments/:commentId/replies`, async ({ params }) => { + await delay(150); + const { commentId } = params as { commentId: string }; + + console.log(`[MSW] GET /comments/${commentId}/replies`); + + const replies = commentReplies.get(commentId) || []; + + return HttpResponse.json( + replies.map((reply) => ({ + id: reply.id, + content: reply.content, + parentId: reply.parentId, + userId: reply.userId, + createdAt: reply.createdAt, + })), + ); + }), + + /** + * 댓글 수정 + * PATCH /comments/:commentId + */ + http.patch(`${BASE_URL}/comments/:commentId`, async ({ params, request }) => { + await delay(200); + const { commentId } = params as { commentId: string }; + const { content } = (await request.json()) as { content: string }; + + console.log(`[MSW] PATCH /comments/${commentId}`, { content }); + + // 슬라이드 댓글에서 찾기 + let found = false; + for (const [slideId, comments] of slideComments.entries()) { + const commentIndex = comments.findIndex((c) => c.id === commentId); + if (commentIndex !== -1) { + comments[commentIndex] = { + ...comments[commentIndex], + content, + updatedAt: new Date().toISOString(), + }; + slideComments.set(slideId, comments); + found = true; + + return HttpResponse.json( + wrapResponse({ + id: comments[commentIndex].id, + content: comments[commentIndex].content, + userId: comments[commentIndex].userId, + createdAt: comments[commentIndex].createdAt, + }), + ); + } + } + + // 답글에서 찾기 + if (!found) { + for (const [parentId, replies] of commentReplies.entries()) { + const replyIndex = replies.findIndex((r) => r.id === commentId); + if (replyIndex !== -1) { + replies[replyIndex] = { + ...replies[replyIndex], + content, + updatedAt: new Date().toISOString(), + }; + commentReplies.set(parentId, replies); + found = true; + + return HttpResponse.json( + wrapResponse({ + id: replies[replyIndex].id, + content: replies[replyIndex].content, + userId: replies[replyIndex].userId, + createdAt: replies[replyIndex].createdAt, + }), + ); + } + } + } + + if (!found) { + return HttpResponse.json(wrapError('C005', '댓글을 찾을 수 없습니다.'), { status: 404 }); + } + }), + + /** + * 댓글 삭제 + * DELETE /comments/:commentId + */ + http.delete(`${BASE_URL}/comments/:commentId`, async ({ params }) => { + await delay(200); + const { commentId } = params as { commentId: string }; + + console.log(`[MSW] DELETE /comments/${commentId}`); + + // 슬라이드 댓글에서 삭제 + let found = false; + for (const [slideId, comments] of slideComments.entries()) { + const filteredComments = comments.filter((c) => c.id !== commentId); + if (filteredComments.length !== comments.length) { + slideComments.set(slideId, filteredComments); + // 해당 댓글의 답글도 모두 삭제 + commentReplies.delete(commentId); + found = true; + break; + } + } + + // 답글에서 삭제 + if (!found) { + for (const [parentId, replies] of commentReplies.entries()) { + const filteredReplies = replies.filter((r) => r.id !== commentId); + if (filteredReplies.length !== replies.length) { + commentReplies.set(parentId, filteredReplies); + found = true; + break; + } + } + } + + if (!found) { + return HttpResponse.json(wrapError('C005', '댓글을 찾을 수 없습니다.'), { status: 404 }); + } + + return HttpResponse.json(wrapResponse(null)); + }), ]; diff --git a/src/types/comment.ts b/src/types/comment.ts index 8a1e1ce2..2f0690ec 100644 --- a/src/types/comment.ts +++ b/src/types/comment.ts @@ -29,3 +29,46 @@ export interface CreateCommentInput { ref?: Comment['ref']; parentId?: string; } + +/** + * API 응답: 댓글 생성/수정 응답 + */ +export interface CommentResponse { + id: string; + content: string; + parentId?: string; + userId: string; + createdAt: string; +} + +/** + * API 응답: 댓글 목록 조회 (슬라이드) + */ +export interface CommentListResponse { + comments: CommentWithUser[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +/** + * API: 사용자 정보 포함 댓글 + */ +export interface CommentWithUser { + id: string; + content: string; + user: { + id: string; + nickName: string; + }; + createdAt: string; + updatedAt: string; +} + +/** + * API: 답글 목록 (배열) + */ +export type ReplyListResponse = CommentResponse[]; From 295b1ae1b582d77281e5a1d1638ab461775bd4fe Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 08:04:06 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=97=B0=EB=8F=99=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/dto/auth.dto.ts | 30 ++++++++++++++++++++++++++++++ src/api/dto/index.ts | 1 + src/api/endpoints/auth.ts | 28 ++++++++++++++++++++++++++++ src/api/index.ts | 1 + src/stores/authStore.ts | 8 ++++++-- src/types/auth.ts | 3 ++- 6 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 src/api/dto/auth.dto.ts create mode 100644 src/api/endpoints/auth.ts diff --git a/src/api/dto/auth.dto.ts b/src/api/dto/auth.dto.ts new file mode 100644 index 00000000..72948130 --- /dev/null +++ b/src/api/dto/auth.dto.ts @@ -0,0 +1,30 @@ +/** + * 인증 관련 DTO + */ + +/** + * 소셜 로그인 성공 응답의 사용자 정보 + */ +export interface SocialLoginUserDto { + id: string; + email: string; + name: string; + sessionId: string; +} + +/** + * 소셜 로그인 성공 응답의 토큰 정보 + */ +export interface SocialLoginTokensDto { + accessToken: string; + refreshToken: string; +} + +/** + * 소셜 로그인 성공 응답 + */ +export interface SocialLoginSuccessDto { + message: string; + user: SocialLoginUserDto; + tokens: SocialLoginTokensDto; +} diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts index 2dc92e8f..54205637 100644 --- a/src/api/dto/index.ts +++ b/src/api/dto/index.ts @@ -3,6 +3,7 @@ * @description DTO 배럴 export */ +export type { SocialLoginSuccessDto, SocialLoginTokensDto, SocialLoginUserDto } from './auth.dto'; export type { CreateProjectDto, UpdateProjectDto } from './projects.dto'; export type { CreateSlideDto, UpdateSlideDto } from './slides.dto'; export type { UpdateScriptDto, RestoreScriptDto } from './scripts.dto'; diff --git a/src/api/endpoints/auth.ts b/src/api/endpoints/auth.ts new file mode 100644 index 00000000..490918aa --- /dev/null +++ b/src/api/endpoints/auth.ts @@ -0,0 +1,28 @@ +/** + * @file auth.ts + * @description 인증 관련 API 엔드포인트 + */ +import { apiClient } from '@/api'; +import type { SocialLoginSuccessDto } from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; + +/** + * Google OAuth 콜백 처리 + * + * @param code - OAuth 인증 코드 + * @returns 로그인 성공 정보 (user, tokens) + * + * @example + * const result = await getGoogleCallback('authorization-code'); + */ +export async function getGoogleCallback(code: string): Promise { + const response = await apiClient.get>( + `/auth/google/callback`, + { params: { code } }, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} diff --git a/src/api/index.ts b/src/api/index.ts index 69c43e33..9c0a5ef9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,5 +6,6 @@ export type { ApiErrorResponse } from './client'; /** @deprecated ApiErrorResponse 사용 권장 */ export type { ApiErrorResponse as ApiError } from './client'; export { queryClient, queryKeys } from './queryClient'; +export * from './endpoints/auth'; export * from './endpoints/videos'; export * from './dto'; diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index bdff2ae1..dcffe16f 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -12,9 +12,10 @@ import type { User } from '@/types/auth'; interface AuthState { user: User | null; accessToken: string | null; + refreshToken: string | null; isLoginModalOpen: boolean; - login: (user: User, accessToken: string) => void; + login: (user: User, accessToken: string, refreshToken: string) => void; logout: () => void; updateUser: (user: Partial) => void; openLoginModal: () => void; @@ -27,13 +28,15 @@ export const useAuthStore = create()( (set) => ({ user: null, accessToken: null, + refreshToken: null, isLoginModalOpen: false, - login: (user, accessToken) => { + login: (user, accessToken, refreshToken) => { set( { user, accessToken, + refreshToken, }, false, 'auth/login', @@ -45,6 +48,7 @@ export const useAuthStore = create()( { user: null, accessToken: null, + refreshToken: null, }, false, 'auth/logout', diff --git a/src/types/auth.ts b/src/types/auth.ts index 08f93620..b293ba81 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -4,6 +4,7 @@ export interface User { id: string; name: string; email: string; - provider: AuthProvider; + sessionId: string; + provider?: AuthProvider; profileImage?: string; } From 6523ec7d9d513b31a4c9099bce9a77cc8cf38715 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 08:06:28 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat:=20=EB=84=A4=EC=9D=B4=EB=B2=84=20?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/endpoints/auth.ts | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/api/endpoints/auth.ts b/src/api/endpoints/auth.ts index 490918aa..a4a1924b 100644 --- a/src/api/endpoints/auth.ts +++ b/src/api/endpoints/auth.ts @@ -26,3 +26,43 @@ export async function getGoogleCallback(code: string): Promise { + const response = await apiClient.get>(`/auth/kakao/callback`, { + params: { code }, + }); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} + +/** + * Naver OAuth 콜백 처리 + * + * @param code - OAuth 인증 코드 + * @returns 로그인 성공 정보 (user, tokens) + * + * @example + * const result = await getNaverCallback('authorization-code'); + */ +export async function getNaverCallback(code: string): Promise { + const response = await apiClient.get>(`/auth/naver/callback`, { + params: { code }, + }); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); +} From a026d8f47a3977d855b602e1b8bdd3234b9714b1 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 08:08:45 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=20=EB=B0=8F=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/login-modal/LoginModal.tsx | 10 +++ src/pages/OAuthCallbackPage.tsx | 65 +++++++++++++++++++ src/pages/index.ts | 1 + src/router/Router.tsx | 5 ++ 4 files changed, 81 insertions(+) create mode 100644 src/pages/OAuthCallbackPage.tsx diff --git a/src/components/auth/login-modal/LoginModal.tsx b/src/components/auth/login-modal/LoginModal.tsx index a96b2b8b..11710f6e 100644 --- a/src/components/auth/login-modal/LoginModal.tsx +++ b/src/components/auth/login-modal/LoginModal.tsx @@ -4,9 +4,16 @@ import KakaoIcon from '@/assets/social-icons/login-kakao.svg?react'; import NaverIcon from '@/assets/social-icons/login-naver.svg?react'; import { Modal } from '@/components/common/Modal'; import { useAuthStore } from '@/stores/authStore'; +import type { AuthProvider } from '@/types/auth'; import SocialLoginButton from './SocialLoginButton'; +const API_URL = import.meta.env.VITE_API_URL; + +function handleSocialLogin(provider: AuthProvider) { + window.location.href = `${API_URL}/auth/${provider}`; +} + export default function LoginModal() { const { isLoginModalOpen, closeLoginModal } = useAuthStore(); @@ -30,16 +37,19 @@ export default function LoginModal() { leftIcon={Google} label="구글로 계속하기" className="bg-[#F5F6F8] text-[#1a1a1a] cursor-pointer" + onClick={() => handleSocialLogin('google')} /> } label="네이버로 계속하기" className="bg-[#2DB400] text-[#ffffff] cursor-pointer" + onClick={() => handleSocialLogin('naver')} /> } label="카카오로 계속하기" className="bg-[#F7E600] text-[#1a1a1a] cursor-pointer" + onClick={() => handleSocialLogin('kakao')} /> diff --git a/src/pages/OAuthCallbackPage.tsx b/src/pages/OAuthCallbackPage.tsx new file mode 100644 index 00000000..31c344ab --- /dev/null +++ b/src/pages/OAuthCallbackPage.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import { getGoogleCallback, getKakaoCallback, getNaverCallback } from '@/api'; +import { Spinner } from '@/components/common'; +import { useAuthStore } from '@/stores/authStore'; +import type { AuthProvider } from '@/types/auth'; + +const callbackMap = { + google: getGoogleCallback, + kakao: getKakaoCallback, + naver: getNaverCallback, +} as const; + +export default function OAuthCallbackPage() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { login, closeLoginModal } = useAuthStore(); + const isProcessing = useRef(false); + + useEffect(() => { + const code = searchParams.get('code'); + const provider = searchParams.get('provider') as AuthProvider | null; + + if (!code || !provider || !callbackMap[provider]) { + navigate('/', { replace: true }); + return; + } + + if (isProcessing.current) return; + isProcessing.current = true; + + const handleCallback = async () => { + try { + const callbackFn = callbackMap[provider]; + const result = await callbackFn(code); + + login( + { + id: result.user.id, + email: result.user.email, + name: result.user.name, + sessionId: result.user.sessionId, + provider, + }, + result.tokens.accessToken, + result.tokens.refreshToken, + ); + + closeLoginModal(); + navigate('/', { replace: true }); + } catch { + navigate('/', { replace: true }); + } + }; + + handleCallback(); + }, [searchParams, navigate, login, closeLoginModal]); + + return ( +
+ +
+ ); +} diff --git a/src/pages/index.ts b/src/pages/index.ts index 750542be..9f3a45ab 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,5 +1,6 @@ export { default as DevTestPage } from './dev-test/DevTestPage'; export { default as HomePage } from './HomePage'; +export { default as OAuthCallbackPage } from './OAuthCallbackPage'; export { default as InsightPage } from './InsightPage'; export { default as SlidePage } from './SlidePage'; export { default as VideoPage } from './VideoPage'; diff --git a/src/router/Router.tsx b/src/router/Router.tsx index 6d5b7678..64e60754 100644 --- a/src/router/Router.tsx +++ b/src/router/Router.tsx @@ -16,6 +16,7 @@ import { FdVideoPage, HomePage, InsightPage, + OAuthCallbackPage, SlidePage, VideoPage, VideoRecordPage, @@ -31,6 +32,10 @@ export const router = createBrowserRouter([ path: '/dev', element: , }, + { + path: '/auth/callback', + element: , + }, { path: '/:projectId', element: ( From 778b3281bf4830a4cb827116023f8ae5325b738b Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 08:11:35 +0900 Subject: [PATCH 09/22] =?UTF-8?q?design:=20=EC=A0=9C=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=8C=9D=EC=98=A4=EB=B2=84=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=84=B1=20=EA=B0=9C=EC=84=A0=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/layout/PresentationTitleEditor.tsx | 123 +++++++----------- 1 file changed, 45 insertions(+), 78 deletions(-) diff --git a/src/components/common/layout/PresentationTitleEditor.tsx b/src/components/common/layout/PresentationTitleEditor.tsx index 766f8298..af76c7d9 100644 --- a/src/components/common/layout/PresentationTitleEditor.tsx +++ b/src/components/common/layout/PresentationTitleEditor.tsx @@ -5,14 +5,13 @@ * - 헤더에 프로젝트 제목 표시 * - 클릭하면 Popover 열리고, 입력/저장 가능 * - Enter 또는 저장 버튼으로 제출 - * - */ -import { type FormEvent, useEffect, useRef, useState } from 'react'; +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'; @@ -22,36 +21,15 @@ export function PresentationTitleEditor() { const { data: presentation } = usePresentation(projectId ?? ''); const { mutate: updatePresentation, isPending } = useUpdatePresentation(); - const [isOpen, setIsOpen] = useState(false); - - const [title, setTitle] = useState(''); - - const inputRef = useRef(null); + const resolvedTitle = presentation?.title?.trim() ? presentation.title : '내 발표'; + const [editTitle, setEditTitle] = useState(resolvedTitle); useEffect(() => { - if (!isOpen) return; + setEditTitle(resolvedTitle); + }, [resolvedTitle]); - // 포커스/셀렉트는 DOM 조작이라 effect에서 해도 OK - const t = window.setTimeout(() => { - inputRef.current?.select(); - }, 0); - - return () => window.clearTimeout(t); - }, [isOpen]); - - const handleOpenChange = (nextOpen: boolean) => { - setIsOpen(nextOpen); - - // 열릴 때만 초기화 (닫힐 땐 굳이 초기화 X) - if (nextOpen) { - setTitle(presentation?.title ?? ''); // presentation이 아직 없으면 빈 값 - } - }; - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - - const trimmedTitle = title.trim(); + const handleSave = (close: () => void) => { + const trimmedTitle = editTitle.trim(); if (!trimmedTitle) { showToast.error('제목을 입력해주세요'); return; @@ -64,7 +42,7 @@ export function PresentationTitleEditor() { { onSuccess: () => { showToast.success('제목이 변경되었습니다'); - setIsOpen(false); + close(); }, onError: () => { showToast.error('제목 변경에 실패했습니다'); @@ -75,73 +53,62 @@ export function PresentationTitleEditor() { return ( ( - } + )} + position="bottom" + align="start" + ariaLabel="발표 이름 변경" + className="flex w-80 items-center gap-2 border border-gray-200 px-3 py-2" > - {() => ( -
+ {({ close }) => ( + <> setTitle(e.target.value)} + value={editTitle} + onChange={(e) => setEditTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(close); + } + }} disabled={isPending} + aria-label="발표 이름" + placeholder="발표 제목을 입력하세요" className={clsx( - 'flex-1 text-body-m-bold text-gray-800', - 'focus:outline-none', - 'disabled:opacity-50 disabled:cursor-not-allowed', + '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', )} - placeholder="발표 제목을 입력하세요" /> - -
+ )}
); From 59b5a03685510b102ea503d84f6a9b9a93768917 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Tue, 3 Feb 2026 08:15:09 +0900 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=95=B4=EA=B2=B0=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/endpoints/analytics.ts | 13 +------------ src/hooks/queries/usePresentations.ts | 2 +- src/mocks/users.ts | 5 +++++ src/pages/OAuthCallbackPage.tsx | 2 +- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/api/endpoints/analytics.ts b/src/api/endpoints/analytics.ts index 11b86862..b8260a7c 100644 --- a/src/api/endpoints/analytics.ts +++ b/src/api/endpoints/analytics.ts @@ -18,18 +18,7 @@ export async function getSummaryAnalytics(projectId: string): Promise getPresentation(), + queryFn: () => getPresentation(projectId), enabled: !!projectId, }); } diff --git a/src/mocks/users.ts b/src/mocks/users.ts index 12966839..79ee187f 100644 --- a/src/mocks/users.ts +++ b/src/mocks/users.ts @@ -5,6 +5,7 @@ export const MOCK_USERS: User[] = [ id: 'user-1', name: '김또랑', email: 'andy@ttorang.io', + sessionId: 'session-mock-1', provider: 'google', profileImage: 'https://api.dicebear.com/9.x/notionists/svg?seed=me', }, @@ -12,6 +13,7 @@ export const MOCK_USERS: User[] = [ id: 'user-2', name: '춘식이', email: 'slide@ttorang.io', + sessionId: 'session-mock-2', provider: 'kakao', profileImage: 'https://api.dicebear.com/9.x/notionists/svg?seed=choonsik', }, @@ -19,6 +21,7 @@ export const MOCK_USERS: User[] = [ id: 'user-3', name: '라이언', email: 'feedback@ttorang.io', + sessionId: 'session-mock-3', provider: 'naver', profileImage: 'https://api.dicebear.com/9.x/notionists/svg?seed=ryan', }, @@ -26,6 +29,7 @@ export const MOCK_USERS: User[] = [ id: 'user-4', name: '어피치', email: 'dev@ttorang.io', + sessionId: 'session-mock-4', provider: 'google', profileImage: 'https://api.dicebear.com/9.x/notionists/svg?seed=apeach', }, @@ -33,6 +37,7 @@ export const MOCK_USERS: User[] = [ id: 'user-5', name: '가나디', email: 'design@ttorang.io', + sessionId: 'session-mock-5', provider: 'google', profileImage: 'https://api.dicebear.com/9.x/notionists/svg?seed=ganadi', }, diff --git a/src/pages/OAuthCallbackPage.tsx b/src/pages/OAuthCallbackPage.tsx index 31c344ab..89d91e04 100644 --- a/src/pages/OAuthCallbackPage.tsx +++ b/src/pages/OAuthCallbackPage.tsx @@ -59,7 +59,7 @@ export default function OAuthCallbackPage() { return (
- +
); } From 06f29fbe4bbb142e413da275e89d1d26ed07be1c Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 4 Feb 2026 00:43:22 +0900 Subject: [PATCH 11/22] =?UTF-8?q?refactor:=20api=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/dto/analytics.dto.ts | 6 +++ src/api/dto/comment.dto.ts | 0 src/api/dto/file.dto.ts | 0 src/api/dto/opinions.dto.ts | 8 ---- src/api/dto/presentation.dto.ts | 0 src/api/dto/projects.dto.ts | 14 ------- src/api/dto/scripts.dto.ts | 38 ++++++++++++++++--- src/api/dto/session.dto.ts | 0 src/api/dto/sharelink.dto.ts | 0 src/api/dto/slides.dto.ts | 27 ++++++++++++-- src/api/dto/video.dto.ts | 65 +++++++++++++++++++++++++++++++++ src/api/endpoints/videos.ts | 65 ++++----------------------------- 12 files changed, 133 insertions(+), 90 deletions(-) create mode 100644 src/api/dto/analytics.dto.ts create mode 100644 src/api/dto/comment.dto.ts create mode 100644 src/api/dto/file.dto.ts delete mode 100644 src/api/dto/opinions.dto.ts create mode 100644 src/api/dto/presentation.dto.ts delete mode 100644 src/api/dto/projects.dto.ts create mode 100644 src/api/dto/session.dto.ts create mode 100644 src/api/dto/sharelink.dto.ts create mode 100644 src/api/dto/video.dto.ts diff --git a/src/api/dto/analytics.dto.ts b/src/api/dto/analytics.dto.ts new file mode 100644 index 00000000..0727bdaa --- /dev/null +++ b/src/api/dto/analytics.dto.ts @@ -0,0 +1,6 @@ +/** + * 대본 복원 요청 DTO + */ +export interface RestoreScriptRequestDto { + version: number; +} diff --git a/src/api/dto/comment.dto.ts b/src/api/dto/comment.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/dto/file.dto.ts b/src/api/dto/file.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/dto/opinions.dto.ts b/src/api/dto/opinions.dto.ts deleted file mode 100644 index e6de12d7..00000000 --- a/src/api/dto/opinions.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * 의견(댓글) 생성 요청 DTO - */ -export interface CreateOpinionDto { - content: string; - /** 답글인 경우 부모 의견 ID */ - parentId?: string; -} diff --git a/src/api/dto/presentation.dto.ts b/src/api/dto/presentation.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/dto/projects.dto.ts b/src/api/dto/projects.dto.ts deleted file mode 100644 index 5f3e7ff3..00000000 --- a/src/api/dto/projects.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 프로젝트 수정 요청 DTO - */ -export interface UpdateProjectDto { - title: string; -} - -/** - * 프로젝트 생성 요청 DTO - */ -export interface CreateProjectDto { - title: string; - uploadFileId: string; -} diff --git a/src/api/dto/scripts.dto.ts b/src/api/dto/scripts.dto.ts index b3946555..c90ecc07 100644 --- a/src/api/dto/scripts.dto.ts +++ b/src/api/dto/scripts.dto.ts @@ -1,13 +1,39 @@ /** - * 대본 저장 요청 DTO + * 대본 저장 및 수정 DTO */ -export interface UpdateScriptDto { +export interface UpdateScriptRequestDto { script: string; } - /** - * 대본 복원 요청 DTO + * 대본 조회 DTO */ -export interface RestoreScriptDto { - version: number; +export interface GetScriptResponseDto { + message: string; + slideId: string; + charCount: number; + scriptText: string; + estimatedDurationSeconds: number; + createdAt: string; + updatedAt: string; +} +/** + * 대본 버전 히스토리 목록 조회 DTO + */ +export interface GetScriptVersionHistoryResponseDto { + versionNumber: number; + scriptText: string; + charCount: number; + createdAt: string; +} +/** + * 특정 버전으로 대본 복원 DTO + */ +export interface RestoreScriptResponseDto { + message: string; + slideId: string; + charCount: number; + scriptText: string; + estimatedDurationSeconds: number; + createdAt: string; + updatedAt: string; } diff --git a/src/api/dto/session.dto.ts b/src/api/dto/session.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/dto/sharelink.dto.ts b/src/api/dto/sharelink.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/dto/slides.dto.ts b/src/api/dto/slides.dto.ts index bc3e8e83..88ebfa15 100644 --- a/src/api/dto/slides.dto.ts +++ b/src/api/dto/slides.dto.ts @@ -1,14 +1,33 @@ +/** + * 슬라이드 목록 조회 DTO + */ +export interface CreateSlideResponseDto { + slideId: string; + projectId: string; + title: string; + slideNum: number; + imageUrl: string; + createdAt: string; + updatedAt: string; +} + /** * 슬라이드 제목 수정 요청 DTO */ -export interface UpdateSlideDto { +export interface UpdateSlideTitleRequestDto { title?: string; } /** - * 슬라이드 생성 요청 DTO + * 슬라이드 상세 조회 DTO */ -export interface CreateSlideDto { +export interface GetSlideResponseDto { + slideId: string; + projectId: string; title: string; - script?: string; + slideNum: number; + imageUrl: string; + prevSlideId: string | null; + nextSlideId: string | null; + updatedAt: string; } diff --git a/src/api/dto/video.dto.ts b/src/api/dto/video.dto.ts new file mode 100644 index 00000000..72091149 --- /dev/null +++ b/src/api/dto/video.dto.ts @@ -0,0 +1,65 @@ +/** + * 영상 타임스탬프 댓글 생성 + */ +export interface CreateOpinionDto { + content: string; + /** 답글인 경우 부모 의견 내용 고쳐라이D */ + parentId?: string; +} + +/** + * 영상 녹화 관련 DTO 정의 + */ +export interface StartVideoRequest { + projectId: number; + title: string; +} + +export interface StartVideoResponse { + resultType: 'SUCCESS' | 'FAILURE'; + error: null | { + errorCode: string; + reason: string; + data?: unknown; + }; + success: { + videoId: number; + }; +} + +export interface FinishVideoRequest { + slideLogs: Array<{ + slideId: number; + timestampMs: number; + }>; +} + +export interface FinishVideoResponse { + resultType: 'SUCCESS' | 'FAILURE'; + error: null | { + errorCode: string; + reason: string; + data?: unknown; + }; + success: { + videoId: string; + status: string; + slideCount: number; + slideDurations: Array<{ + slideId: string; + totalDurationMs: number; + }>; + }; +} + +export interface ChunkUploadResponse { + resultType: 'SUCCESS' | 'FAILURE'; + error: null | { + errorCode: string; + reason: string; + data?: unknown; + }; + success: { + ok: boolean; + }; +} diff --git a/src/api/endpoints/videos.ts b/src/api/endpoints/videos.ts index 24fc8f53..f00913a4 100644 --- a/src/api/endpoints/videos.ts +++ b/src/api/endpoints/videos.ts @@ -1,62 +1,11 @@ -// src/api/endpoints/videos/index.ts import { apiClient } from '@/api/client'; - -/** - * 영상 녹화 관련 DTO 정의 - */ -export interface StartVideoRequest { - projectId: number; - title: string; -} - -export interface StartVideoResponse { - resultType: 'SUCCESS' | 'FAILURE'; - error: null | { - errorCode: string; - reason: string; - data?: unknown; - }; - success: { - videoId: number; - }; -} - -export interface FinishVideoRequest { - slideLogs: Array<{ - slideId: number; - timestampMs: number; - }>; -} - -export interface FinishVideoResponse { - resultType: 'SUCCESS' | 'FAILURE'; - error: null | { - errorCode: string; - reason: string; - data?: unknown; - }; - success: { - videoId: string; - status: string; - slideCount: number; - slideDurations: Array<{ - slideId: string; - totalDurationMs: number; - }>; - }; -} - -export interface ChunkUploadResponse { - resultType: 'SUCCESS' | 'FAILURE'; - error: null | { - errorCode: string; - reason: string; - data?: unknown; - }; - success: { - ok: boolean; - }; -} +import type { + FinishVideoRequest, + FinishVideoResponse, + StartVideoRequest, + StartVideoResponse, +} from '@/api/dto/video.dto'; +import type { ChunkUploadResponse } from '@/api/dto/video.dto'; export const videosApi = { // POST /videos/start - 영상 녹화 세션 생성 From 44f486ee82ab5128496b6a5ee649653be02ca0bb Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 4 Feb 2026 11:57:32 +0900 Subject: [PATCH 12/22] =?UTF-8?q?refactor:=20=EB=B9=8C=EB=93=9C=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/dto/analytics.dto.ts | 2 + src/api/dto/auth.dto.ts | 12 ++- src/api/dto/comments.dto.ts | 88 +++++++++++++++++ src/api/dto/{comment.dto.ts => files.dto.ts} | 0 src/api/dto/index.ts | 31 +++++- src/api/dto/presentation.dto.ts | 0 .../dto/{file.dto.ts => presentations.dto.ts} | 0 src/components/video/RecordingSection.tsx | 50 ++++++---- src/hooks/useVideoUpload.ts | 2 +- src/pages/VideoRecordPage.tsx | 98 ++++++++++++++++++- src/types/recording.ts | 53 ++++++++++ 11 files changed, 304 insertions(+), 32 deletions(-) create mode 100644 src/api/dto/comments.dto.ts rename src/api/dto/{comment.dto.ts => files.dto.ts} (100%) delete mode 100644 src/api/dto/presentation.dto.ts rename src/api/dto/{file.dto.ts => presentations.dto.ts} (100%) create mode 100644 src/types/recording.ts diff --git a/src/api/dto/analytics.dto.ts b/src/api/dto/analytics.dto.ts index 0727bdaa..bcea4b2b 100644 --- a/src/api/dto/analytics.dto.ts +++ b/src/api/dto/analytics.dto.ts @@ -4,3 +4,5 @@ export interface RestoreScriptRequestDto { version: number; } + +//위 형식대로 response, request dto 작성 부탁드립니다. diff --git a/src/api/dto/auth.dto.ts b/src/api/dto/auth.dto.ts index 72948130..abfe776c 100644 --- a/src/api/dto/auth.dto.ts +++ b/src/api/dto/auth.dto.ts @@ -5,7 +5,7 @@ /** * 소셜 로그인 성공 응답의 사용자 정보 */ -export interface SocialLoginUserDto { +export interface SocialLoginUserResponseDto { id: string; email: string; name: string; @@ -15,7 +15,7 @@ export interface SocialLoginUserDto { /** * 소셜 로그인 성공 응답의 토큰 정보 */ -export interface SocialLoginTokensDto { +export interface SocialLoginTokensResponseDto { accessToken: string; refreshToken: string; } @@ -23,8 +23,10 @@ export interface SocialLoginTokensDto { /** * 소셜 로그인 성공 응답 */ -export interface SocialLoginSuccessDto { +export interface SocialLoginSuccessResponseDto { message: string; - user: SocialLoginUserDto; - tokens: SocialLoginTokensDto; + user: SocialLoginUserResponseDto; + tokens: SocialLoginTokensResponseDto; } + +//위 형식대로 response, request dto 작성 부탁드립니다. diff --git a/src/api/dto/comments.dto.ts b/src/api/dto/comments.dto.ts new file mode 100644 index 00000000..a5dbf6af --- /dev/null +++ b/src/api/dto/comments.dto.ts @@ -0,0 +1,88 @@ +/** + * 답글작성 + */ +export interface CreateReplyCommentRequestDto { + commentId: string; +} + +/** + * 답글작성 response DTO + */ +export interface CreateReplyCommentResponseDto { + id: string; + content: string; + parentId: string; + userId: string; + createdAt: string; +} + +/** + * 답글 목록 조회 DTO + */ +export interface GetRepliesResponseDto { + comments: Array<{ + id: string; + content: string; + parentId: string | null; + userId: string; + createdAt: string; + }>; +} +/** + * 슬라이드 댓글 작성 + */ +export interface CreateCommentRequestDto { + slideId: string; +} +/** + * 슬라이드 댓글 작성 response DTO + */ +export interface CreateCommentResponseDto { + id: string; + content: string; + userId: string; + createdAt: string; +} +/** + * 슬라이드 댓글 목록 조회 + */ +export interface GetSlideCommentsResponseDto { + comments: Array<{ + id: string; + content: string; + user: { + id: string; + nickName: string; + }; + createdAt: string; + updatedAt: string; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + }>; +} +/** + * 댓글 수정 + */ +export interface UpdateCommentResponseDto { + id: string; + content: string; + userId: string; + createdAt: string; +} +/** + * 댓글 및, 답글 삭제 + */ +export interface DeleteCommentRequestDto { + commentId: string; +} +/** + * 영상 타임스탬프 댓글 생성 + */ +export interface CreateVideoCommentRequestDto { + content: string; + timestampMs: number; +} diff --git a/src/api/dto/comment.dto.ts b/src/api/dto/files.dto.ts similarity index 100% rename from src/api/dto/comment.dto.ts rename to src/api/dto/files.dto.ts diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts index 54205637..8ea2b976 100644 --- a/src/api/dto/index.ts +++ b/src/api/dto/index.ts @@ -3,9 +3,30 @@ * @description DTO 배럴 export */ -export type { SocialLoginSuccessDto, SocialLoginTokensDto, SocialLoginUserDto } from './auth.dto'; -export type { CreateProjectDto, UpdateProjectDto } from './projects.dto'; -export type { CreateSlideDto, UpdateSlideDto } from './slides.dto'; -export type { UpdateScriptDto, RestoreScriptDto } from './scripts.dto'; -export type { CreateOpinionDto } from './opinions.dto'; +export type { + SocialLoginSuccessResponseDto, + SocialLoginTokensResponseDto, + SocialLoginUserResponseDto, +} from './auth.dto'; +export type { + CreateSlideResponseDto, + UpdateSlideTitleRequestDto, + GetSlideResponseDto, +} from './slides.dto'; +export type { + UpdateScriptRequestDto, + GetScriptResponseDto, + GetScriptVersionHistoryResponseDto, + RestoreScriptResponseDto, +} from './scripts.dto'; export type { ToggleSlideReactionDto } from './reactions.dto'; +export type { RestoreScriptRequestDto } from './analytics.dto'; +// export type { CreateProjectRequestDto, CreateProjectResponseDto, GetProjectResponseDto, UpdateProjectRequestDto } from './presentations.dto'; +// export type { UploadFileResponseDto } from './files.dto'; +export type { StartVideoRequest, FinishVideoRequest, FinishVideoResponse } from './video.dto'; +export type { + CreateCommentRequestDto, + CreateReplyCommentRequestDto, + CreateReplyCommentResponseDto, + GetRepliesResponseDto, +} from './comments.dto'; diff --git a/src/api/dto/presentation.dto.ts b/src/api/dto/presentation.dto.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/api/dto/file.dto.ts b/src/api/dto/presentations.dto.ts similarity index 100% rename from src/api/dto/file.dto.ts rename to src/api/dto/presentations.dto.ts diff --git a/src/components/video/RecordingSection.tsx b/src/components/video/RecordingSection.tsx index 0cbb26d9..3bb15913 100644 --- a/src/components/video/RecordingSection.tsx +++ b/src/components/video/RecordingSection.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Logo, SlideImage } from '@/components/common'; +import type { RecordingSlide } from '@/types/recording'; import { useRecorder } from '../../hooks/useRecorder'; @@ -12,11 +13,17 @@ interface SlideData { interface RecordingSectionProps { title: string; + slides: RecordingSlide[]; initialStream: MediaStream; onFinish: (videoBlob: Blob, durations: { [key: number]: number }) => void; } -export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSectionProps) => { +export const RecordingSection = ({ + title, + slides, + initialStream, + onFinish, +}: RecordingSectionProps) => { const slideImgRef = useRef(null); const logContainerRef = useRef(null); @@ -24,16 +31,25 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe const [currentPage, setCurrentPage] = useState(1); const [totalSeconds, setTotalSeconds] = useState(0); - const [slides, setSlides] = useState<{ [key: number]: SlideData }>({ + const [slideProgress, setSlideProgress] = useState<{ [key: number]: SlideData }>({ 1: { page: 1, duration: 0, visited: true }, }); const [slideImageLoaded, setSlideImageLoaded] = useState(false); const [recordingStartAttempted, setRecordingStartAttempted] = useState(false); const [isFinishing, setIsFinishing] = useState(false); - const totalPages = 10; + const totalPages = slides.length; const formatTime = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; - const getSlideImgUrl = (p: number) => `/thumbnails/p1/${p - 1}.webp`; + + const getSlideImgUrl = (p: number) => { + const slide = slides[p - 1]; + return slide?.imageUrl || `/thumbnails/p1/${p - 1}.webp`; + }; + + const getSlideScript = (p: number) => { + const slide = slides[p - 1]; + return slide?.script || '대본이 없습니다.'; + }; useEffect(() => { setSlideImageLoaded(false); @@ -55,7 +71,7 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe useEffect(() => { if (initialStream && !isRecording && slideImageLoaded && !recordingStartAttempted) { setRecordingStartAttempted(true); - startRecording(initialStream, slideImgRef, () => {}); + startRecording(initialStream, slideImgRef); } }, [initialStream, isRecording, slideImageLoaded, recordingStartAttempted, startRecording]); @@ -63,7 +79,7 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe if (!isRecording) return; const id = setInterval(() => { setTotalSeconds((v) => v + 1); - setSlides((prev) => ({ + setSlideProgress((prev) => ({ ...prev, [currentPage]: { ...prev[currentPage], @@ -80,7 +96,7 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe setCurrentPage((p) => { const next = dir === 'next' ? Math.min(p + 1, totalPages) : Math.max(p - 1, 1); if (next !== p) { - setSlides((prev) => ({ + setSlideProgress((prev) => ({ ...prev, [next]: prev[next] || { page: next, duration: 0, visited: true }, })); @@ -116,8 +132,9 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe try { stopRecording(); await new Promise((resolve) => setTimeout(resolve, 1500)); + const durations = Object.fromEntries( - Object.entries(slides).map(([k, v]) => [Number(k), v.duration]), + Object.entries(slideProgress).map(([k, v]) => [Number(k), v.duration]), ); const finalVideoBlob = new Blob(recordedChunks, { type: 'video/webm' }); @@ -125,12 +142,14 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe if (finalVideoBlob.size === 0) { throw new Error('녹화된 영상이 없습니다.'); } + onFinish(finalVideoBlob, durations); } catch (error) { alert(error instanceof Error ? error.message : '녹화 종료 중 오류가 발생했습니다.'); - setIsFinishing(false); // 에러 발생 시에만 다시 활성화 + setIsFinishing(false); } - }, [isFinishing, isRecording, stopRecording, recordedChunks, slides, onFinish]); + }, [isFinishing, isRecording, stopRecording, recordedChunks, slideProgress, onFinish]); + return (
- {/* Header */}
- {/* Main Content */}
@@ -239,7 +256,7 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe
Slide Time - {formatTime(slides[currentPage]?.duration || 0)} + {formatTime(slideProgress[currentPage]?.duration || 0)}
@@ -250,7 +267,6 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe )}
- {/* Sidebar */}
@@ -332,7 +348,7 @@ export const RecordingSection = ({ title, initialStream, onFinish }: RecordingSe > {Array.from({ length: totalPages }, (_, i) => i + 1).map((idx) => { const isCurrent = idx === currentPage; - const isVisited = slides[idx]?.visited; + const isVisited = slideProgress[idx]?.visited; return (
- {formatTime(slides[idx]?.duration || 0)} + {formatTime(slideProgress[idx]?.duration || 0)} )}
diff --git a/src/hooks/useVideoUpload.ts b/src/hooks/useVideoUpload.ts index 8b53d70f..88961920 100644 --- a/src/hooks/useVideoUpload.ts +++ b/src/hooks/useVideoUpload.ts @@ -1,7 +1,7 @@ import { useState } from 'react'; +import type { FinishVideoResponse, StartVideoResponse } from '@/api/dto/video.dto'; import { videosApi } from '@/api/endpoints/videos'; -import type { FinishVideoResponse, StartVideoResponse } from '@/api/endpoints/videos'; interface UploadProgress { uploadedChunks: number; diff --git a/src/pages/VideoRecordPage.tsx b/src/pages/VideoRecordPage.tsx index e90e89f1..32920f4a 100644 --- a/src/pages/VideoRecordPage.tsx +++ b/src/pages/VideoRecordPage.tsx @@ -1,9 +1,10 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Layout, Logo, Modal } from '@/components/common'; import { DeviceTestSection, RecordingSection } from '@/components/video'; import { useVideoUpload } from '@/hooks/useVideoUpload'; +import type { RecordingProject, RecordingSlide } from '@/types/recording'; type RecordStep = 'TEST' | 'RECORDING'; @@ -16,8 +17,42 @@ export default function VideoRecordPage() { const [isExitModalOpen, setIsExitModalOpen] = useState(false); const streamRef = useRef(null); + const [recordingData, setRecordingData] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(true); + const [loadError, setLoadError] = useState(null); + const { uploadVideo, isUploading, progress, error } = useVideoUpload(); + const MOCK_SLIDES: RecordingSlide[] = Array.from({ length: 10 }, (_, index) => ({ + id: `slide-${index + 1}`, + page: index + 1, + title: '제목없음', + imageUrl: `/thumbnails/p1/${index}.webp`, + script: `슬라이드 ${index + 1}번의 임시 대본입니다. 이것은 테스트용 대본이며 실제로는 스토어에서 가져온 데이터가 표시됩니다.`, + })); + + useEffect(() => { + if (!projectId) { + setLoadError('프로젝트 ID가 없습니다.'); + setIsLoadingData(false); + return; + } + + try { + const data: RecordingProject = { + projectId, + title: `프로젝트 ${projectId}`, + slides: MOCK_SLIDES, + }; + + setRecordingData(data); + } catch (err) { + setLoadError('데이터를 준비할 수 없습니다.'); + } finally { + setIsLoadingData(false); + } + }, [projectId, MOCK_SLIDES]); + const handleTestComplete = (streams: { cam: MediaStream }) => { streamRef.current = streams.cam; setCamStream(streams.cam); @@ -38,7 +73,7 @@ export default function VideoRecordPage() { try { const numericProjectId = projectId ? parseInt(projectId.replace(/\D/g, ''), 10) : 1; - const title = 'Q4 마케팅 전략 발표'; + const title = recordingData?.title || `프로젝트 ${projectId}`; const slideLogs = Object.entries(durations) .sort(([a], [b]) => Number(a) - Number(b)) @@ -105,7 +140,7 @@ export default function VideoRecordPage() { } setCamStream(null); setIsExitModalOpen(false); - navigate(`/${projectId}/video`); + navigate(`/${projectId}/slide`); }; const getStepLabel = () => { @@ -138,6 +173,60 @@ export default function VideoRecordPage() { } }; + if (isLoadingData) { + return ( + }> +
+
+
+

슬라이드 데이터를 준비하는 중...

+

잠시만 기다려주세요

+
+
+ + ); + } + + if (loadError) { + return ( + }> +
+
+
+ + + +
+

데이터 준비 실패

+

{loadError}

+
+ + +
+
+
+
+ ); + } + + if (!recordingData) { + return null; + } + return ( diff --git a/src/types/recording.ts b/src/types/recording.ts new file mode 100644 index 00000000..68d02fcb --- /dev/null +++ b/src/types/recording.ts @@ -0,0 +1,53 @@ +import type { Slide } from './slide'; + +/** + * 녹화용 슬라이드 데이터 + * + * Slide 타입에서 녹화에 필요한 필드만 추출 + */ +export interface RecordingSlide { + id: string; + page: number; + imageUrl: string; + script: string; + title: string; +} + +/** + * 녹화 프로젝트 데이터 + * + * 영상 녹화에 필요한 프로젝트 정보와 슬라이드 목록 + */ +export interface RecordingProject { + projectId: string; + title: string; + slides: RecordingSlide[]; +} + +/** + * Slide 배열을 RecordingSlide 배열로 변환 + */ +export function convertToRecordingSlides(slides: Slide[], projectId: string): RecordingSlide[] { + return slides.map((slide, index) => ({ + id: slide.id, + page: index + 1, + imageUrl: slide.thumb || `/thumbnails/${projectId}/${index}.webp`, + script: slide.script || '', + title: slide.title, + })); +} + +/** + * Slide 배열로부터 RecordingProject 생성 + */ +export function createRecordingProject( + projectId: string, + projectTitle: string, + slides: Slide[], +): RecordingProject { + return { + projectId, + title: projectTitle, + slides: convertToRecordingSlides(slides, projectId), + }; +} From 5e75db5feef9345ce691c24d9cac12e306ce7269 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 4 Feb 2026 12:01:34 +0900 Subject: [PATCH 13/22] =?UTF-8?q?fix:=20=EB=AC=B4=ED=95=9C=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=ED=95=B4=EA=B2=B0(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/VideoRecordPage.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/pages/VideoRecordPage.tsx b/src/pages/VideoRecordPage.tsx index 32920f4a..0dd7f10b 100644 --- a/src/pages/VideoRecordPage.tsx +++ b/src/pages/VideoRecordPage.tsx @@ -7,6 +7,13 @@ import { useVideoUpload } from '@/hooks/useVideoUpload'; import type { RecordingProject, RecordingSlide } from '@/types/recording'; type RecordStep = 'TEST' | 'RECORDING'; +const MOCK_SLIDES: RecordingSlide[] = Array.from({ length: 10 }, (_, index) => ({ + id: `slide-${index + 1}`, + page: index + 1, + title: '제목없음', + imageUrl: `/thumbnails/p1/${index}.webp`, + script: `슬라이드 ${index + 1}번의 임시 대본입니다. 이것은 테스트용 대본이며 실제로는 스토어에서 가져온 데이터가 표시됩니다.`, +})); export default function VideoRecordPage() { const { projectId } = useParams<{ projectId: string }>(); @@ -23,14 +30,6 @@ export default function VideoRecordPage() { const { uploadVideo, isUploading, progress, error } = useVideoUpload(); - const MOCK_SLIDES: RecordingSlide[] = Array.from({ length: 10 }, (_, index) => ({ - id: `slide-${index + 1}`, - page: index + 1, - title: '제목없음', - imageUrl: `/thumbnails/p1/${index}.webp`, - script: `슬라이드 ${index + 1}번의 임시 대본입니다. 이것은 테스트용 대본이며 실제로는 스토어에서 가져온 데이터가 표시됩니다.`, - })); - useEffect(() => { if (!projectId) { setLoadError('프로젝트 ID가 없습니다.'); From eed8303ad4f49caecba44f7386e737c4b675cc39 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 4 Feb 2026 12:41:56 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20SocialLoginSuccessResponseDto?= =?UTF-8?q?=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/endpoints/auth.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/api/endpoints/auth.ts b/src/api/endpoints/auth.ts index a4a1924b..56dfda24 100644 --- a/src/api/endpoints/auth.ts +++ b/src/api/endpoints/auth.ts @@ -3,7 +3,7 @@ * @description 인증 관련 API 엔드포인트 */ import { apiClient } from '@/api'; -import type { SocialLoginSuccessDto } from '@/api/dto'; +import type { SocialLoginSuccessResponseDto } from '@/api/dto'; import type { ApiResponse } from '@/types/api'; /** @@ -15,8 +15,8 @@ import type { ApiResponse } from '@/types/api'; * @example * const result = await getGoogleCallback('authorization-code'); */ -export async function getGoogleCallback(code: string): Promise { - const response = await apiClient.get>( +export async function getGoogleCallback(code: string): Promise { + const response = await apiClient.get>( `/auth/google/callback`, { params: { code } }, ); @@ -36,10 +36,13 @@ export async function getGoogleCallback(code: string): Promise { - const response = await apiClient.get>(`/auth/kakao/callback`, { - params: { code }, - }); +export async function getKakaoCallback(code: string): Promise { + const response = await apiClient.get>( + `/auth/kakao/callback`, + { + params: { code }, + }, + ); if (response.data.resultType === 'SUCCESS') { return response.data.success; @@ -56,10 +59,13 @@ export async function getKakaoCallback(code: string): Promise { - const response = await apiClient.get>(`/auth/naver/callback`, { - params: { code }, - }); +export async function getNaverCallback(code: string): Promise { + const response = await apiClient.get>( + `/auth/naver/callback`, + { + params: { code }, + }, + ); if (response.data.resultType === 'SUCCESS') { return response.data.success; From 05bda5c4d73d11d8365e096542f0514f64bc5855 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 4 Feb 2026 12:50:20 +0900 Subject: [PATCH 15/22] =?UTF-8?q?refactor:=20Script=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20DTO=EB=A1=9C=20=ED=86=B5=ED=95=A9=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScriptResponse → GetScriptResponseDto - ScriptVersion → GetScriptVersionHistoryResponseDto - UpdateScriptRequest → UpdateScriptRequestDto - RestoreScriptRequest → RestoreScriptRequestDto - types/api.ts에서 중복 타입 제거 Co-Authored-By: Claude Opus 4.5 --- src/api/endpoints/scripts.ts | 46 +++++++++++++++------------------- src/hooks/queries/useScript.ts | 14 +++-------- src/hooks/useSlideSelectors.ts | 4 +-- src/types/api.ts | 23 ----------------- src/types/index.ts | 2 +- src/types/slide.ts | 7 +++--- 6 files changed, 31 insertions(+), 65 deletions(-) diff --git a/src/api/endpoints/scripts.ts b/src/api/endpoints/scripts.ts index 47bbcae8..e5288a97 100644 --- a/src/api/endpoints/scripts.ts +++ b/src/api/endpoints/scripts.ts @@ -2,8 +2,14 @@ * @file scripts.ts * @description 대본 관련 API 엔드포인트 */ -import { apiClient } from '@/api/client'; -import type { ApiResponse, ScriptResponse, ScriptVersion } from '@/types/api'; +import { apiClient } from '@/api'; +import type { + GetScriptResponseDto, + GetScriptVersionHistoryResponseDto, + RestoreScriptRequestDto, + UpdateScriptRequestDto, +} from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; /** * 대본 조회 @@ -11,8 +17,8 @@ import type { ApiResponse, ScriptResponse, ScriptVersion } from '@/types/api'; * @param slideId - 슬라이드 ID * @returns 대본 정보 */ -export async function getScript(slideId: string): Promise { - const response = await apiClient.get>( +export async function getScript(slideId: string): Promise { + const response = await apiClient.get>( `/presentations/slides/${slideId}/script`, ); @@ -22,13 +28,6 @@ export async function getScript(slideId: string): Promise { throw new Error(response.data.error.reason); } -/** - * 대본 저장 요청 타입 - */ -export interface UpdateScriptRequest { - script: string; -} - /** * 대본 저장 * @@ -38,9 +37,9 @@ export interface UpdateScriptRequest { */ export async function updateScript( slideId: string, - data: UpdateScriptRequest, -): Promise { - const response = await apiClient.patch>( + data: UpdateScriptRequestDto, +): Promise { + const response = await apiClient.patch>( `/presentations/slides/${slideId}/script`, data, ); @@ -57,8 +56,10 @@ export async function updateScript( * @param slideId - 슬라이드 ID * @returns 버전 목록 (최신순) */ -export async function getScriptVersions(slideId: string): Promise { - const response = await apiClient.get>( +export async function getScriptVersions( + slideId: string, +): Promise { + const response = await apiClient.get>( `/presentations/slides/${slideId}/versions`, ); @@ -68,13 +69,6 @@ export async function getScriptVersions(slideId: string): Promise { - const response = await apiClient.post>( + data: RestoreScriptRequestDto, +): Promise { + const response = await apiClient.post>( `/presentations/slides/${slideId}/restore`, data, ); diff --git a/src/hooks/queries/useScript.ts b/src/hooks/queries/useScript.ts index 0de2d36b..9b619bc6 100644 --- a/src/hooks/queries/useScript.ts +++ b/src/hooks/queries/useScript.ts @@ -4,14 +4,8 @@ */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - type RestoreScriptRequest, - type UpdateScriptRequest, - getScript, - getScriptVersions, - restoreScript, - updateScript, -} from '@/api/endpoints/scripts'; +import type { RestoreScriptRequestDto, UpdateScriptRequestDto } from '@/api/dto'; +import { getScript, getScriptVersions, restoreScript, updateScript } from '@/api/endpoints/scripts'; import { queryKeys } from '@/api/queryClient'; /** @@ -34,7 +28,7 @@ export function useUpdateScript() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ slideId, data }: { slideId: string; data: UpdateScriptRequest }) => + mutationFn: ({ slideId, data }: { slideId: string; data: UpdateScriptRequestDto }) => updateScript(slideId, data), onSuccess: (_, { slideId }) => { @@ -64,7 +58,7 @@ export function useRestoreScript() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ slideId, data }: { slideId: string; data: RestoreScriptRequest }) => + mutationFn: ({ slideId, data }: { slideId: string; data: RestoreScriptRequestDto }) => restoreScript(slideId, data), onSuccess: (_, { slideId }) => { diff --git a/src/hooks/useSlideSelectors.ts b/src/hooks/useSlideSelectors.ts index f50150a9..0b28a99b 100644 --- a/src/hooks/useSlideSelectors.ts +++ b/src/hooks/useSlideSelectors.ts @@ -5,14 +5,14 @@ */ import { useShallow } from 'zustand/shallow'; +import type { GetScriptVersionHistoryResponseDto } from '@/api/dto'; import { useSlideStore } from '@/stores/slideStore'; -import type { ScriptVersion } from '@/types/api'; import type { Comment } from '@/types/comment'; import type { Reaction } from '@/types/script'; // 빈 배열 상수 (참조 안정성을 위해) const EMPTY_OPINIONS: Comment[] = []; -const EMPTY_HISTORY: ScriptVersion[] = []; +const EMPTY_HISTORY: GetScriptVersionHistoryResponseDto[] = []; const EMPTY_EMOJIS: Reaction[] = []; /** 슬라이드 ID 구독 */ diff --git a/src/types/api.ts b/src/types/api.ts index e8bcbffa..24590f82 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -38,29 +38,6 @@ export interface PaginatedData { totalPages: number; } -/** - * 대본 정보 응답 - */ -export interface ScriptResponse { - message?: string; - slideId: string; - charCount: number; - scriptText: string; - estimatedDurationSeconds: number; - createdAt: string; - updatedAt: string; -} - -/** - * 대본 버전 (히스토리) 정보 - */ -export interface ScriptVersion { - versionNumber: number; - scriptText: string; - charCount: number; - createdAt: string; -} - /** * 변환 상태 */ diff --git a/src/types/index.ts b/src/types/index.ts index 0a3d685e..c7bdeb6c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ // API -export type { ApiError, ApiResponse, PaginatedData, ScriptResponse, ScriptVersion } from './api'; +export type { ApiError, ApiResponse, PaginatedData } from './api'; // Auth export type { AuthProvider, User } from './auth'; diff --git a/src/types/slide.ts b/src/types/slide.ts index 592e96e7..d1f12546 100644 --- a/src/types/slide.ts +++ b/src/types/slide.ts @@ -1,4 +1,5 @@ -import type { ScriptVersion } from './api'; +import type { GetGetScriptVersionHistoryResponseDtoHistoryResponseDto } from '@/api/dto'; + import type { Comment } from './comment'; import type { Reaction } from './script'; @@ -18,7 +19,7 @@ export interface SlideListItem { /** 프론트엔드 확장용 - 의견 목록 */ opinions?: Comment[]; /** 프론트엔드 확장용 - 수정 기록 */ - history?: ScriptVersion[]; + history?: GetScriptVersionHistoryResponseDto[]; /** 프론트엔드 확장용 - 이모지 반응 */ emojiReactions?: Reaction[]; /** 영상 피드백에서 슬라이드 시작 시간 (초) */ @@ -61,7 +62,7 @@ export interface Slide { thumb: string; script: string; opinions: Comment[]; - history: ScriptVersion[]; + history: GetScriptVersionHistoryResponseDto[]; emojiReactions: Reaction[]; startTime?: number; } From 3ecaf2e9d38029cdb0b979e3e93b703f37230bf8 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 4 Feb 2026 12:55:51 +0900 Subject: [PATCH 16/22] =?UTF-8?q?refactor:=20Slide=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20DTO=EB=A1=9C=20=ED=86=B5=ED=95=A9=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SlideDetail → GetSlideResponseDto - SlideUpdateResponse → UpdateSlideResponseDto - UpdateSlideDto → UpdateSlideTitleRequestDto - deprecated UpdateSlideRequest 타입 별칭 제거 - types/slide.ts에서 중복 타입 제거 Co-Authored-By: Claude Opus 4.5 --- src/api/dto/index.ts | 3 ++- src/api/dto/slides.dto.ts | 11 +++++++++++ src/api/endpoints/slides.ts | 28 +++++++++++++--------------- src/types/slide.ts | 27 +-------------------------- 4 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts index 8ea2b976..ca7152e7 100644 --- a/src/api/dto/index.ts +++ b/src/api/dto/index.ts @@ -10,8 +10,9 @@ export type { } from './auth.dto'; export type { CreateSlideResponseDto, - UpdateSlideTitleRequestDto, GetSlideResponseDto, + UpdateSlideResponseDto, + UpdateSlideTitleRequestDto, } from './slides.dto'; export type { UpdateScriptRequestDto, diff --git a/src/api/dto/slides.dto.ts b/src/api/dto/slides.dto.ts index 88ebfa15..969d28df 100644 --- a/src/api/dto/slides.dto.ts +++ b/src/api/dto/slides.dto.ts @@ -31,3 +31,14 @@ export interface GetSlideResponseDto { nextSlideId: string | null; updatedAt: string; } + +/** + * 슬라이드 수정 응답 DTO + */ +export interface UpdateSlideResponseDto { + slideId: string; + title: string; + slideNum: number; + imageUrl: string; + updatedAt: string; +} diff --git a/src/api/endpoints/slides.ts b/src/api/endpoints/slides.ts index ff159135..9831934f 100644 --- a/src/api/endpoints/slides.ts +++ b/src/api/endpoints/slides.ts @@ -6,9 +6,13 @@ * 이 함수들은 직접 호출하지 않고, hooks/queries에서 사용합니다. */ import { apiClient } from '@/api'; -import type { UpdateSlideDto } from '@/api/dto'; +import type { + GetSlideResponseDto, + UpdateSlideResponseDto, + UpdateSlideTitleRequestDto, +} from '@/api/dto'; import type { ApiResponse } from '@/types/api'; -import type { SlideDetail, SlideListItem, SlideUpdateResponse } from '@/types/slide'; +import type { SlideListItem } from '@/types/slide'; /** * 프로젝트의 슬라이드 목록 조회 @@ -36,8 +40,8 @@ export async function getSlides(projectId: string): Promise { * @param slideId - 슬라이드 ID * @returns 슬라이드 정보 */ -export async function getSlide(slideId: string): Promise { - const response = await apiClient.get>( +export async function getSlide(slideId: string): Promise { + const response = await apiClient.get>( `/presentations/slides/${slideId}`, ); @@ -47,12 +51,6 @@ export async function getSlide(slideId: string): Promise { throw new Error(response.data.error.reason); } -/** - * 슬라이드 수정 요청 타입 (하위 호환성) - * @deprecated UpdateSlideDto 사용 권장 - */ -export type UpdateSlideRequest = UpdateSlideDto; - /** * 슬라이드 수정 * @@ -62,9 +60,9 @@ export type UpdateSlideRequest = UpdateSlideDto; */ export async function updateSlide( slideId: string, - data: UpdateSlideDto, -): Promise { - const response = await apiClient.patch>( + data: UpdateSlideTitleRequestDto, +): Promise { + const response = await apiClient.patch>( `/presentations/slides/${slideId}`, data, ); @@ -85,8 +83,8 @@ export async function updateSlide( export async function createSlide( projectId: string, data: { title: string; script?: string }, -): Promise { - const response = await apiClient.post>( +): Promise { + const response = await apiClient.post>( `/presentations/${projectId}/slides`, data, ); diff --git a/src/types/slide.ts b/src/types/slide.ts index d1f12546..a4ce5c51 100644 --- a/src/types/slide.ts +++ b/src/types/slide.ts @@ -1,4 +1,4 @@ -import type { GetGetScriptVersionHistoryResponseDtoHistoryResponseDto } from '@/api/dto'; +import type { GetScriptVersionHistoryResponseDto } from '@/api/dto'; import type { Comment } from './comment'; import type { Reaction } from './script'; @@ -26,31 +26,6 @@ export interface SlideListItem { startTime?: number; } -/** - * API 응답 타입: 슬라이드 상세 조회 - */ -export interface SlideDetail { - slideId: string; - projectId: string; - title: string; - slideNum: number; - imageUrl: string; - prevSlideId: string | null; - nextSlideId: string | null; - updatedAt: string; -} - -/** - * API 응답 타입: 슬라이드 수정 응답 - */ -export interface SlideUpdateResponse { - slideId: string; - title: string; - slideNum: number; - imageUrl: string; - updatedAt: string; -} - /** * 슬라이드 데이터 모델 (프론트엔드 스토어용) * @deprecated SlideListItem 사용 권장 From 0d632a5d3720487cb9d681448abe4c49ccdf6ef9 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 4 Feb 2026 13:04:50 +0900 Subject: [PATCH 17/22] =?UTF-8?q?refactor:=20Comment=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20DTO=EB=A1=9C=20=ED=86=B5=ED=95=A9=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommentResponse → CommentResponseDto - CommentListResponse → GetSlideCommentsResponseDto - CommentWithUser → CommentWithUserDto - ReplyListResponse → GetReplyListResponseDto - getReplies 에러 핸들링 패턴 통일 - types/comment.ts에서 API 응답 타입 제거 Co-Authored-By: Claude Opus 4.5 --- src/api/dto/comments.dto.ts | 60 +++++++++++++++++++++++++---------- src/api/dto/index.ts | 9 ++++++ src/api/endpoints/comments.ts | 36 +++++++++++++-------- src/types/comment.ts | 43 ------------------------- 4 files changed, 75 insertions(+), 73 deletions(-) diff --git a/src/api/dto/comments.dto.ts b/src/api/dto/comments.dto.ts index a5dbf6af..75acd3f2 100644 --- a/src/api/dto/comments.dto.ts +++ b/src/api/dto/comments.dto.ts @@ -44,26 +44,52 @@ export interface CreateCommentResponseDto { createdAt: string; } /** - * 슬라이드 댓글 목록 조회 + * 댓글 작성자 정보 + */ +export interface CommentUserDto { + id: string; + nickName: string; +} + +/** + * 사용자 정보 포함 댓글 + */ +export interface CommentWithUserDto { + id: string; + content: string; + user: CommentUserDto; + createdAt: string; + updatedAt: string; +} + +/** + * 슬라이드 댓글 목록 조회 응답 */ export interface GetSlideCommentsResponseDto { - comments: Array<{ - id: string; - content: string; - user: { - id: string; - nickName: string; - }; - createdAt: string; - updatedAt: string; - pagination: { - page: number; - limit: number; - total: number; - totalPages: number; - }; - }>; + comments: CommentWithUserDto[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +/** + * 댓글 생성/수정 응답 + */ +export interface CommentResponseDto { + id: string; + content: string; + parentId?: string; + userId: string; + createdAt: string; } + +/** + * 답글 목록 조회 응답 + */ +export type GetReplyListResponseDto = CommentResponseDto[]; /** * 댓글 수정 */ diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts index ca7152e7..a9476059 100644 --- a/src/api/dto/index.ts +++ b/src/api/dto/index.ts @@ -26,8 +26,17 @@ export type { RestoreScriptRequestDto } from './analytics.dto'; // export type { UploadFileResponseDto } from './files.dto'; export type { StartVideoRequest, FinishVideoRequest, FinishVideoResponse } from './video.dto'; export type { + CommentResponseDto, + CommentUserDto, + CommentWithUserDto, CreateCommentRequestDto, + CreateCommentResponseDto, CreateReplyCommentRequestDto, CreateReplyCommentResponseDto, + CreateVideoCommentRequestDto, + DeleteCommentRequestDto, GetRepliesResponseDto, + GetReplyListResponseDto, + GetSlideCommentsResponseDto, + UpdateCommentResponseDto, } from './comments.dto'; diff --git a/src/api/endpoints/comments.ts b/src/api/endpoints/comments.ts index ac1e2bce..6167e0ea 100644 --- a/src/api/endpoints/comments.ts +++ b/src/api/endpoints/comments.ts @@ -2,9 +2,13 @@ * @file comments.ts * @description 댓글 관련 API 엔드포인트 */ -import { apiClient } from '@/api/client'; +import { apiClient } from '@/api'; +import type { + CommentResponseDto, + GetReplyListResponseDto, + GetSlideCommentsResponseDto, +} from '@/api/dto'; import type { ApiResponse } from '@/types/api'; -import type { CommentListResponse, CommentResponse, ReplyListResponse } from '@/types/comment'; /** * 슬라이드 댓글 목록 조회 @@ -18,8 +22,8 @@ export async function getSlideComments( slideId: string, page = 1, limit = 20, -): Promise { - const response = await apiClient.get>( +): Promise { + const response = await apiClient.get>( `/slides/${slideId}/comments`, { params: { page, limit }, @@ -42,8 +46,8 @@ export async function getSlideComments( export async function createSlideComment( slideId: string, data: { content: string }, -): Promise { - const response = await apiClient.post>( +): Promise { + const response = await apiClient.post>( `/slides/${slideId}/comments`, data, ); @@ -64,8 +68,8 @@ export async function createSlideComment( export async function createReply( commentId: string, data: { content: string }, -): Promise { - const response = await apiClient.post>( +): Promise { + const response = await apiClient.post>( `/comments/${commentId}/replies`, data, ); @@ -82,9 +86,15 @@ export async function createReply( * @param commentId - 댓글 ID * @returns 답글 목록 */ -export async function getReplies(commentId: string): Promise { - const response = await apiClient.get(`/comments/${commentId}/replies`); - return response.data; +export async function getReplies(commentId: string): Promise { + const response = await apiClient.get>( + `/comments/${commentId}/replies`, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -97,8 +107,8 @@ export async function getReplies(commentId: string): Promise export async function updateComment( commentId: string, data: { content: string }, -): Promise { - const response = await apiClient.patch>( +): Promise { + const response = await apiClient.patch>( `/comments/${commentId}`, data, ); diff --git a/src/types/comment.ts b/src/types/comment.ts index 2f0690ec..8a1e1ce2 100644 --- a/src/types/comment.ts +++ b/src/types/comment.ts @@ -29,46 +29,3 @@ export interface CreateCommentInput { ref?: Comment['ref']; parentId?: string; } - -/** - * API 응답: 댓글 생성/수정 응답 - */ -export interface CommentResponse { - id: string; - content: string; - parentId?: string; - userId: string; - createdAt: string; -} - -/** - * API 응답: 댓글 목록 조회 (슬라이드) - */ -export interface CommentListResponse { - comments: CommentWithUser[]; - pagination: { - page: number; - limit: number; - total: number; - totalPages: number; - }; -} - -/** - * API: 사용자 정보 포함 댓글 - */ -export interface CommentWithUser { - id: string; - content: string; - user: { - id: string; - nickName: string; - }; - createdAt: string; - updatedAt: string; -} - -/** - * API: 답글 목록 (배열) - */ -export type ReplyListResponse = CommentResponse[]; From 435a0d5481e5480d9e43f2bdea3f8bf15969d25f Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 4 Feb 2026 13:16:09 +0900 Subject: [PATCH 18/22] =?UTF-8?q?fix:=20DTO=20export=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=20=EB=B0=8F=20import=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSlides.ts: UpdateSlideRequest → UpdateSlideTitleRequestDto - dto/index.ts: CreateOpinionDto export 추가 - presentations.dto.ts: UpdateProjectDto 정의 및 export 추가 Co-Authored-By: Claude Opus 4.5 --- src/api/dto/index.ts | 9 +++++++-- src/api/dto/presentations.dto.ts | 6 ++++++ src/hooks/queries/useSlides.ts | 12 +++--------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts index a9476059..b3adc924 100644 --- a/src/api/dto/index.ts +++ b/src/api/dto/index.ts @@ -22,9 +22,14 @@ export type { } from './scripts.dto'; export type { ToggleSlideReactionDto } from './reactions.dto'; export type { RestoreScriptRequestDto } from './analytics.dto'; -// export type { CreateProjectRequestDto, CreateProjectResponseDto, GetProjectResponseDto, UpdateProjectRequestDto } from './presentations.dto'; +export type { UpdateProjectDto } from './presentations.dto'; // export type { UploadFileResponseDto } from './files.dto'; -export type { StartVideoRequest, FinishVideoRequest, FinishVideoResponse } from './video.dto'; +export type { + CreateOpinionDto, + FinishVideoRequest, + FinishVideoResponse, + StartVideoRequest, +} from './video.dto'; export type { CommentResponseDto, CommentUserDto, diff --git a/src/api/dto/presentations.dto.ts b/src/api/dto/presentations.dto.ts index e69de29b..1819cbe4 100644 --- a/src/api/dto/presentations.dto.ts +++ b/src/api/dto/presentations.dto.ts @@ -0,0 +1,6 @@ +/** + * 프로젝트 제목 수정 요청 DTO + */ +export interface UpdateProjectDto { + title?: string; +} diff --git a/src/hooks/queries/useSlides.ts b/src/hooks/queries/useSlides.ts index 7af96c88..b1840067 100644 --- a/src/hooks/queries/useSlides.ts +++ b/src/hooks/queries/useSlides.ts @@ -3,14 +3,8 @@ */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - type UpdateSlideRequest, - createSlide, - deleteSlide, - getSlide, - getSlides, - updateSlide, -} from '@/api/endpoints/slides'; +import type { UpdateSlideTitleRequestDto } from '@/api/dto'; +import { createSlide, deleteSlide, getSlide, getSlides, updateSlide } from '@/api/endpoints/slides'; import { queryKeys } from '@/api/queryClient'; /** 슬라이드 목록 조회 */ @@ -36,7 +30,7 @@ export function useUpdateSlide() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ slideId, data }: { slideId: string; data: UpdateSlideRequest }) => + mutationFn: ({ slideId, data }: { slideId: string; data: UpdateSlideTitleRequestDto }) => updateSlide(slideId, data), onSuccess: (_, { slideId }) => { From e27ed4a42e7ea4f69f17d3d8373212c478ca5d0c Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 4 Feb 2026 13:20:35 +0900 Subject: [PATCH 19/22] =?UTF-8?q?refactor:=20Video=20DTO=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=ED=86=B5=EC=9D=BC=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Response DTO에서 ApiResponse 래퍼 중복 제거 - StartVideoResponse → StartVideoResponseDto (success 데이터만) - FinishVideoResponse → FinishVideoResponseDto (success 데이터만) - ChunkUploadResponse → ChunkUploadResponseDto (success 데이터만) - videos.ts endpoint에서 ApiResponse 래핑 적용 - useVideoUpload.ts 타입 수정 Co-Authored-By: Claude Opus 4.5 --- src/api/dto/index.ts | 8 +++-- src/api/dto/video.dto.ts | 66 +++++++++++++++---------------------- src/api/endpoints/videos.ts | 25 +++++++------- src/hooks/useVideoUpload.ts | 7 ++-- 4 files changed, 49 insertions(+), 57 deletions(-) diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts index b3adc924..e1441efc 100644 --- a/src/api/dto/index.ts +++ b/src/api/dto/index.ts @@ -25,10 +25,12 @@ export type { RestoreScriptRequestDto } from './analytics.dto'; export type { UpdateProjectDto } from './presentations.dto'; // export type { UploadFileResponseDto } from './files.dto'; export type { + ChunkUploadResponseDto, CreateOpinionDto, - FinishVideoRequest, - FinishVideoResponse, - StartVideoRequest, + FinishVideoRequestDto, + FinishVideoResponseDto, + StartVideoRequestDto, + StartVideoResponseDto, } from './video.dto'; export type { CommentResponseDto, diff --git a/src/api/dto/video.dto.ts b/src/api/dto/video.dto.ts index 72091149..c34b3e5d 100644 --- a/src/api/dto/video.dto.ts +++ b/src/api/dto/video.dto.ts @@ -8,58 +8,46 @@ export interface CreateOpinionDto { } /** - * 영상 녹화 관련 DTO 정의 + * 영상 녹화 시작 요청 DTO */ -export interface StartVideoRequest { +export interface StartVideoRequestDto { projectId: number; title: string; } -export interface StartVideoResponse { - resultType: 'SUCCESS' | 'FAILURE'; - error: null | { - errorCode: string; - reason: string; - data?: unknown; - }; - success: { - videoId: number; - }; +/** + * 영상 녹화 시작 응답 DTO (success 데이터) + */ +export interface StartVideoResponseDto { + videoId: number; } -export interface FinishVideoRequest { +/** + * 영상 녹화 완료 요청 DTO + */ +export interface FinishVideoRequestDto { slideLogs: Array<{ slideId: number; timestampMs: number; }>; } -export interface FinishVideoResponse { - resultType: 'SUCCESS' | 'FAILURE'; - error: null | { - errorCode: string; - reason: string; - data?: unknown; - }; - success: { - videoId: string; - status: string; - slideCount: number; - slideDurations: Array<{ - slideId: string; - totalDurationMs: number; - }>; - }; +/** + * 영상 녹화 완료 응답 DTO (success 데이터) + */ +export interface FinishVideoResponseDto { + videoId: string; + status: string; + slideCount: number; + slideDurations: Array<{ + slideId: string; + totalDurationMs: number; + }>; } -export interface ChunkUploadResponse { - resultType: 'SUCCESS' | 'FAILURE'; - error: null | { - errorCode: string; - reason: string; - data?: unknown; - }; - success: { - ok: boolean; - }; +/** + * 청크 업로드 응답 DTO (success 데이터) + */ +export interface ChunkUploadResponseDto { + ok: boolean; } diff --git a/src/api/endpoints/videos.ts b/src/api/endpoints/videos.ts index f00913a4..6759d63a 100644 --- a/src/api/endpoints/videos.ts +++ b/src/api/endpoints/videos.ts @@ -1,23 +1,24 @@ -import { apiClient } from '@/api/client'; +import { apiClient } from '@/api'; import type { - FinishVideoRequest, - FinishVideoResponse, - StartVideoRequest, - StartVideoResponse, -} from '@/api/dto/video.dto'; -import type { ChunkUploadResponse } from '@/api/dto/video.dto'; + ChunkUploadResponseDto, + FinishVideoRequestDto, + FinishVideoResponseDto, + StartVideoRequestDto, + StartVideoResponseDto, +} from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; export const videosApi = { // POST /videos/start - 영상 녹화 세션 생성 - startVideo: (data: StartVideoRequest) => - apiClient.post('/videos/start', data), + startVideo: (data: StartVideoRequestDto) => + apiClient.post>('/videos/start', data), // POST /videos/{videoId}/chunks/{chunkIndex} - 청크 업로드 uploadChunk: (videoId: number, chunkIndex: number, file: Blob) => { const formData = new FormData(); formData.append('file', file); - return apiClient.post( + return apiClient.post>( `/videos/${videoId}/chunks/${chunkIndex}`, formData, { @@ -29,8 +30,8 @@ export const videosApi = { }, // POST /videos/{videoId}/finish - 녹화 종료 및 영상 처리 시작 - finishVideo: (videoId: number, data: FinishVideoRequest) => - apiClient.post(`/videos/${videoId}/finish`, data), + finishVideo: (videoId: number, data: FinishVideoRequestDto) => + apiClient.post>(`/videos/${videoId}/finish`, data), // GET /videos/{videoId} - 영상 상세 조회 getVideoDetail: (videoId: number) => apiClient.get(`/videos/${videoId}`), diff --git a/src/hooks/useVideoUpload.ts b/src/hooks/useVideoUpload.ts index 88961920..e416e678 100644 --- a/src/hooks/useVideoUpload.ts +++ b/src/hooks/useVideoUpload.ts @@ -1,7 +1,8 @@ import { useState } from 'react'; -import type { FinishVideoResponse, StartVideoResponse } from '@/api/dto/video.dto'; +import type { FinishVideoResponseDto, StartVideoResponseDto } from '@/api/dto'; import { videosApi } from '@/api/endpoints/videos'; +import type { ApiResponse } from '@/types/api'; interface UploadProgress { uploadedChunks: number; @@ -43,7 +44,7 @@ export const useVideoUpload = () => { }); const startResponse = await videosApi.startVideo({ projectId, title }); - const startData: StartVideoResponse = startResponse.data; + const startData: ApiResponse = startResponse.data; if (startData.resultType === 'FAILURE' || !startData.success?.videoId) { throw new Error(startData.error?.reason || 'Video ID를 받지 못했습니다.'); @@ -93,7 +94,7 @@ export const useVideoUpload = () => { }); const finishResponse = await videosApi.finishVideo(videoId, { slideLogs }); - const finishData: FinishVideoResponse = finishResponse.data; + const finishData: ApiResponse = finishResponse.data; if (finishData.resultType === 'FAILURE') { throw new Error(finishData.error?.reason || '영상 처리에 실패했습니다.'); From 73dd15623e691e06af2845102f486cd63d0b5d98 Mon Sep 17 00:00:00 2001 From: Andy Hong Date: Wed, 4 Feb 2026 13:32:03 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=202/3=20=ED=95=B4=EA=B2=B0=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/share/share-modal/ShareModal.tsx | 2 +- src/components/slide/script/ScriptHistory.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/share/share-modal/ShareModal.tsx b/src/components/share/share-modal/ShareModal.tsx index ab1b72f0..a4dba2a6 100644 --- a/src/components/share/share-modal/ShareModal.tsx +++ b/src/components/share/share-modal/ShareModal.tsx @@ -129,7 +129,7 @@ export function ShareModal() { setStep('result'); } else if (response.resultType === 'FAILURE') { // 서버에서 에러 응답이 온 경우 - const errorMessage = response.reason?.message || '공유 링크 생성에 실패했습니다.'; + const errorMessage = response.error.reason || '공유 링크 생성에 실패했습니다.'; showToast.error(errorMessage); } else { // 예상치 못한 응답 형식 diff --git a/src/components/slide/script/ScriptHistory.tsx b/src/components/slide/script/ScriptHistory.tsx index 53565e59..877bb77f 100644 --- a/src/components/slide/script/ScriptHistory.tsx +++ b/src/components/slide/script/ScriptHistory.tsx @@ -6,12 +6,12 @@ */ import clsx from 'clsx'; +import type { GetScriptVersionHistoryResponseDto } from '@/api/dto'; import RevertIcon from '@/assets/icons/icon-revert.svg?react'; import { Popover, Spinner } from '@/components/common'; import { useSlideId, useSlideScript } from '@/hooks'; import { useRestoreScript, useScriptVersions } from '@/hooks/queries/useScript'; import { useSlideStore } from '@/stores/slideStore'; -import type { ScriptVersion } from '@/types/api'; import { formatTimestamp } from '@/utils/format'; import { showToast } from '@/utils/toast'; @@ -23,7 +23,7 @@ export default function ScriptHistory() { const { data: versions, isLoading } = useScriptVersions(slideId ?? ''); const { mutate: restoreScript, isPending: isRestoring } = useRestoreScript(); - const handleRestore = (version: ScriptVersion) => { + const handleRestore = (version: GetScriptVersionHistoryResponseDto) => { if (!slideId) return; restoreScript( From 411b6ee3cb33466aeeca964e7f6a8267fae1dc4f Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 4 Feb 2026 13:57:29 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20Recording=20props=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/video/RecordingContainer.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/video/RecordingContainer.tsx b/src/components/video/RecordingContainer.tsx index ae05d240..0e932bda 100644 --- a/src/components/video/RecordingContainer.tsx +++ b/src/components/video/RecordingContainer.tsx @@ -1,10 +1,18 @@ import { useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import type { RecordingSlide } from '@/types/recording'; + import { DeviceTestSection } from './DeviceTestSection'; import { RecordingSection } from './RecordingSection'; -const RecordingContainer = () => { +interface RecordingContainerProps { + projectId: string; + title: string; + slides: RecordingSlide[]; // 녹화할 슬라이드 정보 배열 +} + +const RecordingContainer = (props: RecordingContainerProps) => { const navigate = useNavigate(); const [step, setStep] = useState<'TEST' | 'RECORDING'>('TEST'); const [camStream, setCamStream] = useState(null); @@ -64,9 +72,10 @@ const RecordingContainer = () => { ) : ( camStream && ( ) )} From 02119e34eed24134e20aae8a6f2394ad3cf33a3d Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 4 Feb 2026 14:21:39 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20=ED=81=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/handlers.ts | 58 +++++++++++++++++++++++++++++++++++++++++++ src/mocks/handlers.ts | 2 +- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/api/handlers.ts diff --git a/src/api/handlers.ts b/src/api/handlers.ts new file mode 100644 index 00000000..71238224 --- /dev/null +++ b/src/api/handlers.ts @@ -0,0 +1,58 @@ +import type { AxiosResponse } from 'axios'; + +/** + * API 응답 표준 형식 + */ +export interface ApiResponse { + resultType: 'SUCCESS' | 'FAILURE'; + error: { + errorCode: string; + reason: string; + } | null; + success: T; +} + +/** + * API 응답 unwrap 헬퍼 함수 + * + * SUCCESS인 경우 success 데이터를 반환 + * FAILURE인 경우 에러를 throw + * + * @example + * ```typescript + * const response = await apiClient.get>('/users/me'); + * const user = unwrapApiResponse(response); + * ``` + */ +export function unwrapApiResponse(response: AxiosResponse>): T { + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + + const error = response.data.error; + const errorMessage = error?.reason || '알 수 없는 오류가 발생했습니다.'; + + throw new Error(errorMessage); +} + +/** + * 에러 응답 생성 헬퍼 (MSW 핸들러용) + */ +export function createErrorResponse(errorCode: string, reason: string): ApiResponse { + return { + resultType: 'FAILURE', + error: { errorCode, reason }, + success: null, + }; +} + +/** + * 성공 응답 생성 헬퍼 (MSW 핸들러용) + */ +export function createSuccessResponse(data: T): ApiResponse { + return { + resultType: 'SUCCESS', + error: null, + success: data, + }; +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index b0b72111..54e2d956 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -271,7 +271,7 @@ export const handlers = [ }); } - return HttpResponse.json(slide); + return HttpResponse.json(wrapResponse(slide)); }), /**