Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/frontend/apps/web/app/(main)/[stockSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export default function StockDetailsPage({ params }) {
// console.log(1, params);

return (
<div className="flex bg-gray-700 py-6 px-[30px] h-full">
<div className="pr-2 w-[45vw]">
<div className="flex bg-gray-700 py-6 px-[30px] h-full w-full min-w-0 min-h-0">
<div className="pr-2 basis-[45%] flex-shrink-0 min-w-0">
<StockDetailLayout />
</div>

<div className="pl-2 w-[55vw]">
<div className="pl-2 basis-[55%] flex-shrink-0 min-w-0">
<ChatContainer />
</div>
</div>
Expand Down
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 src/frontend/apps/web/src/features/chat/model/file-upload.util.ts
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 src/frontend/apps/web/src/features/chat/model/file.utils.ts
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('');
};
});
};
3 changes: 3 additions & 0 deletions src/frontend/apps/web/src/features/chat/model/index.ts
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';
4 changes: 2 additions & 2 deletions src/frontend/apps/web/src/features/chat/ui/chat-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import ChatSection from './chat-section';

const ChatContainer = () => {
return (
<div className="flex h-full bg-white">
<div className="flex w-full h-full bg-white">
<aside className="w-44 h-full bg-gray-400 flex-shrink-0">sidebar</aside>

<div className="flex flex-col w-full h-full">
<div className="flex flex-col min-w-0 min-h-0 w-full h-full">
<ChatHeader />
<ChatSection />
</div>
Expand Down
11 changes: 4 additions & 7 deletions src/frontend/apps/web/src/features/chat/ui/chat-section.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { ChatTextarea } from '@workspace/ui/components';
import ChatContent from './chat-content';
import ChatTextarea from './chat-textarea';

const ChatSection = () => {
return (
<div className="flex flex-1 flex-col h-full">
<div className="flex flex-1 flex-col h-full overflow-y-auto">
<div className="flex flex-1 flex-col w-full h-full">
<div className="flex flex-1 flex-col w-full h-full overflow-y-auto">
<ChatContent />
</div>
<ChatTextarea
onSend=""
onAdd=""
/>
<ChatTextarea />
</div>
);
};
Expand Down
22 changes: 22 additions & 0 deletions src/frontend/apps/web/src/features/chat/ui/chat-textarea.tsx
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 src/frontend/apps/web/src/features/chat/ui/chat-toggle-group.tsx
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[]>([]);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

훅으로 빼면 예쁠것 같습니다!


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;
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const ContentAvatar = ({ type }: ContentAvatarProps) => {
return (
<>
{type === 'live' ? (
<div className="bg-primary flex justify-center items-center flex h-10 w-10 shrink-0 overflow-hidden rounded-md">
<div className="bg-primary flex justify-center items-center h-10 w-10 shrink-0 overflow-hidden rounded-md">
<Headset
size={24}
color="#fff"
Expand Down
32 changes: 32 additions & 0 deletions src/frontend/apps/web/src/features/chat/ui/file-preivew-list.tsx
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 src/frontend/apps/web/src/features/chat/ui/file-preview-item.tsx
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>
);
};
Loading
Loading