diff --git a/src/frontend/apps/web/app/(main)/[stockSlug]/page.tsx b/src/frontend/apps/web/app/(main)/[stockSlug]/page.tsx index 05d0bb61..73f38e3c 100644 --- a/src/frontend/apps/web/app/(main)/[stockSlug]/page.tsx +++ b/src/frontend/apps/web/app/(main)/[stockSlug]/page.tsx @@ -18,12 +18,12 @@ export default function StockDetailsPage({ params }) { // console.log(1, params); return ( -
-
+
+
-
+
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 new file mode 100644 index 00000000..172f8489 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/model/file-data.type.ts @@ -0,0 +1,7 @@ +export type FileData = { + id: string; + file: File; + preview: string; + thumbnailUrl?: string; + type: 'image' | 'video'; +}; diff --git a/src/frontend/apps/web/src/features/chat/model/file-upload.util.ts b/src/frontend/apps/web/src/features/chat/model/file-upload.util.ts new file mode 100644 index 00000000..648e7d46 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/model/file-upload.util.ts @@ -0,0 +1,91 @@ +import type { FileData } from './file-data.type'; +import { validateFileSize, generateVideoThumbnail } from './file.utils'; + +type SetFilesState = React.Dispatch>; + +/** + * `input[type="file"]`의 `change` 이벤트를 받아서, + * 선택된 파일들을 기존 상태(state)에 추가 + * 이미지/비디오 파일의 최대 용량도 검증하며, + * **비디오 파일**이라면 중간 프레임을 썸네일로 생성해 저장 + * + * @remarks + * - 최대 용량(이미지: 20MB, 영상: 200MB)을 초과한 파일은 무시함. + * - 동영상은 재생 길이의 **중간 지점**을 썸네일로 사용 + * + * @param event - `input[type="file"]`의 `change` 이벤트 + * @param setSelectedFiles - 파일 목록을 관리하는 React 상태 업데이트 함수 + * + * @example + * ```ts + * // 예시: 컴포넌트 내부에서 사용 + * const [selectedFiles, setSelectedFiles] = useState([]); + * + * handleFileChangeEvent(event, setSelectedFiles)} + * /> + * ``` + */ +export const handleFileChangeEvent = async ( + event: React.ChangeEvent, + setSelectedFiles: SetFilesState, +) => { + const files = Array.from(event.target.files || []); + // 이벤트 초기화 + event.target.value = ''; + + for (const file of files) { + if (!validateFileSize(file)) continue; + + try { + const fileData: FileData = { + id: Math.random().toString(36).substring(7), + file, + preview: URL.createObjectURL(file), + type: file.type.startsWith('image/') ? 'image' : 'video', + }; + + // 비디오라면 썸네일 생성 + if (fileData.type === 'video') { + try { + const thumbnailUrl = await generateVideoThumbnail(file); + if (thumbnailUrl) { + fileData.thumbnailUrl = thumbnailUrl; + } + } catch (error) { + console.error('Failed to generate thumbnail:', error); + } + } + + // 상태에 추가 + setSelectedFiles((prev) => [...prev, fileData]); + } catch (error) { + console.error('Error processing file:', error); + } + } +}; + +/** + * 지정된 `id`를 가진 파일을 상태에서 제거하고, + * 해당 파일에 할당되었던 **object URL**도 해제하여 메모리를 정리 + * + * @param id - 제거할 파일의 고유 식별자 + * @param setSelectedFiles - 파일 목록을 관리하는 React 상태 업데이트 함수 + * + * @example + * ```ts + * // 예시: 특정 파일 삭제 + * removeFile(someFileId, setSelectedFiles); + * ``` + */ +export const removeFile = (id: string, setSelectedFiles: SetFilesState) => { + setSelectedFiles((prev) => { + const fileToRemove = prev.find((file) => file.id === id); + if (fileToRemove) { + URL.revokeObjectURL(fileToRemove.preview); + } + return prev.filter((file) => file.id !== id); + }); +}; diff --git a/src/frontend/apps/web/src/features/chat/model/file.utils.ts b/src/frontend/apps/web/src/features/chat/model/file.utils.ts new file mode 100644 index 00000000..059fd900 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/model/file.utils.ts @@ -0,0 +1,74 @@ +/** + * 이미지/비디오 파일 최대 용량 (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; + } + + 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; +}; + +/** + * 주어진 동영상 파일로부터 썸네일(이미지)을 생성하는 함수 + * + * @param file - 비디오 File 객체 + * @returns Promise. 성공 시 base64 Data URL 문자열, 실패 시 빈 문자열 + */ +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; + + const thumbnailWidth = 300; + const thumbnailHeight = thumbnailWidth / aspectRatio; + + canvas.width = thumbnailWidth; + canvas.height = thumbnailHeight; + + const ctx = canvas.getContext('2d'); + ctx?.drawImage(video, 0, 0, thumbnailWidth, thumbnailHeight); + + URL.revokeObjectURL(videoUrl); + resolve(canvas.toDataURL('image/jpeg', 0.7)); + }; + + video.onerror = () => { + console.error('Error generating video thumbnail'); + URL.revokeObjectURL(videoUrl); + resolve(''); + }; + }); +}; diff --git a/src/frontend/apps/web/src/features/chat/model/index.ts b/src/frontend/apps/web/src/features/chat/model/index.ts new file mode 100644 index 00000000..65645a5e --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/model/index.ts @@ -0,0 +1,3 @@ +export type { FileData } from './file-data.type'; +export { validateFileSize, generateVideoThumbnail } from './file.utils'; +export { handleFileChangeEvent, removeFile } from './file-upload.util'; diff --git a/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx b/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx index b9eaef79..59ad020a 100644 --- a/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx +++ b/src/frontend/apps/web/src/features/chat/ui/chat-container.tsx @@ -3,10 +3,10 @@ import ChatSection from './chat-section'; const ChatContainer = () => { return ( -
+
-
+
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 95db30a1..2f53b0d4 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,16 +1,13 @@ -import { ChatTextarea } from '@workspace/ui/components'; import ChatContent from './chat-content'; +import ChatTextarea from './chat-textarea'; const ChatSection = () => { 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 new file mode 100644 index 00000000..f8e5c9b7 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Textarea } from '@workspace/ui/components'; +import ChatToggleGroup from './chat-toggle-group'; + +const ChatTextArea = () => { + const handleSendClick = () => alert('Send clicked'); + + return ( +
+