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 public/icons/camera.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
136 changes: 136 additions & 0 deletions src/features/study/group/ui/group-study-thumbnail-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
'use client';

import Image from 'next/image';
import { useState, DragEvent, ChangeEvent, useRef } from 'react';
import Button from '@/shared/ui/button';

const inputStyles = {
base: 'rounded-100 flex w-full flex-col items-center justify-center rounded-lg border-2 p-500',
dragging: 'border-border-brand bg-fill-brand-subtle-hover',
notDragging: 'border-gray-300 border-gray-300 border-dashed',
};

export default function GroupStudyThumbnailInput({
image,
onChangeImage,
}: {
image?: string;
onChangeImage: (image?: string) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);

const handleOpenFileDialog = () => {
fileInputRef.current?.click();
};

const [isDragging, setIsDragging] = useState(false);

// 영역 안에 드래그 들어왔을 때
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
// 영역 밖으로 드래그 나갈 때
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
console.log('drag leave');
setIsDragging(false);
};
// 영역 안에서 드래그 중일 때
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();

console.log('drag over');

if (e.dataTransfer.files) {
setIsDragging(true);
}
};
// 영역 안에서 drop 했을 때
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
console.log('drop');
setIsDragging(false);

const file = e.dataTransfer.files[0]; // 1장만 허용

if (file && file.type.startsWith('image/')) {
onChangeImage(URL.createObjectURL(file));
}
};

// 파일 업로드 버튼으로 파일 선택했을 때 preview 설정
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];

if (file && file.type.startsWith('image/')) {
onChangeImage(URL.createObjectURL(file));
}
};

const handleRemove = () => {
onChangeImage(undefined);
};

return (
<div
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
className={`${inputStyles.base} ${isDragging ? inputStyles.dragging : inputStyles.notDragging}`}
>
{!image ? (
<div className="flex flex-col items-center justify-center gap-300">
<div className="flex flex-col items-center justify-center gap-150">
<Image
src="/icons/camera.svg"
width={32}
height={32}
alt="파일 업로드"
/>
<span className="font-designer-18m text-text-default">
드래그하여 파일 업로드
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
onClick={handleOpenFileDialog}
color="primary"
size="small"
type="button"
>
파일 업로드
</Button>
</div>
) : (
<div className="relative">
<Image
src={image}
alt="preview"
width={400}
height={300}
className="rounded-lg object-cover"
/>
<button
type="button"
onClick={handleRemove}
className="bg-background-dimmer border-border-inverse text-text-inverse absolute top-0 right-0 flex h-[36px] w-[36px] translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border bg-black"
>
</button>
</div>
)}
</div>
);
}
29 changes: 29 additions & 0 deletions src/stories/ui/group-study-thumbnail-input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import GroupStudyThumbnailInput from '@/features/study/group/ui/group-study-thumbnail-input';

const meta: Meta<typeof GroupStudyThumbnailInput> = {
component: GroupStudyThumbnailInput,
argTypes: {
image: {
description:
'이미지 URL 상태 입니다. (undefined인 경우 이미지가 없는 상태를 의미합니다.)',
},
onChangeImage: {
description:
'이미지 URL 상태를 변경하는 함수입니다. 파라미터는 이미지 URL 또는 undefined 입니다. (undefined인 경우 이미지가 제거된 상태를 의미합니다.)',
},
},
};

export default meta;

type Story = StoryObj<typeof GroupStudyThumbnailInput>;

export const Default: Story = {
render: () => {
const [image, setImage] = useState<string | undefined>(undefined);

return <GroupStudyThumbnailInput image={image} onChangeImage={setImage} />;
},
};