diff --git a/src/frontend/apps/web/src/features/chat/api/get-ping.api.ts b/src/frontend/apps/web/src/features/chat/api/get-ping.api.ts new file mode 100644 index 00000000..45b80c0d --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/api/get-ping.api.ts @@ -0,0 +1,5 @@ +import { getRequest } from '@/src/shared/services'; + +export async function getPing() { + return getRequest('file', '/api/v1/files/test'); +} diff --git a/src/frontend/apps/web/src/features/chat/api/index.ts b/src/frontend/apps/web/src/features/chat/api/index.ts index 23958b32..2078b2c5 100644 --- a/src/frontend/apps/web/src/features/chat/api/index.ts +++ b/src/frontend/apps/web/src/features/chat/api/index.ts @@ -1 +1,6 @@ +export { getPing } from './get-ping.api'; +export { uploadFiles } from './upload-file.api'; +export { uploadSmallFiles } from './upload-smallfile.api'; +export { uploadChunksQueue } from './upload-chunks-queue.api'; +export { uploadThumbnail } from './upload-thumbnail.api'; export { getHistoryChat } from './get-history-chat.api'; diff --git a/src/frontend/apps/web/src/features/chat/api/upload-chunk-retry-api.ts b/src/frontend/apps/web/src/features/chat/api/upload-chunk-retry-api.ts new file mode 100644 index 00000000..ae5b2fa7 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/api/upload-chunk-retry-api.ts @@ -0,0 +1,73 @@ +import { ResponseChunkFileData } from '../model'; +import { uploadFiles } from './upload-file.api'; + +export const uploadChunkWithRetry = async ( + chunk: Blob, + channelId: number, + workspaceId: number, + tempFileIdentifier: string, + totalChunks: number, + totalSize: number, + chunkIndex: number, + attempt: number = 0, +): Promise => { + const maxAttempts = 5; + try { + // console.log( + // '[uploadChunkWithRetry] Attempt:', + // attempt, + // 'ChunkIndex:', + // chunkIndex, + // ); + + const fileForChunk = new File([chunk], `chunk-${chunkIndex}`, { + type: 'application/octet-stream', + }); + + // console.log('[uploadChunkWithRetry] fileForChunk size:', fileForChunk.size); + + const response = await uploadFiles({ + channelId, + workspaceId, + tempFileIdentifier, + totalChunks, + totalSize, + chunkIndex, + chunk: fileForChunk, + }); + // console.log( + // '[uploadChunkWithRetry] Upload success. chunkIndex:', + // chunkIndex, + // ); + return response; + } catch (error) { + // console.error( + // '[uploadChunkWithRetry] Upload error. chunkIndex:', + // chunkIndex, + // error, + // ); + + if (attempt < maxAttempts) { + const delay = Math.pow(2, attempt) * 1000; + console.warn( + `Upload failed for chunk ${chunkIndex} (attempt ${attempt + 1}). Retrying in ${delay}ms...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + return uploadChunkWithRetry( + chunk, + channelId, + workspaceId, + tempFileIdentifier, + totalChunks, + totalSize, + chunkIndex, + attempt + 1, + ); + } else { + console.error( + `Chunk ${chunkIndex} upload failed after ${maxAttempts} attempts.`, + ); + throw error; + } + } +}; diff --git a/src/frontend/apps/web/src/features/chat/api/upload-chunks-queue.api.ts b/src/frontend/apps/web/src/features/chat/api/upload-chunks-queue.api.ts new file mode 100644 index 00000000..06a2fee3 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/api/upload-chunks-queue.api.ts @@ -0,0 +1,69 @@ +import { uploadChunkWithRetry } from './upload-chunk-retry-api'; + +import { ResponseChunkFileData } from '../model'; +import { getUploadConcurrency } from '../lib/chunk-file.utils'; + +/** + * 파일 청크들을 네트워크 상태에 따른 병렬성으로 업로드하는 업로드 대기열 함수입니다. + * 각 청크는 바로 API로 전송됩니다. + * @param chunks 업로드할 청크 배열 + * @param channelId 채널 ID + * @param workspaceId 워크스페이스 ID + * @param tempFileIdentifier 임시 파일 식별자 + * @param chunkSize 청크 사이즈 + * @returns {Promise} 각 청크 업로드 결과 배열 + */ +export const uploadChunksQueue = async ( + chunks: Blob[], + channelId: number, + workspaceId: number, + tempFileIdentifier: string, + chunkSize: number, +): Promise => { + const totalChunk = chunks.length; + const concurrency = await getUploadConcurrency(); + // console.log('Starting upload with concurrency:', concurrency); + const results: ResponseChunkFileData[] = new Array(totalChunk); + let currentIndex = 0; + + async function worker(workerId: number) { + while (currentIndex < totalChunk) { + const index = currentIndex; + currentIndex++; + // console.log(`[worker ${workerId}] Uploading chunk index:`, index); + + try { + results[index] = await uploadChunkWithRetry( + chunks[index], + channelId, + workspaceId, + tempFileIdentifier, + totalChunk, + chunkSize, + index + 1, + ); + // console.log( + // `[worker ${workerId}] Upload success for chunk index:`, + // index, + // ); + } catch (error) { + console.error( + `[worker ${workerId}] Upload failed for chunk index:`, + index, + error, + ); + results[index] = { + code: '500', + status: 'error', + }; + } + } + } + + const workers = []; + for (let i = 0; i < concurrency; i++) { + workers.push(worker(i)); + } + await Promise.all(workers); + return results; +}; diff --git a/src/frontend/apps/web/src/features/chat/api/upload-file.api.ts b/src/frontend/apps/web/src/features/chat/api/upload-file.api.ts new file mode 100644 index 00000000..1ed3be2b --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/api/upload-file.api.ts @@ -0,0 +1,44 @@ +import { ChunkFileData, ResponseChunkFileData } from '../model'; +import { postRequest } from '@/src/shared/services/apis'; + +/** + * 각 청크 데이터를 개별적으로 업로드하는 함수. + * 이 함수는 하나의 청크(ChunkInfo 객체)를 API로 전송합니다. + */ +export async function uploadFiles({ + channelId, + workspaceId, + tempFileIdentifier, + totalChunks, + totalSize, + chunkIndex, + chunk, +}): Promise { + const formData = new FormData(); + + formData.append('channelId', channelId.toString()); + formData.append('workspaceId', workspaceId.toString()); + formData.append('tempFileIdentifier', tempFileIdentifier); + formData.append('totalChunks', totalChunks.toString()); + formData.append('totalSize', totalSize.toString()); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('chunk', chunk); + + // 디버깅용: formData의 모든 키-값 출력 + // for (const [key, value] of formData.entries()) { + // console.log('debugging', key, value); + // } + + try { + const response = await postRequest( + 'file', + '/api/v1/files/chunk', + formData, + ); + console.log('[uploadFiles] Response =>', response); + return response; + } catch (error) { + console.error('[uploadFiles] Request error =>', error); + throw error; + } +} diff --git a/src/frontend/apps/web/src/features/chat/api/upload-smallfile.api.ts b/src/frontend/apps/web/src/features/chat/api/upload-smallfile.api.ts new file mode 100644 index 00000000..3cd26763 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/api/upload-smallfile.api.ts @@ -0,0 +1,36 @@ +import { type ResponseChunkFileData } from '../model'; +import { postRequest } from '@/src/shared/services/apis'; + +/** + * 각 청크 데이터를 개별적으로 업로드하는 함수. + * 이 함수는 하나의 청크(ChunkInfo 객체)를 API로 전송합니다. + */ +export async function uploadSmallFiles({ + channelId, + workspaceId, + file, +}): Promise { + const formData = new FormData(); + + formData.append('channelId', channelId.toString()); + formData.append('workspaceId', workspaceId.toString()); + formData.append('file', file); + + // 디버깅용: formData의 모든 키-값 출력 + // for (const [key, value] of formData.entries()) { + // console.log('debugging', key, value); + // } + + try { + const response = await postRequest( + 'file', + '/api/v1/files/small', + formData, + ); + console.log('[uploadFiles] Response =>', response); + return response; + } catch (error) { + console.error('[uploadFiles] Request error =>', error); + throw error; + } +} diff --git a/src/frontend/apps/web/src/features/chat/api/upload-thumbnail.api.ts b/src/frontend/apps/web/src/features/chat/api/upload-thumbnail.api.ts new file mode 100644 index 00000000..c078ed67 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/api/upload-thumbnail.api.ts @@ -0,0 +1,39 @@ +import type { FileResponse } from '../model'; +import { postRequest } from '@/src/shared/services/apis'; + +type ThumbnailData = { + fileId: number; + thumbnail?: File; +}; + +/** + * 각 청크 데이터를 개별적으로 업로드하는 함수. + * 이 함수는 하나의 청크(ChunkInfo 객체)를 API로 전송합니다. + */ +export async function uploadThumbnail({ + fileId, + thumbnail, +}: ThumbnailData): Promise { + const formData = new FormData(); + + formData.append('fileId', fileId.toString()); + formData.append('thumbnail', thumbnail); + + // 디버깅용: formData의 모든 키-값 출력 + // for (const [key, value] of formData.entries()) { + // console.log('debugging', key, value); + // } + + try { + const response = await postRequest( + 'file', + '/api/v1/files/thumbnail', + formData, + ); + console.log('[uploadFiles] Response =>', response); + return response; + } catch (error) { + console.error('[uploadFiles] Request error =>', error); + throw error; + } +} diff --git a/src/frontend/apps/web/src/features/chat/api/uploadFile.api.ts b/src/frontend/apps/web/src/features/chat/api/uploadFile.api.ts deleted file mode 100644 index ad25ee5a..00000000 --- a/src/frontend/apps/web/src/features/chat/api/uploadFile.api.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { postRequest } from '@/src/shared/services/apis'; - -type FileUploadItem = { - fileTypes: 'IMAGE' | 'VIDEO'; - fileIds: number; -}; - -type FileUploadRequest = { - channelId: number; - workspaceId: number; - files: File[]; - thumbnails: (File | null)[]; -}; - -export async function uploadFiles({ - channelId, - workspaceId, - files, - thumbnails, -}: FileUploadRequest): Promise { - const formData = new FormData(); - - formData.append('channelId', channelId.toString()); - formData.append('workspaceId', workspaceId.toString()); - - files.forEach((file) => formData.append('files', file)); - - // thumbnails 배열의 요소가 null인 경우, 문자열 "null"으로 대체 - thumbnails.forEach((thumb) => - formData.append('thumbnails', thumb === null ? 'null' : thumb), - ); - - for (const [key, value] of formData.entries()) { - console.log('debugging', key, value); - } - - return postRequest( - 'file', - '/api/v1/files', - formData, - ); -} diff --git a/src/frontend/apps/web/src/features/chat/lib/chunk-file.utils.ts b/src/frontend/apps/web/src/features/chat/lib/chunk-file.utils.ts new file mode 100644 index 00000000..4a0215e7 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/lib/chunk-file.utils.ts @@ -0,0 +1,113 @@ +import { getPing } from '../api'; + +let cachedChunkSize: number | null = null; +let cachedUploadConcurrency: number | null = null; +const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; + +/** + * 네트워크 지연 시간에 따라 동적으로 청크 사이즈를 결정합니다. + * @returns {Promise} 결정된 청크 사이즈 (바이트 단위) + */ +export const getDynamicChunkSize = async (): Promise => { + if (cachedChunkSize !== null) { + // console.log('Using cached chunk size:', cachedChunkSize); + return cachedChunkSize; + } + + const start = performance.now(); + try { + await getPing(); + } catch (error) { + console.warn('Network speed test failed, using default chunk size.', error); + cachedChunkSize = DEFAULT_CHUNK_SIZE; + return cachedChunkSize; + } + const end = performance.now(); + const latency = end - start; + // console.log('Network latency:', latency); + + if (latency > 1000) { + cachedChunkSize = 2.5 * 1024 * 1024; + } else if (latency > 500) { + cachedChunkSize = 5 * 1024 * 1024; + } else { + cachedChunkSize = DEFAULT_CHUNK_SIZE; + } + + return cachedChunkSize; +}; + +/** + * 네트워크 지연 시간에 따라 병렬 업로드 개수를 결정합니다. + * 빠른 네트워크에서는 5개, 중간이면 3개, 느리면 1개로 조절합니다. + * @returns {Promise} 동시에 전송할 청크 개수 + */ +export const getUploadConcurrency = async (): Promise => { + if (cachedUploadConcurrency !== null) { + // console.log('Using cached upload concurrency:', cachedUploadConcurrency); + return cachedUploadConcurrency; + } + + const start = performance.now(); + try { + await getPing(); + } catch (error) { + console.warn( + 'Network speed test failed, using default upload concurrency.', + error, + ); + cachedUploadConcurrency = 3; + return cachedUploadConcurrency; + } + const end = performance.now(); + const latency = end - start; + // console.log('Network latency for upload concurrency:', latency); + + if (latency <= 500) { + cachedUploadConcurrency = 5; + } else if (latency <= 1000) { + cachedUploadConcurrency = 3; + } else { + cachedUploadConcurrency = 1; + } + // console.log('Upload concurrency set to:', cachedUploadConcurrency); + return cachedUploadConcurrency; +}; + +/** + * 파일을 동적 청크 사이즈에 따라 청크로 분리합니다. + * @param file 분리할 파일 + * @returns {Promise} 파일 청크들의 배열 + */ +export const createChunks = async (file: File): Promise => { + const chunkSize = await getDynamicChunkSize(); + const chunks: Blob[] = []; + let offset = 0; + + // console.log( + // `Creating chunks for ${file.name}, file size: ${file.size} bytes, chunk size: ${chunkSize} bytes`, + // ); + + while (offset < file.size) { + chunks.push(file.slice(offset, offset + chunkSize)); + offset += chunkSize; + } + + return chunks; +}; + +/** + * 임시 파일 식별자를 생성합니다. + * @param userId 사용자 ID + * @param timestamp 파일 선택 시점의 타임스탬프 (밀리초) + * @param fileIndex 파일의 인덱스 + * @returns {string} 생성된 임시 파일 식별자 + */ +export const generateTempFileIdentifier = ( + userId: number, + timestamp: number, + fileIndex: number, +): string => { + const formattedTimestamp = (timestamp / 1000).toFixed(6); + return `${userId}-${formattedTimestamp}-${fileIndex}`; +}; diff --git a/src/frontend/apps/web/src/features/chat/lib/file.utils.ts b/src/frontend/apps/web/src/features/chat/lib/file.utils.ts index 9dfd4e14..f43a7481 100644 --- a/src/frontend/apps/web/src/features/chat/lib/file.utils.ts +++ b/src/frontend/apps/web/src/features/chat/lib/file.utils.ts @@ -1,128 +1,120 @@ -/** - * 이미지/비디오 파일 최대 용량 (image: 20MB, video: 200MB) 상수 - */ -export const MAX_IMAGE_SIZE = 20 * 1024 * 1024; -export const MAX_VIDEO_SIZE = 200 * 1024 * 1024; - -/** - * 주어진 파일의 사이즈가 제한 범위를 초과하지 않는지 검사하는 함수 - * - * @param file - 검증할 File 객체 - * @returns 제한 범위를 벗어나면 false, 괜찮으면 true - */ -export const validateFileSize = (file: File) => { - if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) { - alert('Only image and video files are supported'); - return false; - } +import { validateFileSize } from './validate-filesize.util'; - if (file.type.startsWith('image/') && file.size > MAX_IMAGE_SIZE) { - alert('Image size should not exceed 20MB'); - return false; - } - if (file.type.startsWith('video/') && file.size > MAX_VIDEO_SIZE) { - alert('Video size should not exceed 200MB'); - return false; - } +import type { ProcessedFile } from '../model'; - return true; -}; +const blobUrlCache = new Map(); -/** - * 다중 선택된 파일들의 총 크기가 1000MB를 넘지 않도록 검증하는 함수 - * - * @param files - 업로드할 파일 배열 - * @returns 제한 초과 시 false 반환, 허용 범위 내면 true 반환 - */ -export const MAX_TOTAL_FILE_SIZE = 1000 * 1024 * 1024; // 1000MB - -export const validateTotalFileSize = (files: File[]) => { - const totalSize = files.reduce((acc, file) => acc + file.size, 0); - - if (totalSize > MAX_TOTAL_FILE_SIZE) { - alert( - `총 파일 크기가 1000MB를 초과할 수 없습니다. (현재: ${formatFileSize(totalSize)})`, - ); - return false; +export const generateVideoThumbnail = async ( + file: File, +): Promise => { + if (blobUrlCache.has(file.name)) { + // console.log(`Using cached blob URL for ${file.name}`); + return new File([blobUrlCache.get(file.name)!], 'thumbnail.webp', { + type: 'image/webp', + }); } - return true; -}; -/** - * 주어진 동영상 파일로부터 썸네일(이미지) File 객체를 생성하는 함수 - * - * @param file - 비디오 File 객체 - * @returns Promise. 성공 시 썸네일 File 객체, 실패 시 null - */ -export const generateVideoThumbnail = (file: File): Promise => { - return new Promise((resolve) => { - const video = document.createElement('video'); - video.autoplay = false; - video.muted = true; - video.preload = 'metadata'; - - const videoUrl = URL.createObjectURL(file); - video.src = videoUrl; - - video.onloadedmetadata = () => { - // 영상의 중간 지점으로 이동하여 썸네일을 생성 - video.currentTime = video.duration / 2; - }; - - video.onseeked = () => { - const canvas = document.createElement('canvas'); - const aspectRatio = video.videoWidth / video.videoHeight; - - // 썸네일의 너비를 300px로 설정하고 높이는 비율에 맞게 계산 - const thumbnailWidth = 300; - const thumbnailHeight = thumbnailWidth / aspectRatio; - - canvas.width = thumbnailWidth; - canvas.height = thumbnailHeight; - - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.drawImage(video, 0, 0, thumbnailWidth, thumbnailHeight); - } - - // canvas.toBlob을 사용해 Blob 객체로 변환 후 File 객체 생성 - canvas.toBlob( - (blob) => { - URL.revokeObjectURL(videoUrl); + return new Promise((resolve, reject) => { + try { + const video = document.createElement('video'); + video.autoplay = false; + video.muted = true; + video.preload = 'metadata'; + + const blobUrl = URL.createObjectURL(file); + blobUrlCache.set(file.name, blobUrl); + video.src = blobUrl; + + // console.log(`Created blob URL for video: ${blobUrl}`); + + const timeout = setTimeout(() => { + console.error('Thumbnail generation timeout.'); + URL.revokeObjectURL(blobUrl); + blobUrlCache.delete(file.name); + reject(new Error('Thumbnail generation timeout')); + }, 7000); + + let frameCaptured = false; + + video.onloadedmetadata = () => { + video.currentTime = Math.min( + video.duration * 0.5, + video.duration - 0.1, + ); + }; + + video.onseeked = async () => { + if (frameCaptured) return; + frameCaptured = true; + + requestAnimationFrame(async () => { + const canvas = document.createElement('canvas'); + const aspectRatio = video.videoWidth / video.videoHeight; + const thumbnailWidth = 300; + const thumbnailHeight = thumbnailWidth / aspectRatio; + + canvas.width = thumbnailWidth; + canvas.height = thumbnailHeight; + + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(video, 0, 0, thumbnailWidth, thumbnailHeight); + } + + const blob = await new Promise((res) => { + canvas.toBlob(res, 'image/webp', 0.8); + }); + + clearTimeout(timeout); + URL.revokeObjectURL(blobUrl); + blobUrlCache.delete(file.name); + if (blob) { - // 생성된 Blob을 기반으로 File 객체 생성 (파일명은 thumbnail.jpg) - const thumbnailFile = new File([blob], 'thumbnail.jpg', { - type: 'image/jpeg', + const thumbnailFile = new File([blob], 'thumbnail.webp', { + type: 'image/webp', }); resolve(thumbnailFile); } else { - console.error('Error generating thumbnail blob'); - resolve(null); + reject(new Error('Blob creation failed')); } - }, - 'image/jpeg', - 0.7, - ); - }; - - video.onerror = () => { - console.error('Error generating video thumbnail'); - URL.revokeObjectURL(videoUrl); - resolve(null); - }; + }); + }; + + video.onerror = (error) => { + console.error('Error generating video thumbnail:', error); + URL.revokeObjectURL(blobUrl); + blobUrlCache.delete(file.name); + clearTimeout(timeout); + reject(error); + }; + } catch (error) { + reject(error); + } }); }; -/** - * 파일 크기를 사람이 읽기 쉬운 형태로 포맷팅하는 함수 - * - * @param bytes - 파일 크기 (바이트 단위) - * @returns 포맷팅된 파일 크기 문자열 (예: '1.23 MB') - */ -export const formatFileSize = (bytes: number) => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -}; +const processedFiles = new Map(); + +export async function processFile(file: File): Promise { + if (!validateFileSize(file)) return null; + + if (processedFiles.has(file.name)) { + return processedFiles.get(file.name)!; + } + + let processedFile: ProcessedFile = { file, thumbnailFile: null }; + + if (file.type.startsWith('video/')) { + try { + const thumbnailFile = await generateVideoThumbnail(file); + processedFile = { file, thumbnailFile }; + } catch (err) { + console.error('Failed to generate video thumbnail:', err); + // 썸네일 실패 시 null + } + } + // 이미지 파일이면 thumbnailFile은 null 그대로 + + processedFiles.set(file.name, processedFile); + return processedFile; +} diff --git a/src/frontend/apps/web/src/features/chat/lib/format-file-size.util.ts b/src/frontend/apps/web/src/features/chat/lib/format-file-size.util.ts new file mode 100644 index 00000000..8b8b62b1 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/lib/format-file-size.util.ts @@ -0,0 +1,7 @@ +export const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; diff --git a/src/frontend/apps/web/src/features/chat/lib/index.ts b/src/frontend/apps/web/src/features/chat/lib/index.ts index 337ec381..1f8e8f29 100644 --- a/src/frontend/apps/web/src/features/chat/lib/index.ts +++ b/src/frontend/apps/web/src/features/chat/lib/index.ts @@ -1,10 +1,18 @@ -export { - validateFileSize, - generateVideoThumbnail, - formatFileSize, -} from './file.utils'; export { formatChatTime } from './format-chat-time.util'; export { processMessages } from './process-message.util'; export { formatDate, formatToKoreanDate } from './format-dates.util'; -export { useSendMessage } from './send-message.util'; -export { processChatHistory } from './process-chat-history.util'; + +export { generateVideoThumbnail, processFile } from './file.utils'; +export { formatFileSize } from './format-file-size.util'; +export { + validateFileSize, + MAX_IMAGE_SIZE, + MAX_VIDEO_SIZE, +} from './validate-filesize.util'; +export { validateFileType } from './validate-filetype.util'; +export { validateTotalFileSize } from './validate-total-filesize.util'; +export { + getDynamicChunkSize, + createChunks, + generateTempFileIdentifier, +} from './chunk-file.utils'; diff --git a/src/frontend/apps/web/src/features/chat/lib/send-message.util.ts b/src/frontend/apps/web/src/features/chat/lib/send-message.util.ts deleted file mode 100644 index fe1f197b..00000000 --- a/src/frontend/apps/web/src/features/chat/lib/send-message.util.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { formatToKoreanDate } from '@/src/features/chat/lib'; -import { useQueryClient } from '@tanstack/react-query'; -import { - useMessages, - useWebSocketClient, - WebSocketResponsePayload, -} from '@/src/features/chat/model'; - -export const useSendMessage = ( - channelId: number, - currentUser: { userId: number; nickname: string; profileImage: string }, -) => { - const queryClient = useQueryClient(); - const { addOptimisticMessage } = useMessages(`/subscribe/chat.${channelId}`); - const { publishMessage } = useWebSocketClient(channelId); - - return (content: string, attachmentList: number[]) => { - if (!content.trim() && attachmentList.length === 0) return; - - const fakeThreadId = Math.floor(Math.random() * 1000000); - const fakeTimestamp = formatToKoreanDate(new Date()); - - const optimisticMessage = { - common: { - channelId, - threadId: fakeThreadId, - fakeThreadId, - threadDateTime: fakeTimestamp, - userId: currentUser.userId, - userNickname: currentUser.nickname, - userProfileImage: currentUser.profileImage, - }, - message: [{ type: 'TEXT' as const, text: content }], - }; - - addOptimisticMessage(optimisticMessage); - - const payload = { - userId: currentUser.userId, - content, - attachmentList, - fakeThreadId, - }; - - publishMessage(payload); - - queryClient.setQueryData( - ['messages', `/subscribe/chat.${channelId}`], - (prevMessages: WebSocketResponsePayload[] = []) => - prevMessages.map((msg) => - msg.common.fakeThreadId === fakeThreadId - ? { ...msg, isOptimistic: false } - : msg, - ), - ); - }; -}; diff --git a/src/frontend/apps/web/src/features/chat/lib/validate-filesize.util.ts b/src/frontend/apps/web/src/features/chat/lib/validate-filesize.util.ts new file mode 100644 index 00000000..24c5163e --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/lib/validate-filesize.util.ts @@ -0,0 +1,22 @@ +export const MAX_IMAGE_SIZE = 20 * 1024 * 1024; +export const MAX_VIDEO_SIZE = 200 * 1024 * 1024; + +export const validateFileSize = (file: File) => { + console.log('File type:', file.type); + + if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) { + alert('Only image and video files are supported'); + return false; + } + + if (file.type.startsWith('image/') && file.size > MAX_IMAGE_SIZE) { + alert('Image size should not exceed 20MB'); + return false; + } + if (file.type.startsWith('video/') && file.size > MAX_VIDEO_SIZE) { + alert('Video size should not exceed 200MB'); + return false; + } + + return true; +}; diff --git a/src/frontend/apps/web/src/features/chat/lib/validate-filetype.util.ts b/src/frontend/apps/web/src/features/chat/lib/validate-filetype.util.ts new file mode 100644 index 00000000..70f0e00c --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/lib/validate-filetype.util.ts @@ -0,0 +1,18 @@ +const FORBIDDEN_IMAGE_TYPES = ['image/svg+xml', 'image/heic', 'image/heif']; + +export const validateFileType = (file: File): boolean => { + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + + if (!isImage && !isVideo) { + alert('Only image and video files are supported'); + return false; + } + + if (FORBIDDEN_IMAGE_TYPES.includes(file.type)) { + alert('SVG, HEIC, HEIF files are not supported'); + return false; + } + + return true; +}; diff --git a/src/frontend/apps/web/src/features/chat/lib/validate-total-filesize.util.ts b/src/frontend/apps/web/src/features/chat/lib/validate-total-filesize.util.ts new file mode 100644 index 00000000..f5a78b69 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/lib/validate-total-filesize.util.ts @@ -0,0 +1,15 @@ +import { formatFileSize } from './format-file-size.util'; + +export const MAX_TOTAL_FILE_SIZE = 1000 * 1024 * 1024; // 1000MB + +export const validateTotalFileSize = (files: File[]) => { + const totalSize = files.reduce((acc, file) => acc + file.size, 0); + + if (totalSize > MAX_TOTAL_FILE_SIZE) { + alert( + `총 파일 크기가 1000MB를 초과할 수 없습니다. (현재: ${formatFileSize(totalSize)})`, + ); + return false; + } + return true; +}; diff --git a/src/frontend/apps/web/src/features/chat/model/file-data.type.ts b/src/frontend/apps/web/src/features/chat/model/file-data.type.ts index bffd4982..b5170104 100644 --- a/src/frontend/apps/web/src/features/chat/model/file-data.type.ts +++ b/src/frontend/apps/web/src/features/chat/model/file-data.type.ts @@ -1,6 +1,29 @@ -export type FileData = { - workspaceId: string; - channelId: string; - file: File[]; - thumbnailUrl?: File[]; +export type ChunkFileData = { + workspaceId: number; + channelId: number; + tempFileIdentifier: string; + totalChunks: number; + totalSize: number; + chunkIndex: number; + chunk: File; +}; + +export type ResponseChunkFileData = FileResponse & { + fileId?: number; + fileType?: string; +}; + +export type ThumbnailData = { + fileId: number; + thumbnail?: File; +}; + +export type FileResponse = { + code: string; + status: string; +}; + +export type ProcessedFile = { + file: File; + thumbnailFile?: File; }; diff --git a/src/frontend/apps/web/src/features/chat/model/index.ts b/src/frontend/apps/web/src/features/chat/model/index.ts index ab64e093..1288d27f 100644 --- a/src/frontend/apps/web/src/features/chat/model/index.ts +++ b/src/frontend/apps/web/src/features/chat/model/index.ts @@ -1,5 +1,13 @@ -export type { FileData } from './file-data.type'; +import { FilePreview } from './use-file-managements'; +export type { + ChunkFileData, + ResponseChunkFileData, + ThumbnailData, + ProcessedFile, + FileResponse, +} from './file-data.type'; export { useFileManagements } from './use-file-managements'; +export type { FilePreview } from './use-file-managements'; export type { SendMessagePayload, WebSocketResponsePayload, @@ -7,6 +15,7 @@ export type { export { useMessages } from './use-messages'; export { useWebSocketClient } from './use-websocket-client'; export { useChatAutoScroll } from './use-chat-autoscroll'; +export { useSendMessage } from './send-message'; export { useReverseInfiniteHistory } from './use-reverse-infinite-history'; export { useForwardInfiniteHistory } from './use-forward-infinite-history'; export type { HistoryResponse, MessageItem } from './chat-data.type'; diff --git a/src/frontend/apps/web/src/features/chat/model/send-message.ts b/src/frontend/apps/web/src/features/chat/model/send-message.ts new file mode 100644 index 00000000..a2c037c3 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/model/send-message.ts @@ -0,0 +1,20 @@ +import { useWebSocketClient } from '@/src/features/chat/model'; + +export const useSendMessage = ( + channelId: number, + currentUser: { userId: number; nickname: string; profileImage: string }, +) => { + const { publishMessage } = useWebSocketClient(channelId); + + return (content: string, attachmentList: number[]) => { + if (!content.trim() && attachmentList.length === 0) return; + + const payload = { + userId: currentUser.userId, + content, + attachmentList, + }; + + publishMessage(payload); + }; +}; diff --git a/src/frontend/apps/web/src/features/chat/model/use-file-managements.ts b/src/frontend/apps/web/src/features/chat/model/use-file-managements.ts index 1fce4520..b9e76fc3 100644 --- a/src/frontend/apps/web/src/features/chat/model/use-file-managements.ts +++ b/src/frontend/apps/web/src/features/chat/model/use-file-managements.ts @@ -1,111 +1,164 @@ -import type { FileData } from './file-data.type'; -import { - validateFileSize, - generateVideoThumbnail, - validateTotalFileSize, -} from '../lib/file.utils'; import { useState } from 'react'; -import { uploadFiles } from '../api/uploadFile.api'; -type ProcessedFile = { +import { + validateFileType, + validateTotalFileSize, + processFile, + createChunks, + generateTempFileIdentifier, +} from '../lib'; +import { uploadChunksQueue, uploadSmallFiles, uploadThumbnail } from '../api'; + +export type FilePreview = { + id: string; file: File; - thumbnailFile: File | null; + previewUrl: string; + thumbnailUrl?: string; + isLoading: boolean; }; -export const useFileManagements = (workspaceId: number, channelId: number) => { - const [selectedFiles, setSelectedFiles] = useState([]); - const [isLoading, setIsLoading] = useState(false); +export function useFileManagements( + workspaceId: number, + channelId: number, + userId: number, +) { + const [filePreviews, setFilePreviews] = useState([]); + const [isFinalLoading, setIsFinalLoading] = useState(false); const [error, setError] = useState(null); - - const processFile = async (file: File): Promise => { - if (!validateFileSize(file)) return null; - - if (file.type.startsWith('image/')) { - // 이미지 파일: thumbnailFile은 null 처리 - return { file, thumbnailFile: null }; - } - - if (file.type.startsWith('video/')) { - try { - const thumbnailFile = await generateVideoThumbnail(file); - return { file, thumbnailFile }; - } catch (err) { - console.error('Failed to generate thumbnail:', err); - return { file, thumbnailFile: null }; - } - } - - return null; - }; + const [uploadedFileIds, setUploadedFileIds] = useState([]); const handleFileChange = async ( event: React.ChangeEvent, ) => { - setIsLoading(true); setError(null); + setIsFinalLoading(true); try { const files = Array.from(event.target.files || []); - // 파일 재선택을 위해 input 초기화 event.target.value = ''; - if (!validateTotalFileSize(files)) { - setIsLoading(false); + // ────────── [1] 불허 타입 체크: 하나라도 불허면 전체 중단 ────────── + for (const file of files) { + if (!validateFileType(file)) { + setIsFinalLoading(false); + return; + } + } + + // ────────── [2] 파일 전체 사이즈 검사 ────────── + const allFiles = [...filePreviews.map((fp) => fp.file), ...files]; + if (!validateTotalFileSize(allFiles)) { + setIsFinalLoading(false); return; } - selectedFiles.forEach((fileData) => { - fileData.file.forEach((file) => { - if (file instanceof File && file.type.startsWith('video/')) { - URL.revokeObjectURL(file.name); - } - }); + // ────────── [3] 미리보기 데이터 생성 ────────── + const timestamp = Date.now(); + const newPreviews: FilePreview[] = files.map((file, index) => { + const id = `${userId}-${timestamp}-${index}`; + return { + id, + file, + previewUrl: URL.createObjectURL(file), + thumbnailUrl: undefined, + isLoading: true, + }; }); - // 선택한 파일들을 순서대로 전처리 - const processedResults = await Promise.all(files.map(processFile)); - const processedFiles = processedResults.filter( - (f): f is ProcessedFile => f !== null, - ); - - // 원래 선택 순서를 유지하여 파일 배열과 썸네일 배열 구성 - const fileArray = processedFiles.map((item) => item.file); - const thumbnailArray = processedFiles.map((item) => item.thumbnailFile); - - // 백엔드 API 호출을 위한 payload 구성 - // API 함수 uploadFiles는 채널, 워크스페이스 ID를 number로 받으므로 변환합니다. - const payload = { - channelId: Number(channelId), - workspaceId: Number(workspaceId), - files: fileArray, - thumbnails: thumbnailArray, - }; - // console.log('Payload:', payload); - - const response = await uploadFiles(payload); - console.log('Upload response:', response); - - // 미리보기 상태(FileData) 생성 및 추가 - const newFileData: FileData = { - workspaceId: workspaceId.toString(), - channelId: channelId.toString(), - file: fileArray, - thumbnailUrl: thumbnailArray.filter( - (thumb): thumb is File => thumb !== null, - ), - }; + setFilePreviews((prev) => [...prev, ...newPreviews]); + + // ────────── [4] 썸네일 생성 (비디오면) ────────── + for (const file of files) { + const processed = await processFile(file); + if (processed?.thumbnailFile) { + const thumbUrl = URL.createObjectURL(processed.thumbnailFile); + setFilePreviews((prev) => + prev.map((fp) => + fp.file.name === file.name + ? { ...fp, thumbnailUrl: thumbUrl } + : fp, + ), + ); + } + } - setSelectedFiles((prev) => [...prev, newFileData]); + // ────────── [5] 업로드 로직 ────────── + for (let i = 0; i < files.length; i++) { + const file = files[i]; + try { + // (A) 10MB 이하 소형 파일 + if (file.size <= 10 * 1024 * 1024) { + const smallUploadRes = await uploadSmallFiles({ + channelId, + workspaceId, + file, + }); + if (smallUploadRes.code === '200' && smallUploadRes.fileId) { + // 비디오인 경우 썸네일 업로드 + if (file.type.startsWith('video/')) { + const processed = await processFile(file); + if (processed?.thumbnailFile) { + await uploadThumbnail({ + fileId: smallUploadRes.fileId, + thumbnail: processed.thumbnailFile, + }); + } + } + setUploadedFileIds((prev) => [...prev, smallUploadRes.fileId]); + } + } else { + // (B) 10MB 초과 -> 청크 업로드 + const chunks = await createChunks(file); + const totalSize = file.size; + const tempFileIdentifier = generateTempFileIdentifier( + userId, + timestamp, + i + 1, + ); + const uploadResponses = await uploadChunksQueue( + chunks, + channelId, + workspaceId, + tempFileIdentifier, + totalSize, + ); + + // 청크 업로드 응답에서 fileId를 찾고, 비디오 썸네일 업로드 + for (const res of uploadResponses) { + if (res.code === '200' && res.fileId && res.fileType) { + const finalFileId = res.fileId; + if (file.type.startsWith('video/')) { + const processed = await processFile(file); + if (processed?.thumbnailFile) { + await uploadThumbnail({ + fileId: finalFileId, + thumbnail: processed.thumbnailFile, + }); + } + } + setUploadedFileIds((prev) => [...prev, finalFileId]); + } + } + } + } finally { + setFilePreviews((prev) => + prev.map((fp) => + fp.file.name === file.name ? { ...fp, isLoading: false } : fp, + ), + ); + } + } } catch (err) { console.error('Error during file processing/upload:', err); setError(err as Error); } finally { - setIsLoading(false); + setIsFinalLoading(false); } }; + // 선택한 파일 삭제 처리 const handleRemoveFile = (index: number) => { - setSelectedFiles((prev) => { + setFilePreviews((prev) => { const newFiles = [...prev]; newFiles.splice(index, 1); return newFiles; @@ -115,9 +168,11 @@ export const useFileManagements = (workspaceId: number, channelId: number) => { return { handleFileChange, handleRemoveFile, - selectedFiles, - setSelectedFiles, - isLoading, + filePreviews, + setFilePreviews, + isFinalLoading, error, + uploadedFileIds, + setUploadedFileIds, }; -}; +} diff --git a/src/frontend/apps/web/src/features/chat/model/use-websocket-client.ts b/src/frontend/apps/web/src/features/chat/model/use-websocket-client.ts index 976234dd..8eca9137 100644 --- a/src/frontend/apps/web/src/features/chat/model/use-websocket-client.ts +++ b/src/frontend/apps/web/src/features/chat/model/use-websocket-client.ts @@ -43,16 +43,7 @@ export const useWebSocketClient = (channelId: number) => { queryClient.setQueryData( QUERY_KEYS.messages(channelId), - (prev: WebSocketResponsePayload[] = []) => { - return prev.map((msg) => - msg.common.fakeThreadId === payload.common.threadId - ? { - ...payload, - common: { ...payload.common, fakeThreadId: undefined }, - } - : msg, - ); - }, + (prev: WebSocketResponsePayload[] = []) => [...prev, payload], ); } catch (error) { console.error('❌ 메시지 파싱 실패:', error); @@ -67,7 +58,7 @@ export const useWebSocketClient = (channelId: number) => { }; const publishMessage = useCallback( - (payload: SendMessagePayload & { fakeThreadId: number }) => { + (payload: SendMessagePayload) => { if (!client || !client.connected) { console.error('❌ WebSocket 연결이 되어 있지 않습니다.'); return; @@ -75,7 +66,6 @@ export const useWebSocketClient = (channelId: number) => { const enrichedPayload = { ...payload, - fakeThreadId: payload.fakeThreadId, }; client.publish({ diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx index 454a16db..affb878c 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-section.tsx @@ -1,13 +1,10 @@ 'use client'; -import { useQueryClient } from '@tanstack/react-query'; - -import { useMessages, useWebSocketClient } from '@/src/features/chat/model'; +import { useMessages } from '@/src/features/chat/model'; import ChatItemList from './chat-message-list'; import ChatTextarea from './chat-textarea'; -import { useSendMessage } from '../lib'; -// import ThreadPanel from './thread-panel'; +import { useSendMessage } from '../model'; const ChatSection = () => { const channelId = 1; @@ -16,12 +13,7 @@ const ChatSection = () => { nickname: 'User', profileImage: 'https://via.placeholder.com/150', }; - const { data: messages, addOptimisticMessage } = useMessages( - `/subscribe/chat.${channelId}`, - ); - const { publishMessage } = useWebSocketClient(channelId); - const queryClient = useQueryClient(); - + const { data: messages } = useMessages(`/subscribe/chat.${channelId}`); const handleSendMessage = useSendMessage(channelId, currentUser); return ( diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx index e0964cbf..6e854db5 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx @@ -1,9 +1,13 @@ 'use client'; import { useRef, useState } from 'react'; + import { Textarea } from '@workspace/ui/components'; + import ChatToggleGroup from './chat-toggle-group'; +import { useFileManagements } from '../model'; + const ChatTextArea = ({ onSend, }: { @@ -13,10 +17,17 @@ const ChatTextArea = ({ const [attachmentList, setAttachmentList] = useState([]); const isComposing = useRef(false); + const fileManagements = useFileManagements(1, 1, 1); + const { setFilePreviews, setUploadedFileIds } = fileManagements; + const handleSendClick = () => { if (!message.trim() && attachmentList.length === 0) return; onSend(message, attachmentList); + // console.log('message', message); + // console.log('attachmentList', attachmentList); setMessage(''); + setFilePreviews([]); + setUploadedFileIds([]); setAttachmentList([]); }; @@ -49,6 +60,7 @@ const ChatTextArea = ({ name="image" onSend={handleSendClick} setAttachmentList={setAttachmentList} + fileManagements={fileManagements} /> diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-toggle-group.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-toggle-group.tsx index bbd19fc9..da9fe8a7 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-toggle-group.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-toggle-group.tsx @@ -5,7 +5,6 @@ import { Button } from '@workspace/ui/components'; import { FilePreviewList } from './file-preview-list'; import { FileUploadTrigger } from './file-upload-trigger'; -import { Loader } from 'lucide-react'; import { useFileManagements } from '../model'; @@ -13,56 +12,57 @@ type ChatToggleGroupsProps = { name: string; onSend?: () => void; setAttachmentList?: (files: number[]) => void; + fileManagements: ReturnType; }; const ChatToggleGroup = ({ name, onSend, setAttachmentList, + fileManagements, }: ChatToggleGroupsProps) => { - const [isUploading, setIsUploading] = useState(false); const { - selectedFiles, + filePreviews, handleFileChange, handleRemoveFile, - isLoading, + isFinalLoading, error, - } = useFileManagements(1, 1); + uploadedFileIds, + } = fileManagements; - // useEffect(() => { - // if (setAttachmentList) { - // setAttachmentList(selectedFiles.map((file) => file.id)); - // } - // }, [selectedFiles, setAttachmentList]); + // console.log('filePreviews', filePreviews); + // console.log('uploadedFileIds', uploadedFileIds); + + useEffect(() => { + if (setAttachmentList) { + setAttachmentList(uploadedFileIds); + } + }, [uploadedFileIds, setAttachmentList]); + + // console.log('toggle-group', filePreviews); return (
{error &&
{error.message}
} - {/* {}} - /> */} +
- {isLoading && ( - - )}
{onSend && ( )}
diff --git a/src/frontend/apps/web/src/features/chat/ui/content-text.tsx b/src/frontend/apps/web/src/features/chat/ui/content-text.tsx index 64b008bb..b88ace67 100644 --- a/src/frontend/apps/web/src/features/chat/ui/content-text.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/content-text.tsx @@ -21,71 +21,90 @@ const ContentText = ({ message, hideUserInfo = false, }: ContentTextProps) => { - console.log('123', message); + // console.log('123', message); const formattedTime = formatChatTime( message.common.threadDateTime, hideUserInfo, ); + console.log('messages!', message); return (
-
+
+ {/* ---- 상단: 닉네임 + 시간 + 뱃지 ---- */} + {!hideUserInfo && ( +
+
+ {message.common.userNickname} +
+
{formattedTime}
+ {type === 'live' && ( + + Live + + )} +
+ )} + + {/* ---- 메시지 내용 (TEXT + IMAGE + VIDEO) ---- */}
- {!hideUserInfo ? ( -
-
- {message.common.userNickname} -
-
{formattedTime}
- {type === 'live' && ( - { + if (msg.type === 'TEXT') { + return ( +
- Live - - )} -
- ) : ( - <> - )} - {message.message.map((msg, idx) => ( -
- {hideUserInfo && ( -
- {formattedTime} -
- )} - {msg.type === 'TEXT' && ( -
- {msg.text.replace(/\\n/g, '\n')} + {hideUserInfo && ( +
+ {formattedTime} +
+ )} +
+ {msg.text.replace(/\\n/g, '\n')} +
- )} - {msg.type === 'IMAGE' && ( - Image - )} - {msg.type === 'VIDEO' && ( - - )} -
- ))} + ); + } + if (msg.type === 'VIDEO') { + return ( + + ); + } + return null; + })} +
diff --git a/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx b/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx index 36e0c724..b877072d 100644 --- a/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/file-modal.tsx @@ -1,6 +1,6 @@ import { Button } from '@workspace/ui/components'; import { Modal } from '@workspace/ui/components/Modal/modal'; -import { FileData } from '../model'; +import { FilePreview } from '../model'; import { formatFileSize } from '../lib'; import Image from 'next/image'; @@ -9,7 +9,7 @@ type FileModalProps = { setIsOpen: (isOpen: boolean) => void; size?: 'default' | 'lg'; className?: string; - fileData: FileData; + fileData: FilePreview; }; const FileModal = ({ @@ -29,29 +29,29 @@ const FileModal = ({
{/* 헤더 */}
- {/*
+
- {fileData.type === 'image' ? '🖼️' : '🎥'} + {fileData.thumbnailUrl === undefined ? '🖼️' : '🎥'}

- {fileData.name} + {fileData.file.name}

- {fileData.type === 'image' ? 'Image' : 'Video'} •{' '} + {fileData.thumbnailUrl === undefined ? 'Image' : 'Video'} •{' '} {formatFileSize(fileData.file.size)}

-
*/} +
{/* 컨텐츠 영역 */}
- {/* {fileData.type === 'image' ? ( + {fileData.thumbnailUrl === undefined ? (
{fileData.name}
- )} */} + )}
{/* 푸터 */} diff --git a/src/frontend/apps/web/src/features/chat/ui/file-preview-image.tsx b/src/frontend/apps/web/src/features/chat/ui/file-preview-image.tsx index fba92733..5503cf59 100644 --- a/src/frontend/apps/web/src/features/chat/ui/file-preview-image.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/file-preview-image.tsx @@ -1,6 +1,6 @@ import Image from 'next/image'; -export const ImagePreivew = ({ +export const ImagePreview = ({ preview, onClick, }: { diff --git a/src/frontend/apps/web/src/features/chat/ui/file-preview-item.tsx b/src/frontend/apps/web/src/features/chat/ui/file-preview-item.tsx index 0c28b2ec..61cfae68 100644 --- a/src/frontend/apps/web/src/features/chat/ui/file-preview-item.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/file-preview-item.tsx @@ -1,30 +1,34 @@ 'use client'; import { useState } from 'react'; -import { CircleX } from 'lucide-react'; +import { CircleX, Loader2 } from 'lucide-react'; -import { ImagePreivew } from './file-preview-image'; +import { ImagePreview } from './file-preview-image'; import { VideoPreview } from './file-preview-video'; import FileModal from './file-modal'; -import type { FileData } from '../model'; +import type { FilePreview } from '../model'; type FilePreviewItemProps = { - fileData: FileData; - onRemove: (id: string) => void; + id: number; + fileData: FilePreview; + onRemove: (id: number) => void; }; export const FilePreviewItem = ({ + id, fileData, onRemove, }: FilePreviewItemProps) => { const [isModalOpen, setIsModalOpen] = useState(false); + console.log(fileData); + return (
- {/*
- {fileData.type === 'image' ? ( - + {fileData.thumbnailUrl === undefined ? ( + setIsModalOpen(true)} /> ) : ( @@ -33,23 +37,33 @@ export const FilePreviewItem = ({ onClick={() => setIsModalOpen(true)} /> )} - { - e.stopPropagation(); - onRemove(fileData.id); - }} - color="#EA4335" - className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity" - /> -
*/} - {/* {isModalOpen && ( + {fileData.isLoading ? ( +
+ +
+ ) : ( + { + e.stopPropagation(); + onRemove(Number(id)); + }} + color="#EA4335" + className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity" + /> + )} +
+ {isModalOpen && ( - )} */} + )}
); }; diff --git a/src/frontend/apps/web/src/features/chat/ui/file-preview-list.tsx b/src/frontend/apps/web/src/features/chat/ui/file-preview-list.tsx index adf01961..2bc737bb 100644 --- a/src/frontend/apps/web/src/features/chat/ui/file-preview-list.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/file-preview-list.tsx @@ -1,11 +1,11 @@ 'use client'; -import type { FileData } from '../model'; +import type { FilePreview } from '../model'; import { FilePreviewItem } from './file-preview-item'; type FilePreviewListProps = { - selectedFiles: FileData[]; - onRemoveFile: (id: string) => void; + selectedFiles: FilePreview[]; + onRemoveFile: (id: number) => void; }; export const FilePreviewList = ({ @@ -16,16 +16,19 @@ export const FilePreviewList = ({ return null; } + // console.log(selectedFiles); + return (
- {/* {selectedFiles.map((fileData) => ( + {selectedFiles.map((fileData, index) => ( - ))} */} + ))}
); diff --git a/src/frontend/apps/web/src/features/chat/ui/file-preview-video.tsx b/src/frontend/apps/web/src/features/chat/ui/file-preview-video.tsx index a20f93a4..416edf43 100644 --- a/src/frontend/apps/web/src/features/chat/ui/file-preview-video.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/file-preview-video.tsx @@ -4,7 +4,7 @@ export const VideoPreview = ({ thumbnailUrl, onClick, }: { - thumbnailUrl: string; + thumbnailUrl: string | null; onClick: () => void; }) => { return ( diff --git a/src/frontend/apps/web/src/shared/services/apis/fetch-instance.api.ts b/src/frontend/apps/web/src/shared/services/apis/fetch-instance.api.ts index 414e1eab..0af71e0b 100644 --- a/src/frontend/apps/web/src/shared/services/apis/fetch-instance.api.ts +++ b/src/frontend/apps/web/src/shared/services/apis/fetch-instance.api.ts @@ -65,8 +65,15 @@ export async function fetchInstance( // body 데이터가 있고, GET 요청이 아닐 때만 body 필드 추가 if (body && method !== 'GET') { - finalOptions.body = - body instanceof FormData ? body : JSON.stringify(body); + if ( + body instanceof FormData || + body instanceof Blob || + body instanceof File + ) { + finalOptions.body = body; + } else { + finalOptions.body = JSON.stringify(body); + } } // API 호출