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
4 changes: 4 additions & 0 deletions src/assets/plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/upload.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/upload_active.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 91 additions & 0 deletions src/components/molecules/comment/CommentInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
ChangeEvent,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { textAreaVariants } from '@/components/atoms/textArea/TextArea';

interface CommentInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight: number;
maxHeight?: number;
maxLineCount?: number;
}

export const CommentInput = ({
value,
onChange,
placeholder,
minHeight,
maxHeight = minHeight,
maxLineCount,
...props
}: CommentInputProps) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [paddingTop, setPaddingTop] = useState<number | null>(null);

const handleTextChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
onChange(event.target.value);
};

useEffect(() => {
if (!wrapperRef.current) return;
const computedStyle = window.getComputedStyle(wrapperRef.current);
setPaddingTop(parseInt(computedStyle.paddingTop, 10) || null);
}, []);

useLayoutEffect(() => {
const textArea = textAreaRef.current;
const wrapper = wrapperRef.current;
if (!textArea || !wrapper || paddingTop === null) return;

const { scrollHeight, clientHeight } = textArea;
textArea.style.height = `${minHeight}px`;

// 한 줄만 있을 때는 minHeight 유지
if (scrollHeight === clientHeight) {
wrapper.style.paddingTop = `${paddingTop}px`;
return;
}

const lineCount = Math.floor(
scrollHeight / parseInt(window.getComputedStyle(textArea).lineHeight, 10),
);

if (maxLineCount && lineCount >= maxLineCount) {
textArea.style.height = `${maxHeight + paddingTop}px`;
wrapper.style.paddingTop = '0px';
} else {
textArea.style.height = `${lineCount === 1 ? minHeight : maxHeight}px`;
wrapper.style.paddingTop = `${paddingTop}px`;
}
}, [value, paddingTop]);

return (
<div
ref={wrapperRef}
className={'flex w-full rounded-md bg-gray-200 px-3 py-2'}
>
<textarea
ref={textAreaRef}
value={value}
onChange={handleTextChange}
placeholder={placeholder}
className={textAreaVariants({
variant: value.trim() ? 'main' : 'none',
})}
style={{
height: `${minHeight}px`,
minHeight: `${minHeight}px`,
maxHeight: maxLineCount ? undefined : `${maxHeight}px`,
}}
{...props}
/>
</div>
);
};
1 change: 1 addition & 0 deletions src/components/molecules/comment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CommentInput } from '@/components/molecules/comment/CommentInput';
24 changes: 24 additions & 0 deletions src/features/comment/component/CommentBox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { CommentBox } from './CommentBox';

const meta: Meta<typeof CommentBox> = {
title: 'Components/CommentBox',
component: CommentBox,
parameters: {
layout: 'fullscreen',
},
};

export default meta;
type Story = StoryObj<typeof CommentBox>;

export const Default: Story = {
args: {
text: '',
},
render: (args) => {
const [text, setText] = useState(args.text);
return <CommentBox {...args} text={text} setText={setText} />;
},
};
38 changes: 38 additions & 0 deletions src/features/comment/component/CommentBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Upload from '@/assets/upload.svg';
import UploadActive from '@/assets/upload_active.svg';
import { CommentInput } from '@/components/molecules/comment';
import { ImageUploader } from './ImageUploader';

interface CommentBoxProps {
text: string;
setText: (text: string) => void;
}

export const CommentBox = ({ text, setText }: CommentBoxProps) => {
const handleUploadText = () => {
// text upload
setText('');
};

return (
<div className='absolute bottom-0 flex w-full items-center justify-between gap-3 bg-gray-100 px-5 py-2'>
<ImageUploader />

<CommentInput
value={text}
onChange={setText}
placeholder='댓글 입력'
minHeight={20}
maxHeight={42}
maxLineCount={3}
/>

<div
className={`cursor-pointer rounded-full p-2 ${text.trim() ? 'bg-primary-400' : 'bg-primary-100'}`}
onClick={handleUploadText}
>
{text.trim() ? <UploadActive /> : <Upload />}
</div>
</div>
);
};
98 changes: 98 additions & 0 deletions src/features/comment/component/ImageUploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useState } from 'react';
import Plus from '@/assets/plus.svg';

export const ImageUploader = () => {
const [images, setImages] = useState<string[]>([]);
const [showGallery, setShowGallery] = useState(false);

const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!event.target.files) return;

const files = Array.from(event.target.files);
const fileArray = files.map((file) => URL.createObjectURL(file));

setImages((prev) => [...prev, ...fileArray]);
setShowGallery(true);
};

const openNativeGallery = () => {
const fileInput = document.getElementById('fileInput') as HTMLInputElement;
if (fileInput) {
fileInput.click();
}
};

const handleDeleteImage = (index: number) => {
setImages((prev) => prev.filter((_, i) => i !== index));
};

const handleGalleryClose = () => {
// 선택한 사진 닫는 기능
setImages([]);
setShowGallery(false);
};

const handleUploadImage = () => {
// upload Image : images 변수에 저장되어 있음
setImages([]);
setShowGallery(false);
};

return (
<>
<input
type='file'
accept='image/*'
multiple
id='fileInput'
className='hidden'
onChange={handleImageUpload}
/>

<div
className='cursor-pointer rounded-full bg-gray-200 p-2'
onClick={openNativeGallery}
>
<Plus />
</div>

{showGallery && (
<div className='absolute bottom-0 left-0 z-50 w-full rounded-t-2xl bg-gray-200 py-5 shadow-lg'>
<div className='flex items-center justify-between px-5 py-[4.5px] text-center text-lg font-medium text-white'>
<button
className='h-8 w-[52px] text-gray-600'
onClick={handleGalleryClose}
>
닫기
</button>
<span>선택한 사진</span>
<button
className='h-8 w-[52px] rounded-full bg-primary-400 text-base'
onClick={handleUploadImage}
>
전송
</button>
</div>

<div className='mt-2 grid grid-cols-3 gap-[3px]'>
{images.map((src, index) => (
<div key={index} className='relative'>
<img
src={src}
alt='업로드 하고자 하는 사진'
className='h-32 w-full object-cover'
/>
<button
className='absolute right-1 top-1 h-6 w-6 rounded-full bg-red-500 text-xs text-white'
onClick={() => handleDeleteImage(index)}
>
</button>
</div>
))}
</div>
</div>
)}
</>
);
};