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/analytics.dto.ts b/src/api/dto/analytics.dto.ts new file mode 100644 index 00000000..bcea4b2b --- /dev/null +++ b/src/api/dto/analytics.dto.ts @@ -0,0 +1,8 @@ +/** + * 대본 복원 요청 DTO + */ +export interface RestoreScriptRequestDto { + version: number; +} + +//위 형식대로 response, request dto 작성 부탁드립니다. diff --git a/src/api/dto/auth.dto.ts b/src/api/dto/auth.dto.ts new file mode 100644 index 00000000..abfe776c --- /dev/null +++ b/src/api/dto/auth.dto.ts @@ -0,0 +1,32 @@ +/** + * 인증 관련 DTO + */ + +/** + * 소셜 로그인 성공 응답의 사용자 정보 + */ +export interface SocialLoginUserResponseDto { + id: string; + email: string; + name: string; + sessionId: string; +} + +/** + * 소셜 로그인 성공 응답의 토큰 정보 + */ +export interface SocialLoginTokensResponseDto { + accessToken: string; + refreshToken: string; +} + +/** + * 소셜 로그인 성공 응답 + */ +export interface SocialLoginSuccessResponseDto { + message: string; + 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..75acd3f2 --- /dev/null +++ b/src/api/dto/comments.dto.ts @@ -0,0 +1,114 @@ +/** + * 답글작성 + */ +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 CommentUserDto { + id: string; + nickName: string; +} + +/** + * 사용자 정보 포함 댓글 + */ +export interface CommentWithUserDto { + id: string; + content: string; + user: CommentUserDto; + createdAt: string; + updatedAt: string; +} + +/** + * 슬라이드 댓글 목록 조회 응답 + */ +export interface GetSlideCommentsResponseDto { + 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[]; +/** + * 댓글 수정 + */ +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/files.dto.ts b/src/api/dto/files.dto.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/api/dto/index.ts b/src/api/dto/index.ts new file mode 100644 index 00000000..e1441efc --- /dev/null +++ b/src/api/dto/index.ts @@ -0,0 +1,49 @@ +/** + * @file index.ts + * @description DTO 배럴 export + */ + +export type { + SocialLoginSuccessResponseDto, + SocialLoginTokensResponseDto, + SocialLoginUserResponseDto, +} from './auth.dto'; +export type { + CreateSlideResponseDto, + GetSlideResponseDto, + UpdateSlideResponseDto, + UpdateSlideTitleRequestDto, +} 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 { UpdateProjectDto } from './presentations.dto'; +// export type { UploadFileResponseDto } from './files.dto'; +export type { + ChunkUploadResponseDto, + CreateOpinionDto, + FinishVideoRequestDto, + FinishVideoResponseDto, + StartVideoRequestDto, + StartVideoResponseDto, +} 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/dto/presentations.dto.ts b/src/api/dto/presentations.dto.ts new file mode 100644 index 00000000..1819cbe4 --- /dev/null +++ b/src/api/dto/presentations.dto.ts @@ -0,0 +1,6 @@ +/** + * 프로젝트 제목 수정 요청 DTO + */ +export interface UpdateProjectDto { + title?: 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..c90ecc07 --- /dev/null +++ b/src/api/dto/scripts.dto.ts @@ -0,0 +1,39 @@ +/** + * 대본 저장 및 수정 DTO + */ +export interface UpdateScriptRequestDto { + script: string; +} +/** + * 대본 조회 DTO + */ +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 new file mode 100644 index 00000000..969d28df --- /dev/null +++ b/src/api/dto/slides.dto.ts @@ -0,0 +1,44 @@ +/** + * 슬라이드 목록 조회 DTO + */ +export interface CreateSlideResponseDto { + slideId: string; + projectId: string; + title: string; + slideNum: number; + imageUrl: string; + createdAt: string; + updatedAt: string; +} + +/** + * 슬라이드 제목 수정 요청 DTO + */ +export interface UpdateSlideTitleRequestDto { + title?: string; +} + +/** + * 슬라이드 상세 조회 DTO + */ +export interface GetSlideResponseDto { + slideId: string; + projectId: string; + title: string; + slideNum: number; + imageUrl: string; + prevSlideId: string | null; + nextSlideId: string | null; + updatedAt: string; +} + +/** + * 슬라이드 수정 응답 DTO + */ +export interface UpdateSlideResponseDto { + slideId: string; + title: string; + slideNum: number; + imageUrl: string; + updatedAt: string; +} diff --git a/src/api/dto/video.dto.ts b/src/api/dto/video.dto.ts new file mode 100644 index 00000000..c34b3e5d --- /dev/null +++ b/src/api/dto/video.dto.ts @@ -0,0 +1,53 @@ +/** + * 영상 타임스탬프 댓글 생성 + */ +export interface CreateOpinionDto { + content: string; + /** 답글인 경우 부모 의견 내용 고쳐라이D */ + parentId?: string; +} + +/** + * 영상 녹화 시작 요청 DTO + */ +export interface StartVideoRequestDto { + projectId: number; + title: string; +} + +/** + * 영상 녹화 시작 응답 DTO (success 데이터) + */ +export interface StartVideoResponseDto { + videoId: number; +} + +/** + * 영상 녹화 완료 요청 DTO + */ +export interface FinishVideoRequestDto { + slideLogs: Array<{ + slideId: number; + timestampMs: number; + }>; +} + +/** + * 영상 녹화 완료 응답 DTO (success 데이터) + */ +export interface FinishVideoResponseDto { + videoId: string; + status: string; + slideCount: number; + slideDurations: Array<{ + slideId: string; + totalDurationMs: number; + }>; +} + +/** + * 청크 업로드 응답 DTO (success 데이터) + */ +export interface ChunkUploadResponseDto { + ok: boolean; +} 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 { + 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); +} + +/** + * Kakao OAuth 콜백 처리 + * + * @param code - OAuth 인증 코드 + * @returns 로그인 성공 정보 (user, tokens) + * + * @example + * const result = await getKakaoCallback('authorization-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; + } + 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); +} diff --git a/src/api/endpoints/comments.ts b/src/api/endpoints/comments.ts new file mode 100644 index 00000000..6167e0ea --- /dev/null +++ b/src/api/endpoints/comments.ts @@ -0,0 +1,133 @@ +/** + * @file comments.ts + * @description 댓글 관련 API 엔드포인트 + */ +import { apiClient } from '@/api'; +import type { + CommentResponseDto, + GetReplyListResponseDto, + GetSlideCommentsResponseDto, +} from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; + +/** + * 슬라이드 댓글 목록 조회 + * + * @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`, + ); + + 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 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/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 18e10f6d..864199a0 100644 --- a/src/api/endpoints/presentations.ts +++ b/src/api/endpoints/presentations.ts @@ -1,76 +1,118 @@ /** - * @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, + PresentationListResponse, + ProjectUpdateResponse, +} from '@/types/presentation'; /** - * 프로젝트 목록 조회 + * 프로젝트 목록 조회 (GET) * - * 각 프로젝트는 id를 포함하며, 수정/삭제 시 이 id를 사용함. - * @returns 프로젝트 배열 + * @returns Presentation[] */ 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.presentations; + } + throw new Error(response.data.error.reason); } /** * 프로젝트 상세 조회 * - * @param projectId - 프로젝트 ID + * @param projectId + * @returns Presentation */ -export async function getPresentation(): Promise { - const response = await apiClient.get(`/presentations`); - return response.data; -} +export async function getPresentation(projectId: string): Promise { + const response = await apiClient.get>(`/presentations/${projectId}`); -/** - * 프로젝트 수정 요청 타입 - */ -export interface UpdatePresentationRequest { - title?: string; + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** - * 프로젝트 수정 + * 프로젝트 제목 수정 (PATCH) * - * @param projectId - 수정할 프로젝트 ID - * @param data - 수정할 데이터 - * @returns 수정된 프로젝트 + * @param projectId + * @param data - 수정할 프로젝트 데이터 + * @returns ProjectUpdateResponse - 수정된 프로젝트 정보 */ 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); } /** - * 프로젝트 생성 + * 프로젝트 생성 (POST) * * @param data - 생성할 프로젝트 데이터 - * @returns 생성된 프로젝트 + * @returns CreatePresentationSuccess - 생성된 프로젝트 정보 */ -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); } /** - * 프로젝트 삭제 + * 프로젝트 삭제 (DELETE) * - * @param projectId - 삭제할 프로젝트 ID + * @param projectId */ export async function deletePresentation(projectId: 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); + } +} + +/** + * 프로젝트 파일 변환 상태 조회 (GET) + * + * @param projectId - 프로젝트 ID + * @returns ConversionStatusResponse - 변환 상태 정보 + */ +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..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,18 +17,15 @@ 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`, ); - return response.data.success; -} -/** - * 대본 저장 요청 타입 - */ -export interface UpdateScriptRequest { - script: string; + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -34,13 +37,17 @@ 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, ); - return response.data.success; + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -49,18 +56,17 @@ 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`, ); - return response.data.success; -} -/** - * 대본 복원 요청 타입 - */ -export interface RestoreScriptRequest { - version: number; + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -72,11 +78,15 @@ export interface RestoreScriptRequest { */ export async function restoreScript( slideId: string, - data: RestoreScriptRequest, -): Promise { - const response = await apiClient.post>( + data: RestoreScriptRequestDto, +): Promise { + const response = await apiClient.post>( `/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 ccac5df2..9831934f 100644 --- a/src/api/endpoints/slides.ts +++ b/src/api/endpoints/slides.ts @@ -6,7 +6,13 @@ * 이 함수들은 직접 호출하지 않고, hooks/queries에서 사용합니다. */ import { apiClient } from '@/api'; -import type { Slide } from '@/types/slide'; +import type { + GetSlideResponseDto, + UpdateSlideResponseDto, + UpdateSlideTitleRequestDto, +} from '@/api/dto'; +import type { ApiResponse } from '@/types/api'; +import type { SlideListItem } from '@/types/slide'; /** * 프로젝트의 슬라이드 목록 조회 @@ -17,9 +23,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(`/presentations/${projectId}/slides`); - return response.data; +export async function getSlides(projectId: string): Promise { + const response = await apiClient.get>( + `/presentations/${projectId}/slides`, + ); + + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -28,17 +40,15 @@ 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>( + `/presentations/slides/${slideId}`, + ); -/** - * 슬라이드 수정 요청 타입 - */ -export interface UpdateSlideRequest { - title?: string; - script?: string; + if (response.data.resultType === 'SUCCESS') { + return response.data.success; + } + throw new Error(response.data.error.reason); } /** @@ -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: UpdateSlideTitleRequestDto, +): Promise { + const response = await apiClient.patch>( + `/presentations/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(`/presentations/${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>(`/presentations/slides/${slideId}`); + + if (response.data.resultType === 'FAILURE') { + throw new Error(response.data.error.reason); + } } diff --git a/src/api/endpoints/videos.ts b/src/api/endpoints/videos.ts index 24fc8f53..6759d63a 100644 --- a/src/api/endpoints/videos.ts +++ b/src/api/endpoints/videos.ts @@ -1,74 +1,24 @@ -// 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 { apiClient } from '@/api'; +import type { + 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, { @@ -80,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/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/api/index.ts b/src/api/index.ts index 8f7231cf..9c0a5ef9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,6 +2,10 @@ * 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/auth'; export * from './endpoints/videos'; +export * from './dto'; diff --git a/src/api/queryClient.ts b/src/api/queryClient.ts index 7773152d..9e272dc7 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/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/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="발표 제목을 입력하세요" /> - -
+ )}
); 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..e2266644 100644 --- a/src/components/presentation/PresentationCard.tsx +++ b/src/components/presentation/PresentationCard.tsx @@ -83,20 +83,18 @@ function PresentationCardSkeleton() { } function PresentationCard({ - id, + projectId, title, highlightQuery = '', updatedAt, - durationMinutes, - pageCount, - commentCount, - reactionCount, - viewCount = 0, + durationSeconds, + slideCount, + feedbackCount, thumbnailUrl, }: Props) { const navigate = useNavigate(); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = - usePresentationDeletion(id); + usePresentationDeletion(projectId); const { isRenaming, @@ -108,11 +106,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[] = [ @@ -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 04bae944..2d1e4f32 100644 --- a/src/components/presentation/PresentationList.tsx +++ b/src/components/presentation/PresentationList.tsx @@ -81,19 +81,17 @@ function PresentationListSkeleton() { } function PresentationList({ - id, + projectId, title, updatedAt, - durationMinutes, - pageCount, - commentCount, - reactionCount, - viewCount = 0, + durationSeconds, + slideCount, + feedbackCount, thumbnailUrl, }: Props) { const navigate = useNavigate(); const { isDeleteModalOpen, openDeleteModal, closeDeleteModal, confirmDelete, isPending } = - usePresentationDeletion(id); + usePresentationDeletion(projectId); const { isRenaming, @@ -105,11 +103,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[] = [ @@ -198,30 +196,22 @@ function PresentationList({ {formatRelativeTime(updatedAt)} - {durationMinutes} + {Math.ceil(durationSeconds / 60)}분 {/* 구분선 */} - {/* 페이지 수 & 반응 모음 */} + {/* 슬라이드 수 & 피드백 수 */}
- {pageCount} 페이지 + {slideCount} 슬라이드 - {commentCount} - - - - {reactionCount} - - - - {viewCount} + {feedbackCount}
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/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/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( 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 && ( ) )} 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/components/video/VideoListPage.tsx b/src/components/video/VideoListPage.tsx index 68633998..60ff02a3 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,15 +52,15 @@ 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, + 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); @@ -153,13 +142,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 b611c2d9..13752a13 100644 --- a/src/hooks/queries/usePresentations.ts +++ b/src/hooks/queries/usePresentations.ts @@ -7,15 +7,15 @@ 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, updatePresentation, } from '@/api/endpoints/presentations'; -import type { Presentation } from '@/types/presentation'; +import type { CreatePresentationRequest, Presentation } from '@/types/presentation'; import { showToast } from '@/utils/toast'; /** 프로젝트 목록 조회 */ @@ -30,7 +30,7 @@ export function usePresentations() { export function usePresentation(projectId: string) { return useQuery({ queryKey: queryKeys.presentations.detail(projectId), - queryFn: () => getPresentation(), + queryFn: () => getPresentation(projectId), enabled: !!projectId, }); } @@ -39,13 +39,13 @@ 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) => { // 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/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/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 }) => { 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/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/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..0b28a99b 100644 --- a/src/hooks/useSlideSelectors.ts +++ b/src/hooks/useSlideSelectors.ts @@ -5,24 +5,24 @@ */ 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 구독 */ -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/hooks/useVideoUpload.ts b/src/hooks/useVideoUpload.ts index 8b53d70f..e416e678 100644 --- a/src/hooks/useVideoUpload.ts +++ b/src/hooks/useVideoUpload.ts @@ -1,7 +1,8 @@ import { useState } from 'react'; +import type { FinishVideoResponseDto, StartVideoResponseDto } from '@/api/dto'; import { videosApi } from '@/api/endpoints/videos'; -import type { FinishVideoResponse, StartVideoResponse } 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 || '영상 처리에 실패했습니다.'); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 86a1e911..54e2d956 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) { @@ -39,10 +53,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 핸들러 정의 * @@ -58,10 +78,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(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, + }), + ); }), /** @@ -69,11 +108,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,15 +134,14 @@ 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, - 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]; @@ -120,7 +158,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 +185,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 +194,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,27 +204,63 @@ 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)); }), /** * 특정 슬라이드 조회 - * 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); @@ -197,19 +271,19 @@ export const handlers = [ }); } - return HttpResponse.json(slide); + return HttpResponse.json(wrapResponse(slide)); }), /** * 슬라이드 수정 - * 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); @@ -274,13 +348,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); @@ -530,14 +604,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 +628,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 +663,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 +691,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 +712,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 }); } // 현재 스크립트를 버전으로 저장 @@ -805,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/mocks/projects.ts b/src/mocks/projects.ts index 04b3ea2b..bec7e3c0 100644 --- a/src/mocks/projects.ts +++ b/src/mocks/projects.ts @@ -4,47 +4,43 @@ import { daysAgo } from './utils'; export const MOCK_PROJECTS: Presentation[] = [ { - id: 'p1', + 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), }, { - id: 'p2', + 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), }, { - id: 'p3', + 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), }, { - id: 'p4', + 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/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/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/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/pages/InsightPage.tsx b/src/pages/InsightPage.tsx index 9a1fffa4..ea6e79d3 100644 --- a/src/pages/InsightPage.tsx +++ b/src/pages/InsightPage.tsx @@ -172,7 +172,7 @@ export default function InsightPage() { .slice(0, 3); }, [slides]); - const getThumb = (slideIndex: number) => slides?.[slideIndex]?.thumb; + const getThumb = (slideIndex: number) => slides?.[slideIndex]?.imageUrl; return (
{ + 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/SlidePage.tsx b/src/pages/SlidePage.tsx index c2ed8307..8676f8da 100644 --- a/src/pages/SlidePage.tsx +++ b/src/pages/SlidePage.tsx @@ -13,7 +13,7 @@ export default function SlidePage() { const { data: slides, isLoading, isError } = useSlides(projectId ?? ''); const slideIdParam = searchParams.get('slideId'); - const currentSlide = slides?.find((s) => 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/pages/VideoRecordPage.tsx b/src/pages/VideoRecordPage.tsx index e90e89f1..0dd7f10b 100644 --- a/src/pages/VideoRecordPage.tsx +++ b/src/pages/VideoRecordPage.tsx @@ -1,11 +1,19 @@ -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'; +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 }>(); @@ -16,8 +24,34 @@ 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(); + 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 +72,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 +139,7 @@ export default function VideoRecordPage() { } setCamStream(null); setIsExitModalOpen(false); - navigate(`/${projectId}/video`); + navigate(`/${projectId}/slide`); }; const getStepLabel = () => { @@ -138,6 +172,60 @@ export default function VideoRecordPage() { } }; + if (isLoadingData) { + return ( + }> +
+
+
+

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

+

잠시만 기다려주세요

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

데이터 준비 실패

+

{loadError}

+
+ + +
+
+
+
+ ); + } + + if (!recordingData) { + return null; + } + 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: ( 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/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..24590f82 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; + }; /** * 페이지네이션 응답 @@ -31,24 +39,26 @@ export interface PaginatedData { } /** - * 대본 정보 응답 + * 변환 상태 + */ +export type ConversionStatus = 'processing' | 'completed' | 'failed'; + +/** + * 변환 진행 상황 */ -export interface ScriptResponse { - message?: string; - slideId: string; - charCount: number; - scriptText: string; - estimatedDurationSeconds: number; - createdAt: string; - updatedAt: string; +export interface ConversionProgress { + slides: { + total: number; + generated: number; + }; + thumbnail: ConversionStatus; + metadata: ConversionStatus; } /** - * 대본 버전 (히스토리) 정보 + * 변환 상태 응답 */ -export interface ScriptVersion { - versionNumber: number; - scriptText: string; - charCount: number; - createdAt: string; +export interface ConversionStatusResponse { + status: ConversionStatus; + progress: ConversionProgress; } 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; } diff --git a/src/types/index.ts b/src/types/index.ts index 49b0445f..2a0ec610 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/presentation.ts b/src/types/presentation.ts index 23f3656b..f07b7b77 100644 --- a/src/types/presentation.ts +++ b/src/types/presentation.ts @@ -18,13 +18,32 @@ export interface CreatePresentationSuccess { } export interface Presentation { - id: string; + 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; +} + +/** + * API 응답 타입: 프로젝트 수정 응답 + */ +export interface ProjectUpdateResponse { + projectId: string; + title: string; + updatedAt: string; } 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), + }; +} diff --git a/src/types/slide.ts b/src/types/slide.ts index 8e5d3d45..a4ce5c51 100644 --- a/src/types/slide.ts +++ b/src/types/slide.ts @@ -1,12 +1,34 @@ -import type { ScriptVersion } from './api'; +import type { GetScriptVersionHistoryResponseDto } from '@/api/dto'; + 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?: GetScriptVersionHistoryResponseDto[]; + /** 프론트엔드 확장용 - 이모지 반응 */ + emojiReactions?: Reaction[]; + /** 영상 피드백에서 슬라이드 시작 시간 (초) */ + startTime?: number; +} + +/** + * 슬라이드 데이터 모델 (프론트엔드 스토어용) + * @deprecated SlideListItem 사용 권장 */ export interface Slide { id: string; @@ -15,8 +37,7 @@ export interface Slide { thumb: string; script: string; opinions: Comment[]; - history: ScriptVersion[]; + history: GetScriptVersionHistoryResponseDto[]; emojiReactions: Reaction[]; - /** 영상 피드백에서 슬라이드 시작 시간 (초) */ startTime?: number; }