diff --git a/src/frontend/apps/web/src/features/chat/model/file.utils.ts b/src/frontend/apps/web/src/features/chat/lib/file.utils.ts similarity index 81% rename from src/frontend/apps/web/src/features/chat/model/file.utils.ts rename to src/frontend/apps/web/src/features/chat/lib/file.utils.ts index 059fd900..7e176e42 100644 --- a/src/frontend/apps/web/src/features/chat/model/file.utils.ts +++ b/src/frontend/apps/web/src/features/chat/lib/file.utils.ts @@ -72,3 +72,17 @@ export const generateVideoThumbnail = (file: File): Promise => { }; }); }; + +/** + * 파일 크기를 사람이 읽기 쉬운 형태로 포맷팅하는 함수 + * + * @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]; +}; diff --git a/src/frontend/apps/web/src/features/chat/lib/index.ts b/src/frontend/apps/web/src/features/chat/lib/index.ts new file mode 100644 index 00000000..9b8ae5a2 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/lib/index.ts @@ -0,0 +1,5 @@ +export { + validateFileSize, + generateVideoThumbnail, + formatFileSize, +} from './file.utils'; 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 172f8489..9498d499 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,5 +1,6 @@ export type FileData = { id: string; + name: string; file: File; preview: string; thumbnailUrl?: string; 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 deleted file mode 100644 index 648e7d46..00000000 --- a/src/frontend/apps/web/src/features/chat/model/file-upload.util.ts +++ /dev/null @@ -1,91 +0,0 @@ -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/index.ts b/src/frontend/apps/web/src/features/chat/model/index.ts index 65645a5e..02cc626f 100644 --- a/src/frontend/apps/web/src/features/chat/model/index.ts +++ b/src/frontend/apps/web/src/features/chat/model/index.ts @@ -1,3 +1,2 @@ export type { FileData } from './file-data.type'; -export { validateFileSize, generateVideoThumbnail } from './file.utils'; -export { handleFileChangeEvent, removeFile } from './file-upload.util'; +export { useFileManagements } from './use-file-managements'; 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 new file mode 100644 index 00000000..d8f111e0 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/model/use-file-managements.ts @@ -0,0 +1,97 @@ +import type { FileData } from './file-data.type'; +import { validateFileSize, generateVideoThumbnail } from '../lib/file.utils'; +import { useState, useEffect } from 'react'; + +/** + * 파일 관리를 위한 커스텀 훅 + * @returns {Object} 파일 관리 관련 메서드와 상태 + * - selectedFiles: 선택된 파일 목록 + * - handleFileChange: 파일 선택 이벤트 핸들러 + * - handleRemoveFile: 파일 제거 메서드 + * - setSelectedFiles: 파일 목록 설정 함수 + */ +export const useFileManagements = () => { + const [selectedFiles, setSelectedFiles] = useState([]); + + const processFile = async (file: File): Promise => { + if (!validateFileSize(file)) return null; + + const fileData: FileData = { + id: Math.random().toString(36).substring(7), + name: file.name, + file, + preview: URL.createObjectURL(file), + type: file.type.startsWith('image/') ? 'image' : 'video', + }; + + if (fileData.type === 'video') { + try { + fileData.thumbnailUrl = await generateVideoThumbnail(file); + } catch (error) { + console.error('Failed to generate thumbnail:', error); + } + } + + return fileData; + }; + + /** + * 파일 선택 이벤트 핸들러 + * @param event - 파일 input 이벤트 + */ + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + setIsLoading(true); + setError(null); + + try { + const files = Array.from(event.target.files || []); + event.target.value = ''; + + const processedFiles = await Promise.all(files.map(processFile)); + + setSelectedFiles((prev) => [...prev, ...processedFiles.filter(Boolean)]); + } catch (error) { + setError(error as Error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + const currentFiles = selectedFiles; + + return () => { + currentFiles.forEach((file) => { + URL.revokeObjectURL(file.preview); + }); + }; + }, [selectedFiles]); + + /** + * 파일 제거 메서드 + * @param id - 제거할 파일의 ID + */ + const handleRemoveFile = (id: string) => { + setSelectedFiles((prev) => { + const fileToRemove = prev.find((file) => file.id === id); + if (fileToRemove) { + URL.revokeObjectURL(fileToRemove.preview); + } + return prev.filter((file) => file.id !== id); + }); + }; + + return { + handleFileChange, + handleRemoveFile, + selectedFiles, + setSelectedFiles, + isLoading, + error, + }; +}; 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 5ab8a889..9db03e82 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 @@ -1,12 +1,13 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useCallback } from 'react'; import { Button } from '@workspace/ui/components'; -import { handleFileChangeEvent, removeFile } from '@/src/features/chat/model'; -import { FilePreviewList } from './file-preivew-list'; +import { FilePreviewList } from './file-preview-list'; import { FileUploadTrigger } from './file-upload-trigger'; -import type { FileData } from '../model'; +import { Loader } from 'lucide-react'; + +import { useFileManagements } from '../model'; type ChatToggleGroupsProps = { name: string; @@ -14,27 +15,41 @@ type ChatToggleGroupsProps = { }; const ChatToggleGroup = ({ name, onSend }: ChatToggleGroupsProps) => { - const [selectedFiles, setSelectedFiles] = useState([]); - - const handleFileChange = (event: React.ChangeEvent) => { - handleFileChangeEvent(event, setSelectedFiles); - }; - - const handleRemoveFile = (id: string) => { - removeFile(id, setSelectedFiles); - }; + const { + selectedFiles, + handleFileChange, + handleRemoveFile, + isLoading, + error, + } = useFileManagements(); return (
+ {error &&
{error.message}
} + handleRemoveFile(id), + [handleRemoveFile], + )} />
- +
+ handleFileChange(event), + [handleFileChange], + )} + /> + {isLoading && ( + + )} +
{onSend && ( +
+
+ + ); +}; + +export default FileModal; 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 new file mode 100644 index 00000000..fba92733 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/ui/file-preview-image.tsx @@ -0,0 +1,19 @@ +import Image from 'next/image'; + +export const ImagePreivew = ({ + preview, + onClick, +}: { + preview: string; + onClick: () => void; +}) => { + return ( + Preview + ); +}; 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 35192107..6a0abeb0 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,6 +1,11 @@ 'use client'; +import { useState } from 'react'; +import { CircleX } from 'lucide-react'; + +import { ImagePreivew } from './file-preview-image'; +import { VideoPreview } from './file-preview-video'; +import FileModal from './file-modal'; -import Image from 'next/image'; import type { FileData } from '../model'; type FilePreviewItemProps = { @@ -12,31 +17,39 @@ export const FilePreviewItem = ({ fileData, onRemove, }: FilePreviewItemProps) => { + const [isModalOpen, setIsModalOpen] = useState(false); + return (
-
+
{fileData.type === 'image' ? ( - Preview setIsModalOpen(true)} /> ) : ( - Video thumbnail setIsModalOpen(true)} /> )} - + { + e.stopPropagation(); + onRemove(fileData.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-preivew-list.tsx b/src/frontend/apps/web/src/features/chat/ui/file-preview-list.tsx similarity index 100% rename from src/frontend/apps/web/src/features/chat/ui/file-preivew-list.tsx rename to src/frontend/apps/web/src/features/chat/ui/file-preview-list.tsx 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 new file mode 100644 index 00000000..a20f93a4 --- /dev/null +++ b/src/frontend/apps/web/src/features/chat/ui/file-preview-video.tsx @@ -0,0 +1,27 @@ +import Image from 'next/image'; + +export const VideoPreview = ({ + thumbnailUrl, + onClick, +}: { + thumbnailUrl: string; + onClick: () => void; +}) => { + return ( +
+ Video thumbnail +
+
+ ▶ +
+
+ ); +}; diff --git a/src/frontend/packages/ui/.storybook/main.ts b/src/frontend/packages/ui/.storybook/main.ts index b4e374e3..e1f48693 100644 --- a/src/frontend/packages/ui/.storybook/main.ts +++ b/src/frontend/packages/ui/.storybook/main.ts @@ -6,7 +6,7 @@ import { join, dirname } from 'path'; * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ -function getAbsolutePath(value: string): any { +function getAbsolutePath(value: string) { return dirname(require.resolve(join(value, 'package.json'))); } const config: StorybookConfig = { diff --git a/src/frontend/packages/ui/src/components/Modal/modal.stories.tsx b/src/frontend/packages/ui/src/components/Modal/modal.stories.tsx new file mode 100644 index 00000000..01941fcf --- /dev/null +++ b/src/frontend/packages/ui/src/components/Modal/modal.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Modal } from './modal'; +import { Button } from '../Button'; +import { useState } from 'react'; + +const meta: Meta = { + title: 'Widget/Modal', + component: Modal, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + size: { + control: 'select', + options: ['default', 'lg'], + description: 'Sets the size of the modal', + }, + className: { + control: 'text', + description: 'Additional CSS classes to apply', + }, + asChild: { + control: 'boolean', + description: 'Whether to merge props onto child element', + }, + isOpen: { + control: 'boolean', + description: 'Controls modal visibility', + }, + onClose: { + action: 'closed', + description: 'Callback when modal should close', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const ModalWithToggle = ({ + size, + className, +}: { + size?: 'default' | 'lg'; + className?: string; +}) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + setIsOpen(false)} + > +
+
+

Modal Title

+ +
+
+

+ This is a modal component rendered with createPortal. + {size === 'lg' ? ' This is a large variant.' : ''} +

+
+
+ + +
+
+
+
+ ); +}; + +// 기본 모달 Story +export const Default: Story = { + render: () => , +}; + +// 큰 사이즈 모달 Story +export const Large: Story = { + render: () => , +}; diff --git a/src/frontend/packages/ui/src/components/Modal/modal.tsx b/src/frontend/packages/ui/src/components/Modal/modal.tsx new file mode 100644 index 00000000..8478b4c6 --- /dev/null +++ b/src/frontend/packages/ui/src/components/Modal/modal.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@workspace/ui/lib/utils'; + +const modalVariants = cva( + 'fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4', + { + variants: { + size: { + default: '[&>div]:max-w-lg', + lg: '[&>div]:max-w-4xl', + }, + }, + defaultVariants: { + size: 'default', + }, + }, +); + +export interface ModalProps + extends React.HTMLAttributes, + VariantProps { + asChild?: boolean; + isOpen?: boolean; + onClose?: () => void; +} + +const Modal = React.forwardRef( + ( + { + className, + size, + asChild = false, + isOpen = false, + onClose, + children, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : 'div'; + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + React.useEffect(() => { + if (!isOpen) return; + + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape' && onClose) { + onClose(); + } + }; + + document.addEventListener('keydown', handleEsc); + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleEsc); + document.body.style.overflow = 'unset'; + }; + }, [isOpen, onClose]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && onClose) { + onClose(); + } + }; + + if (!mounted || !isOpen) return null; + + const modalContent = ( + +
+ {children} +
+
+ ); + + return createPortal(modalContent, document.body); + }, +); + +Modal.displayName = 'Modal'; + +export { Modal, modalVariants };