-
Notifications
You must be signed in to change notification settings - Fork 3
파일 업로드 파트 구현 #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
파일 업로드 파트 구현 #109
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
1b65b7c
#68 fix(fe): chat-textarea -> textarea로 통합
KimKyuHoi fc4a08a
#68 fix(fe): 레이아웃 수정
KimKyuHoi 208831a
#68 fix(fe): flex 중복 제거
KimKyuHoi e1ae39b
#68 fix(fe): textarea 제거
KimKyuHoi 8852d6f
#68 feat(fe): 채팅 textarea 구현
KimKyuHoi 05b301f
#68 feat(fe): file 유틸 함수 분리
KimKyuHoi 4a86572
#68 feat(fe): 이미지 preview list 코드 분리 구현
KimKyuHoi 58ede60
#68 feat(fe): upload버튼 부분 트리거 코드 분리
KimKyuHoi e95ecb0
Merge branch 'dev' into fe-feat/fileload
KimKyuHoi 234bf19
#68 fix(fe): DataTable 삭제
KimKyuHoi e9ecb27
Merge branch 'fe-feat/fileload' of https://github.com/sgdevcamp2025/c…
KimKyuHoi 896e87d
#68 fix(fe): type ComponentsPropswithRef 수정
KimKyuHoi 732951d
#68 fix(fe): 이미지랑 비디오 포맷이 아닐경우 제어 로직 구현
KimKyuHoi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
7 changes: 7 additions & 0 deletions
7
src/frontend/apps/web/src/features/chat/model/file-data.type.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export type FileData = { | ||
| id: string; | ||
| file: File; | ||
| preview: string; | ||
| thumbnailUrl?: string; | ||
| type: 'image' | 'video'; | ||
| }; |
91 changes: 91 additions & 0 deletions
91
src/frontend/apps/web/src/features/chat/model/file-upload.util.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import type { FileData } from './file-data.type'; | ||
| import { validateFileSize, generateVideoThumbnail } from './file.utils'; | ||
|
|
||
| type SetFilesState = React.Dispatch<React.SetStateAction<FileData[]>>; | ||
|
|
||
| /** | ||
| * `input[type="file"]`의 `change` 이벤트를 받아서, | ||
| * 선택된 파일들을 기존 상태(state)에 추가 | ||
| * 이미지/비디오 파일의 최대 용량도 검증하며, | ||
| * **비디오 파일**이라면 중간 프레임을 썸네일로 생성해 저장 | ||
| * | ||
| * @remarks | ||
| * - 최대 용량(이미지: 20MB, 영상: 200MB)을 초과한 파일은 무시함. | ||
| * - 동영상은 재생 길이의 **중간 지점**을 썸네일로 사용 | ||
| * | ||
| * @param event - `input[type="file"]`의 `change` 이벤트 | ||
| * @param setSelectedFiles - 파일 목록을 관리하는 React 상태 업데이트 함수 | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * // 예시: 컴포넌트 내부에서 사용 | ||
| * const [selectedFiles, setSelectedFiles] = useState<FileData[]>([]); | ||
| * | ||
| * <input | ||
| * type="file" | ||
| * multiple | ||
| * onChange={(event) => handleFileChangeEvent(event, setSelectedFiles)} | ||
| * /> | ||
| * ``` | ||
| */ | ||
| export const handleFileChangeEvent = async ( | ||
| event: React.ChangeEvent<HTMLInputElement>, | ||
| 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); | ||
| }); | ||
| }; |
74 changes: 74 additions & 0 deletions
74
src/frontend/apps/web/src/features/chat/model/file.utils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> => { | ||
| 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(''); | ||
| }; | ||
| }); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export type { FileData } from './file-data.type'; | ||
| export { validateFileSize, generateVideoThumbnail } from './file.utils'; | ||
| export { handleFileChangeEvent, removeFile } from './file-upload.util'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 4 additions & 7 deletions
11
src/frontend/apps/web/src/features/chat/ui/chat-section.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="flex flex-col w-full rounded-md border bg-secondary border-gray-300 p-2 overflow-auto"> | ||
| <Textarea placeholder="Type your message..." /> | ||
| <div className="w-full px-2 pt-2"> | ||
| <ChatToggleGroup | ||
| name="image" | ||
| onSend={handleSendClick} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default ChatTextArea; |
51 changes: 51 additions & 0 deletions
51
src/frontend/apps/web/src/features/chat/ui/chat-toggle-group.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| 'use client'; | ||
| import { useState, useRef } from 'react'; | ||
|
|
||
| import { Button } from '@workspace/ui/components'; | ||
| import { handleFileChangeEvent, removeFile } from '@/src/features/chat/model'; | ||
|
|
||
| import { FilePreviewList } from './file-preivew-list'; | ||
| import { FileUploadTrigger } from './file-upload-trigger'; | ||
| import type { FileData } from '../model'; | ||
|
|
||
| type ChatToggleGroupsProps = { | ||
| name: string; | ||
| onSend?: () => void; | ||
| }; | ||
|
|
||
| const ChatToggleGroup = ({ name, onSend }: ChatToggleGroupsProps) => { | ||
| const [selectedFiles, setSelectedFiles] = useState<FileData[]>([]); | ||
|
|
||
| const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||
| handleFileChangeEvent(event, setSelectedFiles); | ||
| }; | ||
|
|
||
| const handleRemoveFile = (id: string) => { | ||
| removeFile(id, setSelectedFiles); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="flex flex-col justify-between gap-2"> | ||
| <FilePreviewList | ||
| selectedFiles={selectedFiles} | ||
| onRemoveFile={handleRemoveFile} | ||
| /> | ||
| <div className="flex flex-row justify-between"> | ||
| <FileUploadTrigger | ||
| name={name} | ||
| onFileChange={handleFileChange} | ||
| /> | ||
| {onSend && ( | ||
| <Button | ||
| onClick={onSend} | ||
| size="sm" | ||
| > | ||
| Send | ||
| </Button> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default ChatToggleGroup; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
src/frontend/apps/web/src/features/chat/ui/file-preivew-list.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| 'use client'; | ||
|
|
||
| import type { FileData } from '../model'; | ||
| import { FilePreviewItem } from './file-preview-item'; | ||
|
|
||
| type FilePreviewListProps = { | ||
| selectedFiles: FileData[]; | ||
| onRemoveFile: (id: string) => void; | ||
| }; | ||
|
|
||
| export const FilePreviewList = ({ | ||
| selectedFiles, | ||
| onRemoveFile, | ||
| }: FilePreviewListProps) => { | ||
| if (selectedFiles.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <div className="overflow-x-auto w-full"> | ||
| <div className="flex gap-2 pb-2 w-max"> | ||
| {selectedFiles.map((fileData) => ( | ||
| <FilePreviewItem | ||
| key={fileData.id} | ||
| fileData={fileData} | ||
| onRemove={onRemoveFile} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; |
42 changes: 42 additions & 0 deletions
42
src/frontend/apps/web/src/features/chat/ui/file-preview-item.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| 'use client'; | ||
|
|
||
| import Image from 'next/image'; | ||
| import type { FileData } from '../model'; | ||
|
|
||
| type FilePreviewItemProps = { | ||
| fileData: FileData; | ||
| onRemove: (id: string) => void; | ||
| }; | ||
|
|
||
| export const FilePreviewItem = ({ | ||
| fileData, | ||
| onRemove, | ||
| }: FilePreviewItemProps) => { | ||
| return ( | ||
| <div className="relative group flex-shrink-0"> | ||
| <div className="w-20 h-20 rounded-lg relative"> | ||
| {fileData.type === 'image' ? ( | ||
| <Image | ||
| src={fileData.preview} | ||
| alt="Preview" | ||
| fill | ||
| className="object-cover" | ||
| /> | ||
| ) : ( | ||
| <Image | ||
| src={fileData.thumbnailUrl || ''} | ||
| alt="Video thumbnail" | ||
| fill | ||
| className="object-cover" | ||
| /> | ||
| )} | ||
| <button | ||
| onClick={() => onRemove(fileData.id)} | ||
| className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" | ||
| > | ||
| × | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
훅으로 빼면 예쁠것 같습니다!